diff --git a/gulpfile.js/index.js b/gulpfile.js/index.js index ba009110e6..0615544704 100644 --- a/gulpfile.js/index.js +++ b/gulpfile.js/index.js @@ -645,7 +645,7 @@ function inlineTextRequire(file, content, srcDir, isDevBuild = true) { const requireFragments = extractRequireTextFragments(content); for (const {requirePath, requireStatement} of requireFragments) { let filePath = srcDir + requirePath; - if(requirePath.startsWith("./")) { + if(requirePath.startsWith("./") || requirePath.startsWith("../")) { filePath = path.join(path.dirname(file), requirePath); } let textContent = textContentMap[getKey(filePath, isDevBuild)]; @@ -663,7 +663,7 @@ function inlineTextRequire(file, content, srcDir, isDevBuild = true) { textContentMap[getKey(filePath, isDevBuild)] = fileContent; textContent = fileContent; } - if((requirePath.endsWith(".js") && !requirePath.includes("./")) // js files that are relative paths are ok + if((requirePath.endsWith(".js") && !requirePath.includes("./") && !requirePath.includes("../")) // js files that are relative paths are ok || excludeSuffixPathsInlining.some(ext => requirePath.endsWith(ext))) { console.warn("Not inlining JS/JSON file:", requirePath, filePath); if(filePath.includes("phoenix-pro")) { diff --git a/src/brackets.js b/src/brackets.js index c3a0b9d261..75aabed6d2 100644 --- a/src/brackets.js +++ b/src/brackets.js @@ -139,8 +139,6 @@ define(function (require, exports, module) { require("widgets/InlineMenu"); require("thirdparty/tinycolor"); require("utils/LocalizationUtils"); - require("services/login-desktop"); - require("services/login-browser"); // DEPRECATED: In future we want to remove the global CodeMirror, but for now we // expose our required CodeMirror globally so as to avoid breaking extensions in the diff --git a/src/command/DefaultMenus.js b/src/command/DefaultMenus.js index 31578dad57..4798f1d166 100644 --- a/src/command/DefaultMenus.js +++ b/src/command/DefaultMenus.js @@ -267,9 +267,6 @@ define(function (require, exports, module) { menu.addMenuItem(Commands.HELP_SUPPORT); menu.addMenuDivider(); menu.addMenuItem(Commands.HELP_GET_PRO); - if(Phoenix.isNativeApp) { - menu.addMenuItem(Commands.HELP_MANAGE_LICENSES); - } menu.addMenuDivider(); if (brackets.config.suggest_feature_url) { menu.addMenuItem(Commands.HELP_SUGGEST); diff --git a/src/extensionsIntegrated/Phoenix-live-preview/main.js b/src/extensionsIntegrated/Phoenix-live-preview/main.js index bd6187d630..bcf03f6c17 100644 --- a/src/extensionsIntegrated/Phoenix-live-preview/main.js +++ b/src/extensionsIntegrated/Phoenix-live-preview/main.js @@ -73,7 +73,6 @@ define(function (require, exports, module) { panelHTML = require("text!./panel.html"), Dialogs = require("widgets/Dialogs"), DefaultDialogs = require("widgets/DefaultDialogs"), - ProDialogs = require("services/pro-dialogs"), utils = require('./utils'); const KernalModeTrust = window.KernalModeTrust; @@ -163,12 +162,17 @@ define(function (require, exports, module) { let connectingOverlayTimer = null; // this is needed as we show the connecting overlay after 3s let connectingOverlayTimeDuration = 3000; + function _getLiveEditEntitlement() { + // in community edition, this may not be present; + return KernalModeTrust.EntitlementsManager && KernalModeTrust.EntitlementsManager.getLiveEditEntitlement(); + } + let isProEditUser = false; // this is called everytime there is a change in entitlements async function _entitlementsChanged() { try { - const entitlement = await KernalModeTrust.EntitlementsManager.getLiveEditEntitlement(); - isProEditUser = entitlement.activated; + const entitlement = await _getLiveEditEntitlement(); + isProEditUser = entitlement && entitlement.activated; } catch (error) { console.error("Error updating pro user status:", error); isProEditUser = false; @@ -411,7 +415,12 @@ define(function (require, exports, module) { LiveDevelopment.setMode(LiveDevelopment.CONSTANTS.LIVE_HIGHLIGHT_MODE); } else if (index === 2) { if (!LiveDevelopment.setMode(LiveDevelopment.CONSTANTS.LIVE_EDIT_MODE)) { - ProDialogs.showProUpsellDialog(ProDialogs.UPSELL_TYPE_LIVE_EDIT); + if(KernalModeTrust.ProDialogs) { + KernalModeTrust.ProDialogs.showProUpsellDialog( + KernalModeTrust.ProDialogs.UPSELL_TYPE_LIVE_EDIT); + } else { + Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "proUpsellDlg", "fail"); + } } } else if (item === Strings.LIVE_PREVIEW_EDIT_HIGHLIGHT_ON) { // Don't allow edit highlight toggle if edit features are not active @@ -1313,7 +1322,8 @@ define(function (require, exports, module) { _projectOpened(); if(!Phoenix.isSpecRunnerWindow){ _entitlementsChanged(); - KernalModeTrust.EntitlementsManager.on( + // in community edition EntitlementsManager may be null + KernalModeTrust.EntitlementsManager && KernalModeTrust.EntitlementsManager.on( KernalModeTrust.EntitlementsManager.EVENT_ENTITLEMENTS_CHANGED, _entitlementsChanged ); diff --git a/src/extensionsIntegrated/Phoenix/guided-tour.js b/src/extensionsIntegrated/Phoenix/guided-tour.js index 217f70be45..ccbc40775b 100644 --- a/src/extensionsIntegrated/Phoenix/guided-tour.js +++ b/src/extensionsIntegrated/Phoenix/guided-tour.js @@ -235,13 +235,23 @@ define(function (require, exports, module) { PhStore.setItem(GUIDED_TOUR_LOCAL_STORAGE_KEY, JSON.stringify(userAlreadyDidAction)); } + function _isLoggedIn() { + // in community edition entitlements for pro is null + return KernalModeTrust.EntitlementsManager && KernalModeTrust.EntitlementsManager.isLoggedIn(); + } + + function _isPaidSubscriber() { + // in community edition entitlements for pro is null + return KernalModeTrust.EntitlementsManager && KernalModeTrust.EntitlementsManager.isPaidSubscriber(); + } + async function _resolvePowerUserSurveyURL(surveyJson) { try { - const isLoggedIn = KernalModeTrust.EntitlementsManager.isLoggedIn(); + const isLoggedIn = _isLoggedIn(); if(!isLoggedIn) { return surveyJson.powerUser; } - const paidSubscriber = await KernalModeTrust.EntitlementsManager.isPaidSubscriber(); + const paidSubscriber = await _isPaidSubscriber(); if(paidSubscriber && surveyJson.powerUserPaid) { return surveyJson.powerUserPaid; } diff --git a/src/help/HelpCommandHandlers.js b/src/help/HelpCommandHandlers.js index 41b5012593..ea240d748c 100644 --- a/src/help/HelpCommandHandlers.js +++ b/src/help/HelpCommandHandlers.js @@ -31,7 +31,6 @@ define(function (require, exports, module) { NativeApp = require("utils/NativeApp"), Strings = require("strings"), StringUtils = require("utils/StringUtils"), - ManageLicenses = require("services/manage-licenses"), AboutDialogTemplate = require("text!htmlContent/about-dialog.html"), ContributorsTemplate = require("text!htmlContent/contributors-list.html"), Mustache = require("thirdparty/mustache/mustache"); @@ -174,7 +173,6 @@ define(function (require, exports, module) { CommandManager.register(Strings.CMD_GET_PRO, Commands.HELP_GET_PRO, _handleLinkMenuItem(brackets.config.purchase_url), { htmlName: getProString }); - CommandManager.register(Strings.CMD_MANAGE_LICENSES, Commands.HELP_MANAGE_LICENSES, ManageLicenses.showManageLicensesDialog); CommandManager.register(Strings.CMD_SUGGEST, Commands.HELP_SUGGEST, _handleLinkMenuItem(brackets.config.suggest_feature_url)); CommandManager.register(Strings.CMD_REPORT_ISSUE, Commands.HELP_REPORT_ISSUE, _handleLinkMenuItem(brackets.config.report_issue_url)); CommandManager.register(Strings.CMD_RELEASE_NOTES, Commands.HELP_RELEASE_NOTES, _handleLinkMenuItem(brackets.config.release_notes_url)); diff --git a/src/services/EntitlementsManager.js b/src/services/EntitlementsManager.js deleted file mode 100644 index f4ec73ff4c..0000000000 --- a/src/services/EntitlementsManager.js +++ /dev/null @@ -1,388 +0,0 @@ -/* - * 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"), - AIControl = require("./ai-control"), - UserNotifications = require("./UserNotifications"), - Strings = require("strings"), - StringUtils = require("utils/StringUtils"); - - 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 EntitlementsManager = {}; - EventDispatcher.makeEventDispatcher(EntitlementsManager); - // Set up KernalModeTrust.Entitlements - KernalModeTrust.EntitlementsManager = EntitlementsManager; - - // 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 && 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); - }); - } - - let _entitlementFnForTests; - let effectiveEntitlementsCached = undefined; // entitlements can be null and its valid if no login/trial - async function _getEffectiveEntitlements() { - if(_entitlementFnForTests){ - return _entitlementFnForTests(); - } - if(effectiveEntitlementsCached !== undefined){ - return effectiveEntitlementsCached; - } - const entitlements = await LoginService.getEffectiveEntitlements(); - effectiveEntitlementsCached = entitlements; - return entitlements; - } - - /** - * Get the plan details from entitlements with fallback to free plan defaults. If the user is - * in pro trial(isInProTrial API), then isSubscriber will always be true as we need to treat - * user as subcriber. 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 _getEffectiveEntitlements(); - - if (entitlements && entitlements.plan) { - return entitlements.plan; - } - - // Fallback to free plan defaults - const currentDate = Date.now(); - return { - isSubscriber: false, - paidSubscriber: false, - name: Strings.USER_FREE_PLAN_NAME_DO_NOT_TRANSLATE, - validTill: currentDate + (FREE_PLAN_VALIDITY_DAYS * MS_IN_DAY) - }; - } - - /** - * Checks if the current user is a paid subscriber (has purchased a plan, not trial users) - * - * @return {Promise} A promise that resolves to true if the user is a paid subscriber, false otherwise. - */ - async function isPaidSubscriber() { - if(!isLoggedIn()) { - return false; - } - const planDetails = await getPlanDetails(); - return !!planDetails.paidSubscriber; - } - - /** - * Check if user is in a pro trial. IF the user is in pro trail, then `plan.isSubscriber` will always be true. - * @returns {Promise} True if user is in pro trial, false otherwise - */ - async function isInProTrial() { - const entitlements = await _getEffectiveEntitlements(); - return !!(entitlements && entitlements.isInProTrial); - } - - /** - * Get remaining trial days - * @returns {Promise} Number of remaining trial days - */ - async function getTrialRemainingDays() { - const entitlements = await _getEffectiveEntitlements(); - return entitlements && entitlements.trialDaysRemaining ? entitlements.trialDaysRemaining : 0; - } - - /** - * Get current raw entitlements. Should not be used directly, use individual feature entitlement instead - * like getLiveEditEntitlement. Raw entitlements are server set and will not contain trial info. - * @returns {Promise} Raw entitlements object or null - */ - async function getRawEntitlements() { - return await LoginService.getEntitlements(); - } - - /** - * Get notifications array from entitlements. Uses cached entitlements when available. - * Notifications are used to display in-app promotional messages, alerts, or announcements to users. - * - * @returns {Promise} Array of notification objects, empty array if no notifications available - * - * @description Each notification object has the following structure: - * ```javascript - * { - * notificationID: string, // Unique UUID to track if notification was shown - * title: string, // Notification title - * htmlContent: string, // HTML content of the notification message - * validTill: number, // Timestamp when notification expires - * options: { - * autoCloseTimeS: number, // Optional: Time in seconds for auto-close. Default: never - * dismissOnClick: boolean, // Optional: Close on click. Default: true - * toastStyle: string // Optional: Style class (NOTIFICATION_STYLES_CSS_CLASS.INFO, etc.) - * } - * } - * ``` - */ - async function getNotifications() { - const entitlements = await _getEffectiveEntitlements(); - - if (entitlements && entitlements.notifications) { - return entitlements.notifications; - } - return []; - } - - /** - * 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 EntitlementsManager.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 _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 - }; - } - - /** - * Get AI is enabled for user, based on his logged in pro-user/trial status. - * - * @returns {Promise} AI entitlement object with the following shape: - * @returns {Promise} entitlement.activated - If true, enable AI features. If false, check upsellDialog. - * @returns {Promise} [entitlement.needsLogin] - If true, user needs to login first. - * @returns {Promise} [entitlement.aiBrandName] - The brand name used for AI. Eg: `Phoenix AI` - * @returns {Promise} [entitlement.buyURL] - URL to subscribe/purchase if not activated. Can be null if AI - * is not purchasable. - * @returns {Promise} [entitlement.upgradeToPlan] - Plan name that includes AI entitlement - * @returns {Promise} [entitlement.validTill] - Timestamp when entitlement expires (if from server) - * @returns {Promise} [entitlement.upsellDialog] - Dialog configuration if user needs to be shown an upsell. - * Only present when activated is false. - * @returns {Promise} [entitlement.upsellDialog.title] - Dialog title - * @returns {Promise} [entitlement.upsellDialog.message] - Dialog message - * @returns {Promise} [entitlement.upsellDialog.buyURL] - Purchase URL. If present, dialog shows - * "Get AI Access" button. If absent, shows only OK button. - * - * @example - * const aiEntitlement = await EntitlementsManager.getAIEntitlement(); - * if (aiEntitlement.activated) { - * // Enable AI features - * enableAIFeature(); - * } else if (aiEntitlement.upsellDialog) { - * // Show upsell dialog when user tries to use AI - * promotions.showAIUpsellDialog(aiEntitlement); - * } - */ - async function getAIEntitlement() { - if(!isLoggedIn()) { - return { - needsLogin: true, - activated: false, - upsellDialog: { - title: Strings.AI_LOGIN_DIALOG_TITLE, - message: Strings.AI_LOGIN_DIALOG_MESSAGE - // no buy url as it is a sign in hint. only ok button will be there in this dialog. - } - }; - } - const aiControlStatus = await EntitlementsManager.getAIControlStatus(); - if(!aiControlStatus.aiEnabled) { - return { - activated: false, - upsellDialog: { - title: Strings.AI_DISABLED_DIALOG_TITLE, - // Eg. AI is disabled by school admin/root user. - // no buyURL as ai is disabled explicitly. only ok button will be there in this dialog. - message: aiControlStatus.message || Strings.AI_CONTROL_ADMIN_DISABLED - } - }; - } - const defaultAIBrandName = brackets.config.ai_brand_name, - defaultPurchaseURL = brackets.config.purchase_url, - defaultUpsellTitle = StringUtils.format(Strings.AI_UPSELL_DIALOG_TITLE, defaultAIBrandName), - defaultUpsellMessage = StringUtils.format(Strings.AI_UPSELL_DIALOG_MESSAGE, defaultAIBrandName); - const entitlements = await _getEffectiveEntitlements(); - if(!entitlements || !entitlements.entitlements || !entitlements.entitlements.aiAgent) { - return { - activated: false, - aiBrandName: defaultAIBrandName, - buyURL: defaultPurchaseURL, - upgradeToPlan: defaultAIBrandName, - upsellDialog: { - title: defaultUpsellTitle, - message: defaultUpsellMessage, - buyURL: defaultPurchaseURL - } - }; - } - const aiEntitlement = entitlements.entitlements.aiAgent; - // entitlements.entitlements.aiAgent: { - // activated: boolean, - // aiBrandName: string, - // subscribeURL: string, - // upgradeToPlan: string, - // validTill: number, - // upsellDialog: { - // title: "if activated is false, server can send a custom upsell dialog to show", - // message: "this is the message to show", - // buyURL: "if this url is present from server, this will be shown to as buy link" - // } - // } - - if(aiEntitlement.activated) { - return { - activated: true, - aiBrandName: aiEntitlement.aiBrandName, - buyURL: aiEntitlement.subscribeURL, - upgradeToPlan: aiEntitlement.upgradeToPlan, - validTill: aiEntitlement.validTill - // no upsellDialog, as it need not be shown. - }; - } - - const upsellTitle = StringUtils.format(Strings.AI_UPSELL_DIALOG_TITLE, - aiEntitlement.aiBrandName || defaultAIBrandName); - const upsellMessage = StringUtils.format(Strings.AI_UPSELL_DIALOG_MESSAGE, - aiEntitlement.aiBrandName || defaultAIBrandName); - const upsellDialog = aiEntitlement.upsellDialog || {}; - return { - activated: false, - aiBrandName: aiEntitlement.aiBrandName, - buyURL: aiEntitlement.subscribeURL || defaultPurchaseURL, - upgradeToPlan: aiEntitlement.upgradeToPlan, - validTill: aiEntitlement.validTill, - upsellDialog: { - title: upsellDialog.title || upsellTitle, - message: upsellDialog.message || upsellMessage, - buyURL: upsellDialog.buyURL || aiEntitlement.subscribeURL || defaultPurchaseURL - } - }; - } - - 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() { - effectiveEntitlementsCached = undefined; - EntitlementsManager.trigger(EVENT_ENTITLEMENTS_CHANGED); - }); - AIControl.init(); - UserNotifications.init(); - } - - // Test-only exports for integration testing - if (Phoenix.isTestWindow) { - window._test_entitlements_exports = { - EntitlementsService: EntitlementsManager, - isLoggedIn, - getPlanDetails, - isPaidSubscriber, - isInProTrial, - getTrialRemainingDays, - getRawEntitlements, - getNotifications, - getLiveEditEntitlement, - loginToAccount, - simulateEntitlementForTests: (entitlementsFn) => { - _entitlementFnForTests = entitlementsFn; - EntitlementsManager.trigger(EVENT_ENTITLEMENTS_CHANGED); - } - }; - } - - exports.init = init; - // no public exports to prevent extension tampering - - // Add functions to secure exports. These can be accessed via `KernalModeTrust.Entitlements.*` - EntitlementsManager.isLoggedIn = isLoggedIn; - EntitlementsManager.loginToAccount = loginToAccount; - EntitlementsManager.getPlanDetails = getPlanDetails; - EntitlementsManager.isPaidSubscriber = isPaidSubscriber; - EntitlementsManager.isInProTrial = isInProTrial; - EntitlementsManager.getTrialRemainingDays = getTrialRemainingDays; - EntitlementsManager.getRawEntitlements = getRawEntitlements; - EntitlementsManager.getNotifications = getNotifications; - EntitlementsManager.getLiveEditEntitlement = getLiveEditEntitlement; - EntitlementsManager.getAIEntitlement = getAIEntitlement; - EntitlementsManager.EVENT_ENTITLEMENTS_CHANGED = EVENT_ENTITLEMENTS_CHANGED; -}); diff --git a/src/services/UserNotifications.js b/src/services/UserNotifications.js deleted file mode 100644 index 98888fbe11..0000000000 --- a/src/services/UserNotifications.js +++ /dev/null @@ -1,300 +0,0 @@ -/* - * 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. - * - */ - -/** - * User Notifications Service - * - * This module handles server-sent notifications from the entitlements API. - * Notifications are displayed as toast messages and acknowledged back to the server. - */ - -define(function (require, exports, module) { - const KernalModeTrust = window.KernalModeTrust; - if(!KernalModeTrust){ - throw new Error("UserNotifications should have access to KernalModeTrust. Cannot boot without trust ring"); - } - - const PreferencesManager = require("preferences/PreferencesManager"), - NotificationUI = require("widgets/NotificationUI"); - - const PREF_NOTIFICATIONS_SHOWN_LIST = "notificationsShownList"; - PreferencesManager.stateManager.definePreference(PREF_NOTIFICATIONS_SHOWN_LIST, "object", {}); - - let EntitlementsManager; - let LoginService; - - // In-memory tracking to prevent duplicate notifications during rapid EVENT_ENTITLEMENTS_CHANGED events - const currentlyShownNotifications = new Set(); - - // Save a copy of window.fetch so that extensions won't tamper with it - let fetchFn = window.fetch; - - /** - * Get the list of notification IDs that have been shown and acknowledged - * @returns {Object} Map of notificationID -> timestamp - */ - function getShownNotifications() { - return PreferencesManager.stateManager.get(PREF_NOTIFICATIONS_SHOWN_LIST) || {}; - } - - /** - * Mark a notification as shown and acknowledged - * @param {string} notificationID - The notification ID to mark as shown - */ - function markNotificationAsShown(notificationID) { - const shownNotifications = getShownNotifications(); - shownNotifications[notificationID] = Date.now(); - PreferencesManager.stateManager.set(PREF_NOTIFICATIONS_SHOWN_LIST, shownNotifications); - currentlyShownNotifications.delete(notificationID); - } - - /** - * Call the server API to acknowledge a notification - * @param {string} notificationID - The notification ID to acknowledge - * @returns {Promise} - True if successful, false otherwise - */ - async function acknowledgeNotificationToServer(notificationID) { - try { - const accountBaseURL = LoginService.getAccountBaseURL(); - let url = `${accountBaseURL}/notificationAcknowledged`; - - const requestBody = { - notificationID: notificationID - }; - - let fetchOptions = { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json' - }, - body: JSON.stringify(requestBody) - }; - - // Handle different authentication methods for browser vs desktop - if (Phoenix.isNativeApp) { - // Desktop app: use appSessionID and validationCode - const profile = LoginService.getProfile(); - if (profile && profile.apiKey && profile.validationCode) { - requestBody.appSessionID = profile.apiKey; - requestBody.validationCode = profile.validationCode; - fetchOptions.body = JSON.stringify(requestBody); - } - } else { - // Browser app: use session cookies - fetchOptions.credentials = 'include'; - } - - const response = await fetchFn(url, fetchOptions); - - if (response.ok) { - const result = await response.json(); - if (result.isSuccess) { - console.log(`Notification ${notificationID} acknowledged successfully`); - return true; - } - } - - console.warn(`Failed to acknowledge notification ${notificationID}:`, response.status); - return false; - } catch (error) { - console.error(`Error acknowledging notification ${notificationID}:`, error); - return false; - } - } - - /** - * Handle notification dismissal - * @param {string} notificationID - The notification ID that was dismissed - */ - async function handleNotificationDismiss(notificationID) { - // Always mark as shown locally to prevent re-showing, even if API fails - markNotificationAsShown(notificationID); - - // Call server API to acknowledge - return acknowledgeNotificationToServer(notificationID); - } - - /** - * Check if a notification should be shown - * @param {Object} notification - The notification object from server - * @returns {boolean} - True if should be shown, false otherwise - */ - function shouldShowNotification(notification) { - if (!notification || !notification.notificationID) { - return false; - } - - // Check if expired - if (notification.validTill && Date.now() > notification.validTill) { - return false; - } - - // Check if already shown (persistent storage) - const shownNotifications = getShownNotifications(); - if (shownNotifications[notification.notificationID]) { - return false; - } - - // Check if currently being shown (in-memory) - if (currentlyShownNotifications.has(notification.notificationID)) { - return false; - } - - return true; - } - - /** - * Display a single notification - * @param {Object} notification - The notification object from server - */ - function displayNotification(notification) { - const { - notificationID, - title, - htmlContent, - options = {} - } = notification; - - // Mark as currently showing to prevent duplicates - currentlyShownNotifications.add(notificationID); - - // Prepare options for NotificationUI - const toastOptions = { - dismissOnClick: options.dismissOnClick !== undefined ? options.dismissOnClick : true, - toastStyle: options.toastStyle || NotificationUI.NOTIFICATION_STYLES_CSS_CLASS.INFO - }; - - // Add autoCloseTimeS if provided - if (options.autoCloseTimeS) { - toastOptions.autoCloseTimeS = options.autoCloseTimeS; - } - - // Create and show the toast notification - const notificationInstance = NotificationUI.createToastFromTemplate( - title, - htmlContent, - toastOptions - ); - - // Handle notification dismissal - notificationInstance.done(() => { - handleNotificationDismiss(notificationID); - }); - } - - /** - * Clean up stale notification IDs from state manager - * Removes notification IDs that are no longer in the remote notifications list - * @param {Array} remoteNotifications - The current notifications from server - */ - function cleanupStaleNotifications(remoteNotifications) { - if (!remoteNotifications || remoteNotifications.length === 0) { - return; - } - - // Build a set of remote notification IDs for quick lookup - const remoteIDs = new Set(); - for (const notification of remoteNotifications) { - if (notification.notificationID) { - remoteIDs.add(notification.notificationID); - } - } - - // Keep only notification IDs that are still in remote notifications - const shownNotifications = getShownNotifications(); - const updatedShownNotifications = {}; - for (const id in shownNotifications) { - if (remoteIDs.has(id)) { - updatedShownNotifications[id] = shownNotifications[id]; - } - } - - // Update state if we removed any stale IDs - const oldCount = Object.keys(shownNotifications).length; - const newCount = Object.keys(updatedShownNotifications).length; - if (newCount < oldCount) { - console.log(`Cleaning up ${oldCount - newCount} stale notification ID(s) from state`); - PreferencesManager.stateManager.set(PREF_NOTIFICATIONS_SHOWN_LIST, updatedShownNotifications); - } - } - - /** - * Process notifications from entitlements - */ - async function processNotifications() { - try { - const notifications = await EntitlementsManager.getNotifications(); - - if (!notifications || !Array.isArray(notifications)) { - return; - } - - // Clean up stale notification IDs if we have at least 1 notification from server - if (notifications.length > 0) { - cleanupStaleNotifications(notifications); - } - - // Filter and show new notifications - const notificationsToShow = notifications.filter(shouldShowNotification); - - if (notificationsToShow.length > 0) { - console.log(`Showing ${notificationsToShow.length} new notification(s)`); - notificationsToShow.forEach(displayNotification); - } - } catch (error) { - console.error('Error processing notifications:', error); - } - } - - /** - * Initialize the UserNotifications service - */ - function init() { - EntitlementsManager = KernalModeTrust.EntitlementsManager; - LoginService = KernalModeTrust.loginService; - - if (!EntitlementsManager || !LoginService) { - throw new Error("UserNotifications requires EntitlementsManager and LoginService in KernalModeTrust"); - } - - // Listen for entitlements changes - EntitlementsManager.on(EntitlementsManager.EVENT_ENTITLEMENTS_CHANGED, processNotifications); - - console.log('UserNotifications service initialized'); - } - - // Test-only exports for integration testing - if (Phoenix.isTestWindow) { - window._test_user_notifications_exports = { - getShownNotifications, - markNotificationAsShown, - shouldShowNotification, - acknowledgeNotificationToServer, - processNotifications, - cleanupStaleNotifications, - currentlyShownNotifications, - setFetchFn: function (fn) { - fetchFn = fn; - } - }; - } - - exports.init = init; - // no public exports to prevent extension tampering -}); diff --git a/src/services/ai-control.js b/src/services/ai-control.js deleted file mode 100644 index 8bd205973d..0000000000 --- a/src/services/ai-control.js +++ /dev/null @@ -1,134 +0,0 @@ -// SPDX-License-Identifier: AGPL-3.0-only -// Copyright (c) 2021 - present core.ai. All rights reserved. - -/** - * This file is only relevant to desktop apps. - * - * AI can be not active in phoenix code either by: - * 1. Schools with admin control. See https://docs.phcode.dev/docs/control-ai - * 2. user is not entitled to ai services by his subscription. - * - * This file only deals with case 1. you should use `EntitlementsManager.js` to resolve the correct AI entitlment, - * which will reconcile 1 and 2 to give you appropriate status. - **/ - -/*global */ - -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("ai-control.js should have access to KernalModeTrust. Cannot boot without trust ring"); - } - - const NodeUtils = require("utils/NodeUtils"), - Strings = require("strings"), - StringUtils = require("utils/StringUtils"); - let EntitlementsManager; - - /** - * Get the platform-specific config file path - * @returns {string} The path to the config file - */ - function _getAIConfigFilePath() { - let aiConfigPath; - // The path is decided by https://github.com/phcode-dev/phoenix-code-ai-control/tree/main/install_scripts - - if(!Phoenix.isNativeApp) { - return ""; - } - - if (Phoenix.platform === 'win') { - aiConfigPath = 'C:\\Program Files\\Phoenix AI Control\\config.json'; - } else if (Phoenix.platform === 'mac') { - aiConfigPath = '/Library/Application Support/Phoenix AI Control/config.json'; - } else if (Phoenix.platform === 'linux') { - aiConfigPath = '/etc/phoenix-ai-control/config.json'; - } else { - throw new Error(`Unsupported platform: ${Phoenix.platform}`); - } - return Phoenix.VFS.getTauriVirtualPath(aiConfigPath); - } - const AI_CONFIG_FILE_PATH = _getAIConfigFilePath(); - if(Phoenix.isNativeApp) { - console.log("AI system Config File is: ", AI_CONFIG_FILE_PATH); - } - - /** - * Check if the current user is in the allowed users list - * @param {Array} allowedUsers - List of allowed usernames - * @param {string} currentUser to check against - * @returns {boolean} True if current user is allowed - */ - function _isCurrentUserAllowed(allowedUsers, currentUser) { - if (!allowedUsers || !Array.isArray(allowedUsers) || allowedUsers.length === 0) { - return false; - } - - return allowedUsers.includes(currentUser); - } - - /** - * Get AI control configuration - * @returns {Object} The configuration status and details - */ - async function getAIControlStatus() { - try { - if(!Phoenix.isNativeApp) { - return {aiEnabled: true}; // AI control with system files in not available in browser. - // In browser, AI can be disabled with firewall only. - } - const fileData = await Phoenix.VFS.readFileResolves(AI_CONFIG_FILE_PATH, 'utf8'); - - if (fileData.error || !fileData.data) { - return { - aiEnabled: true, - message: Strings.AI_CONTROL_ALL_ALLOWED_NO_CONFIG - }; // No ai config file exists - } - - const aiConfig = JSON.parse(fileData.data); - const currentUser = await NodeUtils.getOSUserName(); - - // Check if AI is disabled globally - if (aiConfig.disableAI === true) { - // Check if current user is in allowed users list - if (aiConfig.allowedUsers && _isCurrentUserAllowed(aiConfig.allowedUsers, currentUser)) { - return { - aiEnabled: true, - message: StringUtils.format(Strings.AI_CONTROL_USER_ALLOWED, currentUser) - }; - } else if(aiConfig.managedByEmail){ - return { - aiEnabled: false, - message: StringUtils.format(Strings.AI_CONTROL_ADMIN_DISABLED_CONTACT, aiConfig.managedByEmail) - }; - } - return { - aiEnabled: false, - message: Strings.AI_CONTROL_ADMIN_DISABLED - }; - } - // AI is enabled globally - return { - aiEnabled: true, - message: Strings.AI_CONTROL_ALL_ALLOWED - }; - } catch (error) { - console.error('Error checking AI control:', error); - return {aiEnabled: true, message: error.message}; - } - } - - let inited = false; - function init() { - if(inited){ - return; - } - inited = true; - EntitlementsManager = KernalModeTrust.EntitlementsManager; - EntitlementsManager.getAIControlStatus = getAIControlStatus; - } - - exports.init = init; -}); diff --git a/src/services/html/browser-login-waiting-dialog.html b/src/services/html/browser-login-waiting-dialog.html deleted file mode 100644 index 733e99dcd1..0000000000 --- a/src/services/html/browser-login-waiting-dialog.html +++ /dev/null @@ -1,19 +0,0 @@ - diff --git a/src/services/html/license-management.html b/src/services/html/license-management.html deleted file mode 100644 index 0ec119e701..0000000000 --- a/src/services/html/license-management.html +++ /dev/null @@ -1,96 +0,0 @@ - diff --git a/src/services/html/login-popup.html b/src/services/html/login-popup.html deleted file mode 100644 index aa74adb015..0000000000 --- a/src/services/html/login-popup.html +++ /dev/null @@ -1,29 +0,0 @@ - diff --git a/src/services/html/otp-dialog.html b/src/services/html/otp-dialog.html deleted file mode 100644 index 6aa79b1339..0000000000 --- a/src/services/html/otp-dialog.html +++ /dev/null @@ -1,21 +0,0 @@ - diff --git a/src/services/html/pro-upgrade.html b/src/services/html/pro-upgrade.html deleted file mode 100644 index 48e1ce046e..0000000000 --- a/src/services/html/pro-upgrade.html +++ /dev/null @@ -1,56 +0,0 @@ - diff --git a/src/services/html/profile-popup.html b/src/services/html/profile-popup.html deleted file mode 100644 index 03910e4087..0000000000 --- a/src/services/html/profile-popup.html +++ /dev/null @@ -1,49 +0,0 @@ - diff --git a/src/services/html/promo-ended.html b/src/services/html/promo-ended.html deleted file mode 100644 index 5a3142d8c1..0000000000 --- a/src/services/html/promo-ended.html +++ /dev/null @@ -1,22 +0,0 @@ - diff --git a/src/services/login-browser.js b/src/services/login-browser.js deleted file mode 100644 index 3c0498d8b1..0000000000 --- a/src/services/login-browser.js +++ /dev/null @@ -1,456 +0,0 @@ -/* - * 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 logger*/ - -/** - * Phoenix Browser Login Service - * - * This module handles user authentication for Phoenix browser applications. - * It integrates with the Phoenix login service to provide secure authentication - * across the phcode.dev domain ecosystem. - * - * IMPORTANT: For detailed setup instructions, development workflows, and - * troubleshooting guide, see: src/services/login-service-no_dist.md - * - * Key Features: - * - Domain-wide session management using 'session' cookie at .phcode.dev level - * - Proxy server support for localhost development (serve-proxy.js) - * - Support for both production (account.phcode.dev) and custom login servers - * - Automatic session validation and user profile management - * - * Development Notes: - * - Production: Uses account.phcode.dev directly with domain-wide cookies - * - Development: Uses /proxy/accounts route through serve-proxy.js for localhost:8000 to account.phcode.dev - * - Session cookies must be manually copied from account.phcode.dev to localhost for testing - * - * @see src/services/login-service-no_dist.md for comprehensive documentation - */ - -define(function (require, exports, module) { - 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"), - DefaultDialogs = require("widgets/DefaultDialogs"), - Strings = require("strings"), - StringUtils = require("utils/StringUtils"), - ProfileMenu = require("./profile-menu"), - Mustache = require("thirdparty/mustache/mustache"), - browserLoginWaitingTemplate = require("text!./html/browser-login-waiting-dialog.html"); - - const KernalModeTrust = window.KernalModeTrust; - if(!KernalModeTrust){ - // integrated extensions will have access to kernal mode, but not external extensions - throw new Error("Browser Login service should have access to KernalModeTrust. Cannot boot without trust ring"); - } - const LoginService = KernalModeTrust.loginService; - - // user profile structure: "customerID": "uuid...", "firstName":"Aa","lastName":"bb", - // "email":"aaaa@sss.com", "loginTime":1750074393853, "isSuccess": true, - // "profileIcon":{"color":"#14b8a6","initials":"AB"} - let userProfile = null; - let isLoggedInUser = false; - - // just used as trigger to notify different windows about user profile changes - const PREF_USER_PROFILE_VERSION = "userProfileVersion"; - - function isLoggedIn() { - return isLoggedInUser; - } - - function getProfile() { - return userProfile; - } - - /** - * Get the base URL for account API calls - * Uses proxy routes for localhost, direct URL otherwise - */ - function _getAccountBaseURL() { - if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') { - return '/proxy/accounts'; - } - return Phoenix.config.account_url.replace(/\/$/, ''); // Remove trailing slash - } - - /** - * Get the account website URL for opening browser tabs - */ - function _getAccountWebURL() { - return Phoenix.config.account_url; - } - - const ERR_RETRY_LATER = "retry_later"; - const ERR_INVALID = "invalid"; - const ERR_NOT_LOGGED_IN = "not_logged_in"; - - // save a copy of window.fetch so that extensions wont tamper with it. - let fetchFn = window.fetch; - - /** - * Resolve browser session using cookies - * @return {Promise} A promise resolving to user profile or error object - */ - async function _resolveBrowserSession() { - const resolveURL = `${_getAccountBaseURL()}/resolveBrowserSession`; - if (!navigator.onLine) { - return {err: ERR_RETRY_LATER}; - } - try { - if(Phoenix.isTestWindow && fetchFn === fetch){ - // so we never allow tests to hit the actual login service. - return {err: ERR_NOT_LOGGED_IN}; - } - const response = await fetchFn(resolveURL, { - method: 'GET', - credentials: 'include', // Include cookies - headers: { - 'Accept': 'application/json' - } - }); - - if (response.status === 401 || response.status === 403 || response.status === 404) { - // Not logged in or session expired - return {err: ERR_NOT_LOGGED_IN}; - } else if (response.status === 400) { - return {err: ERR_INVALID}; - } else if (response.ok) { - const userDetails = await response.json(); - if (userDetails.isSuccess) { - return {userDetails}; - } else { - return {err: ERR_NOT_LOGGED_IN}; - } - } - // Other errors like 500 are retriable - console.log('Browser session resolve error:', response.status); - return {err: ERR_RETRY_LATER}; - } catch (e) { - console.error(e, "Failed to call resolveBrowserSession endpoint", resolveURL); - return {err: ERR_RETRY_LATER}; - } - } - - async function _resetBrowserLogin() { - isLoggedInUser = false; - userProfile = null; - ProfileMenu.setNotLoggedIn(); - // bump the version so that in multi windows, the other window gets notified of the change - PreferencesManager.stateManager.set(PREF_USER_PROFILE_VERSION, crypto.randomUUID()); - } - - /** - * Calls remote resolveBrowserSession endpoint to verify login status. should not be used frequently. - * @param silentCheck - * @returns {Promise} - * @private - */ - async function _verifyBrowserLogin(silentCheck = false) { - console.log("Verifying browser login status..."); - - const resolveResponse = await _resolveBrowserSession(); - if(resolveResponse.userDetails) { - // User is logged in - userProfile = resolveResponse.userDetails; - isLoggedInUser = true; - ProfileMenu.setLoggedIn(userProfile.profileIcon.initials, userProfile.profileIcon.color); - console.log("Browser login verified for:", userProfile.email); - Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, "browser", "OKLogin"); - return; - } - - // User is not logged in or error occurred if here - if(resolveResponse.err === ERR_NOT_LOGGED_IN) { - console.log("No browser session found. Not logged in"); - Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, "browser", "NotLoggedIn"); - _handleLoginError(silentCheck); - return; - } - - if (resolveResponse.err === ERR_INVALID) { - console.log("Invalid auth token, resetting login state"); - Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, "browser", "invalidLogin"); - _handleLoginError(silentCheck); - return; - } - - // Other errors (network, retry later, etc.) - console.log("Browser login verification failed (temporary):", resolveResponse.err); - Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, "browser", "RetryLogin"); - // Don't reset login state for temporary errors, regardless of silent check - } - - function _handleLoginError(silentCheck) { - if (!silentCheck) { - _resetBrowserLogin(); - } else { - // For silent checks, just update the internal state - isLoggedInUser = false; - userProfile = null; - } - } - - let loginWaitingDialog = null; - - /** - * Show waiting dialog with auto-detection and manual check options - */ - function _showLoginWaitingDialog() { - if (loginWaitingDialog) { - return; // Already showing - } - - // Prepare dialog data with fallback strings - const dialogData = { - Strings: { - SIGN_IN_WAITING_TITLE: Strings.SIGN_IN_WAITING_TITLE, - SIGN_IN_WAITING_MESSAGE: Strings.SIGN_IN_WAITING_MESSAGE, - WAITING_FOR_LOGIN: Strings.WAITING_FOR_LOGIN, - CHECK_NOW: Strings.CHECK_NOW, - CANCEL: Strings.CANCEL - } - }; - - const $template = $(Mustache.render(browserLoginWaitingTemplate, dialogData)); - loginWaitingDialog = Dialogs.showModalDialogUsingTemplate($template); - - // Handle Check Now button - $template.on('click', '[data-button-id="check"]', async function() { - const $btn = $(this); - const originalText = $btn.text(); - $btn.prop('disabled', true).text(Strings.CHECKING); - $template.find('#login-status').text(Strings.CHECKING_STATUS); - - await _verifyBrowserLogin(); - - if (isLoggedInUser) { - _onLoginSuccess(); - } else { - $template.find('#login-status').text(Strings.NOT_SIGNED_IN_YET); - $btn.prop('disabled', false).text(originalText); - } - }); - - // Handle Cancel button - $template.on('click', '[data-button-id="cancel"]', function() { - loginWaitingDialog.close(); - }); - - // Auto-check when page gains focus - const onFocusCheck = async () => { - if (loginWaitingDialog && !isLoggedInUser) { - $template.find('#login-status').text(Strings.CHECKING_STATUS); - await _verifyBrowserLogin(); - - if (isLoggedInUser) { - _onLoginSuccess(); - } - } - }; - - $(window).off('focus.loginWaiting'); - $(window).on('focus.loginWaiting', onFocusCheck); - - // Clean up when dialog closes - loginWaitingDialog.done(() => { - _cancelLoginWaiting(); - }); - } - - function _onLoginSuccess() { - if (loginWaitingDialog) { - const $template = loginWaitingDialog.getElement(); - const welcomeBackMessage = Phoenix.isNativeApp ? - StringUtils.format(Strings.WELCOME_BACK_USER, userProfile.firstName): Strings.WELCOME_BACK; - // in desktop app, the apis return full username so we can show `Welcome back, alice`, but in - // browser app, we only get name like `a***` due to security posture, so we show `Welcome back` in browser. - $template.find('#login-status') - .text(welcomeBackMessage) - .css('color', '#10b981'); - setTimeout(() => { - _cancelLoginWaiting(); - Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, "browserLogin", "browser"); - }, 1500); - } - // on login we fire an entitlements changed event forcefully as new user came online - // even if entitlements didn't change(entitlements may not change between trial users for eg.). - LoginService._debounceEntitlementsChanged(); - } - - function _cancelLoginWaiting() { - if (loginWaitingDialog) { - loginWaitingDialog.close(); - loginWaitingDialog = null; - } - $(window).off('focus.loginWaiting'); - } - - /** - * Open browser-based sign-in in new tab - */ - async function signInToBrowser() { - if (!navigator.onLine) { - Dialogs.showModalDialog( - DefaultDialogs.DIALOG_ID_ERROR, - Strings.SIGNED_IN_OFFLINE_TITLE, - Strings.SIGNED_IN_OFFLINE_MESSAGE - ); - return; - } - - const signInURL = _getAccountWebURL() + "authorizeBrowserApp"; - - // Open account URL in new tab - const newTab = window.open(signInURL, '_blank'); - - if (!newTab) { - Dialogs.showModalDialog( - DefaultDialogs.DIALOG_ID_ERROR, - Strings.SIGNED_IN_FAILED_TITLE, - StringUtils.format(Strings.POPUP_BLOCKED, _getAccountWebURL()) - ); - return; - } - - // Show dialog with better UX - auto-detect when user returns - _showLoginWaitingDialog(); - - Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, "browserLoginAttempt", "browser"); - } - - /** - * Sign out from browser session - */ - async function signOutBrowser() { - const logoutURL = `${_getAccountBaseURL()}/signOut`; - try { - if(Phoenix.isTestWindow && fetchFn === fetch){ - // so we never allow tests to hit the actual login service. - return; - } - const response = await fetchFn(logoutURL, { - method: 'POST', - credentials: 'include', // Include cookies - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({}) - }); - - // Always reset local state regardless of server response - await _resetBrowserLogin(); - await _verifyBrowserLogin(); - - if (response.ok) { - const result = await response.json(); - if (result.isSuccess) { - Dialogs.showModalDialog( - DefaultDialogs.DIALOG_ID_INFO, - Strings.SIGNED_OUT, - Strings.SIGNED_OUT_MESSAGE_FRIENDLY - ); - Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, 'browserLogoutOK', 'browser'); - return; - } - } - - // If we get here, there was some issue but we still signed out locally - console.warn('Logout may not have completed on server, but signed out locally'); - const dialog = Dialogs.showModalDialog( - DefaultDialogs.DIALOG_ID_ERROR, - Strings.SIGNED_OUT_FAILED_TITLE, - Strings.SIGNED_OUT_FAILED_MESSAGE - ); - dialog.done(() => { - window.open(_getAccountWebURL() + "#advanced", '_blank'); - }); - Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, 'browserLogoutPartial', 'browser'); - - } catch (error) { - // Always reset local state even on network error - await _resetBrowserLogin(); - await _verifyBrowserLogin(); - console.error("Network error during logout:", error); - const dialog = Dialogs.showModalDialog( - DefaultDialogs.DIALOG_ID_ERROR, - Strings.SIGNED_OUT_FAILED_TITLE, - Strings.SIGNED_OUT_FAILED_MESSAGE - ); - dialog.done(() => { - window.open(_getAccountWebURL() + "#advanced", '_blank'); - }); - Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, 'browserLogoutError', 'browser'); - } - } - - function 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); - - // Watch for profile changes from other windows/tabs - const pref = PreferencesManager.stateManager.definePreference(PREF_USER_PROFILE_VERSION, 'string', '0'); - pref.watchExternalChanges(); - pref.on('change', ()=>{ - _verifyBrowserLogin(true).catch(console.error); - }); - - // Note: We don't do automatic verification on page focus to avoid server overload. - // Automatic checks are only done during the login waiting dialog period. - } - - // no sensitive apis or events should be triggered from the public exports of this module as extensions - // can read them. Always use KernalModeTrust.loginService for sensitive apis. - - // Only set exports for browser apps to avoid conflict with desktop 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 = signInToBrowser; - LoginService.signOutAccount = signOutBrowser; - LoginService.getProfile = getProfile; - // verifyLoginStatus Calls remote resolveBrowserSession endpoint to verify. should not be used frequently. - // All users are required to use isLoggedIn API instead. - LoginService._verifyLoginStatus = () => _verifyBrowserLogin(false); - LoginService.getAccountBaseURL = _getAccountBaseURL; - init(); - } - - // Test-only exports for integration testing - if (Phoenix.isTestWindow) { - window._test_login_browser_exports = { - setFetchFn: function _setFetchFn(fn) { - fetchFn = fn; - } - }; - } - - // public exports - exports.isLoggedIn = isLoggedIn; - -}); diff --git a/src/services/login-desktop.js b/src/services/login-desktop.js deleted file mode 100644 index 5b595a5112..0000000000 --- a/src/services/login-desktop.js +++ /dev/null @@ -1,464 +0,0 @@ -/* - * 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 logger*/ - -define(function (require, exports, module) { - const LoginServiceDirectImport = require("./login-service"); // after this, loginService will be in KernalModeTrust - - const EventDispatcher = require("utils/EventDispatcher"), - PreferencesManager = require("preferences/PreferencesManager"), - Metrics = require("utils/Metrics"), - Dialogs = require("widgets/Dialogs"), - DefaultDialogs = require("widgets/DefaultDialogs"), - Strings = require("strings"), - NativeApp = require("utils/NativeApp"), - ProfileMenu = require("./profile-menu"), - Mustache = require("thirdparty/mustache/mustache"), - NodeConnector = require("NodeConnector"), - otpDialogTemplate = require("text!./html/otp-dialog.html"); - - 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 LoginService = KernalModeTrust.loginService; - - // user profile is something like "apiKey": "uuid...", validationCode: "dfdf", "firstName":"Aa","lastName":"bb", - // "email":"aaaa@sss.com", "customerID":"uuid...","loginTime":1750074393853, - // "profileIcon":{"color":"#14b8a6","initials":"AB"} - let userProfile = null; - let isLoggedInUser = false; - - // save a copy of window.fetch so that extensions wont tamper with it. - let fetchFn = window.fetch; - - // just used as trigger to notify different windows about user profile changes - const PREF_USER_PROFILE_VERSION = "userProfileVersion"; - - const _EVT_PAGE_FOCUSED = "page_focused"; - const focusWatcher = {}; - EventDispatcher.makeEventDispatcher(focusWatcher); - $(window).focus(function () { - focusWatcher.trigger(_EVT_PAGE_FOCUSED); - }); - - const AUTH_CONNECTOR_ID = "ph_auth"; - const EVENT_CONNECTED = "connected"; - let authNodeConnector; - if(Phoenix.isNativeApp) { - authNodeConnector = NodeConnector.createNodeConnector(AUTH_CONNECTOR_ID, exports); - } - - - function isLoggedIn() { - return isLoggedInUser; - } - - function getProfile() { - return userProfile; - } - - /** - * Get the account base URL for API calls - * For desktop apps, this directly uses the configured account URL - */ - function getAccountBaseURL() { - if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') { - return '/proxy/accounts'; - } - return Phoenix.config.account_url.replace(/\/$/, ''); // Remove trailing slash - } - - /** - * Get the account website URL for opening browser tabs - */ - function _getAccountWebURL() { - return Phoenix.config.account_url; - } - - const ERR_RETRY_LATER = "retry_later"; - const ERR_INVALID = "invalid"; - - /** - * Resolves the provided API key and verification code to user profile data - * - * @param {string} apiKey - The API key to be validated. - * @param {string} validationCode - The verification code associated with the API key. - * @return {Promise} A promise resolving to an object containing the user details if successful, - * or an error object with the relevant error code (`ERR_RETRY_LATER` or `ERR_INVALID`) if the operation fails. - * never rejects. - */ - async function _resolveAPIKey(apiKey, validationCode) { - const resolveURL = `${getAccountBaseURL()}/resolveAppSessionID?appSessionID=${apiKey}&validationCode=${validationCode}`; - if (!navigator.onLine) { - return {err: ERR_RETRY_LATER}; - } - try { - if(Phoenix.isTestWindow && fetchFn === fetch){ - // so we never allow tests to hit the actual login service. - return {err: ERR_INVALID}; - } - const response = await fetchFn(resolveURL); - if (response.status === 400 || response.status === 404) { - // 404 api key not found and 400 Bad Request, eg: verification code mismatch - return {err: ERR_INVALID}; - } else if (response.ok) { - const userDetails = await response.json(); - userDetails.apiKey = apiKey; - userDetails.validationCode = validationCode; - return {userDetails}; - } - // Other errors like 500 are retriable - console.log('Other error:', response.status); - return {err: ERR_RETRY_LATER}; - } catch (e) { - console.error(e, "Failed to call resolve API endpoint", resolveURL); - return {err: ERR_RETRY_LATER}; - } - } - - async function _resetAccountLogin() { - isLoggedInUser = false; - ProfileMenu.setNotLoggedIn(); - await KernalModeTrust.removeCredential(KernalModeTrust.CRED_KEY_API); - // bump the version so that in multi windows, the other window gets notified of the change - PreferencesManager.stateManager.set(PREF_USER_PROFILE_VERSION, crypto.randomUUID()); - } - - async function _verifyLogin(silentCheck = false) { - const savedUserProfile = await KernalModeTrust.getCredential(KernalModeTrust.CRED_KEY_API); - if(!savedUserProfile){ - console.log("No savedUserProfile found. Not logged in"); - if (!silentCheck) { - ProfileMenu.setNotLoggedIn(); - } - isLoggedInUser = false; - return; - } - try { - userProfile = JSON.parse(savedUserProfile); - } catch (e) { - console.error(e, "Failed to parse saved user profile credentials");// this should never happen - if (!silentCheck) { - ProfileMenu.setNotLoggedIn(); - } - return; // not logged in if parse fails - } - isLoggedInUser = true; - // api key is present, verify if the key is valid. but just show user that we are logged in with - // stored credentials. - ProfileMenu.setLoggedIn(userProfile.profileIcon.initials, userProfile.profileIcon.color); - const resolveResponse = await _resolveAPIKey(userProfile.apiKey, userProfile.validationCode); - if(resolveResponse.userDetails) { - // a valid user account is in place. update the stored credentials - userProfile = resolveResponse.userDetails; - ProfileMenu.setLoggedIn(userProfile.profileIcon.initials, userProfile.profileIcon.color); - await KernalModeTrust.setCredential(KernalModeTrust.CRED_KEY_API, JSON.stringify(userProfile)); - // we dont need to bump the PREF_USER_PROFILE_VERSION here as its just a cred update - // (maybe name) and may lead to infi loops. - return; - } - // some error happened. - if(resolveResponse.err === ERR_INVALID) { // the api key is invalid, we need to logout and tell user - _resetAccountLogin() - .catch(console.error); - Dialogs.showModalDialog( - DefaultDialogs.DIALOG_ID_ERROR, - Strings.SIGNED_OUT, - Strings.SIGNED_OUT_MESSAGE - ); - } - // maybe some intermittent network error, ERR_RETRY_LATER is here. do nothing - } - - function _getAutoAuthPortURL() { - const localAutoAuthURL = KernalModeTrust.localAutoAuthURL; // Eg: http://localhost:33577/AutoAuthDI0zAUJo - if(!localAutoAuthURL) { - return "9797/urlDoesntExist"; - } - return localAutoAuthURL.replace("http://localhost:", ""); - } - - const PLATFORM_STRINGS = { - "win": "Windows", - "mac": "mac", - "linux": "Linux" - }; - // never rejects. - async function _getAppAuthSession() { - const authPortURL = _getAutoAuthPortURL(); - const platformStr = PLATFORM_STRINGS[Phoenix.platform] || Phoenix.platform; - const appName = encodeURIComponent(`${Strings.APP_NAME} Desktop on ${platformStr}`); - const resolveURL = `${getAccountBaseURL()}/getAppAuthSession?autoAuthPort=${authPortURL}&appName=${appName}`; - // {"isSuccess":true,"appSessionID":"a uuid...","validationCode":"SWXP07"} - try { - if(Phoenix.isTestWindow && fetchFn === fetch){ - // so we never allow tests to hit the actual login service. - return null; - } - const response = await fetchFn(resolveURL); - if (response.ok) { - const {appSessionID, validationCode} = await response.json(); - if(!appSessionID || !validationCode) { - throw new Error("Invalid response from getAppAuthSession API endpoint" + resolveURL); - } - return {appSessionID, validationCode}; - } - return null; - } catch (e) { - console.error(e, "Failed to call getAppAuthSession API endpoint", resolveURL); - Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, 'getAppAuth', Phoenix.platform); - logger.reportError(e, "Failed to call getAppAuthSession API endpoint" + resolveURL); - return null; - } - } - - async function setAutoVerificationCode(validationCode) { - const TIMEOUT_MS = 1000; - try { - await Promise.race([ - authNodeConnector.execPeer("setVerificationCode", validationCode), - new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), TIMEOUT_MS)) - ]); - } catch (e) { - console.error("failed to send auth login verification code to node", e); - // we ignore this and continue for manual verification - Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, 'autoFail', Phoenix.platform); - } - } - - async function signInToAccount() { - if (!navigator.onLine) { - Dialogs.showModalDialog( - DefaultDialogs.DIALOG_ID_ERROR, - Strings.SIGNED_IN_OFFLINE_TITLE, - Strings.SIGNED_IN_OFFLINE_MESSAGE - ); - return; - } - const appAuthSession = await _getAppAuthSession(); - if(!appAuthSession) { - Dialogs.showModalDialog( - DefaultDialogs.DIALOG_ID_ERROR, - Strings.SIGNED_IN_FAILED_TITLE, - Strings.SIGNED_IN_FAILED_MESSAGE - ); - return; - } - const {appSessionID, validationCode} = appAuthSession; - await setAutoVerificationCode(validationCode); - const appSignInURL = `${_getAccountWebURL()}authorizeApp?appSessionID=${appSessionID}`; - - // Show dialog with validation code - const dialogData = { - validationCode: validationCode, - Strings: Strings - }; - - const $template = $(Mustache.render(otpDialogTemplate, dialogData)); - const dialog = Dialogs.showModalDialogUsingTemplate($template); - - // Set timeout to close dialog after 5 minutes, as validity is only 5 mins - const closeTimeout = setTimeout(() => { - dialog.close(); - }, 5 * 60 * 1000); - - // Handle button clicks - $template.on('click', '[data-button-id="copy"]', function() { - Phoenix.app.copyToClipboard(validationCode); - - // Show "Copied" feedback - const $validationCodeSpan = $template.find('.validation-code span'); - const originalText = $validationCodeSpan.text(); - - // Replace validation code with "Copied" text - $validationCodeSpan.text(Strings.VALIDATION_CODE_COPIED); - - // Restore original validation code after 1.5 seconds - setTimeout(() => { - $validationCodeSpan.text(originalText); - }, 1500); - }); - - $template.on('click', '[data-button-id="open"]', function() { - NativeApp.openURLInDefaultBrowser(appSignInURL); - }); - $template.on('click', '[data-button-id="cancel"]', function() { - dialog.close(); - }); - $template.on('click', '[data-button-id="refresh"]', function() { - checkLoginStatus(); - }); - - let checking = false, checkAgain = false; - // never rejects - async function checkLoginStatus() { - if(checking) { - checkAgain = true; - return; - } - checking = true; - try { - const resolveResponse = await _resolveAPIKey(appSessionID, validationCode); - if(resolveResponse.userDetails) { - // the user has validated the creds - userProfile = resolveResponse.userDetails; - isLoggedInUser = true; - ProfileMenu.setLoggedIn(userProfile.profileIcon.initials, userProfile.profileIcon.color); - await KernalModeTrust.setCredential(KernalModeTrust.CRED_KEY_API, JSON.stringify(userProfile)); - // bump the version so that in multi windows, the other window gets notified of the change - PreferencesManager.stateManager.set(PREF_USER_PROFILE_VERSION, crypto.randomUUID()); - checkAgain = false; - dialog.close(); - // on login we fire an entitlements changed event forcefully as new user came online - // even if entitlements didn't change(entitlements may not change between trial users for eg.). - LoginService._debounceEntitlementsChanged(); - } - } catch (e) { - console.error("Failed to check login status.", e); - } - checking = false; - if(checkAgain) { - checkAgain = false; - setTimeout(checkLoginStatus, 100); - } - } - let isAutoSignedIn = false; - focusWatcher.on(_EVT_PAGE_FOCUSED, checkLoginStatus); - async function _AutoSignedIn() { - isAutoSignedIn = true; - await checkLoginStatus(); - } - authNodeConnector.one(EVENT_CONNECTED, _AutoSignedIn); - - // Clean up when dialog is closed - dialog.done(function() { - focusWatcher.off(_EVT_PAGE_FOCUSED, checkLoginStatus); - authNodeConnector.off(EVENT_CONNECTED, _AutoSignedIn); - clearTimeout(closeTimeout); - Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, - isAutoSignedIn ? 'autoLogin' : 'manLogin' - , Phoenix.platform); - Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, "dsktpLogin", - isAutoSignedIn ? 'auto' : 'man'); - }); - NativeApp.openURLInDefaultBrowser(appSignInURL); - } - - async function signOutAccount() { - const resolveURL = `${getAccountBaseURL()}/logoutSession`; - try { - let input = { - appSessionID: userProfile.apiKey - }; - - if(Phoenix.isTestWindow && fetchFn === fetch){ - // so we never allow tests to hit the actual login service. - return; - } - const response = await fetchFn(resolveURL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(input) - }); - - const result = await response.json(); - - if (!result.isSuccess) { - console.error('Error logging out', result); - const dialog = Dialogs.showModalDialog( - DefaultDialogs.DIALOG_ID_ERROR, - Strings.SIGNED_OUT_FAILED_TITLE, - Strings.SIGNED_OUT_FAILED_MESSAGE - ); - dialog.done(() => { - NativeApp.openURLInDefaultBrowser(_getAccountWebURL() + "#advanced"); - }); - Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, 'logoutFail', Phoenix.platform); - return; - } - await _resetAccountLogin(); - await _verifyLogin(); - Dialogs.showModalDialog( - DefaultDialogs.DIALOG_ID_INFO, - Strings.SIGNED_OUT, - Strings.SIGNED_OUT_MESSAGE_FRIENDLY - ); - Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, 'logoutOK', Phoenix.platform); - } catch (error) { - console.error("Network error. Could not log out session.", error); - const dialog = Dialogs.showModalDialog( - DefaultDialogs.DIALOG_ID_ERROR, - Strings.SIGNED_OUT_FAILED_TITLE, - Strings.SIGNED_OUT_FAILED_MESSAGE - ); - dialog.done(() => { - NativeApp.openURLInDefaultBrowser(_getAccountWebURL() + "#advanced"); - }); - Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, 'getAppAuth', Phoenix.platform); - logger.reportError(error, "Failed to call logout calling" + resolveURL); - } - } - - function init() { - if(!Phoenix.isNativeApp){ - console.log("Desktop login service not needed for browser"); - 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(); - pref.on('change', _verifyLogin); - } - - // no sensitive apis or events should be triggered from the public exports of this module as extensions - // can read them. Always use KernalModeTrust.loginService for sensitive apis. - - // 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; - LoginService._verifyLoginStatus = () => _verifyLogin(false); - LoginService.getAccountBaseURL = getAccountBaseURL; - init(); - } - - // Test-only exports for integration testing - if (Phoenix.isTestWindow) { - window._test_login_desktop_exports = { - setFetchFn: function _setFetchFn(fn) { - fetchFn = fn; - } - }; - } - - // public exports - exports.isLoggedIn = isLoggedIn; - -}); diff --git a/src/services/login-service.js b/src/services/login-service.js deleted file mode 100644 index 96ec7f5bf1..0000000000 --- a/src/services/login-service.js +++ /dev/null @@ -1,763 +0,0 @@ -/* - * 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 path, logger*/ - -/** - * Shared Login Service - * - * This module contains shared login service functionality used by both - * browser and desktop login implementations, including entitlements management. - */ - -define(function (require, exports, module) { - require("./setup-login-service"); // this adds loginService to KernalModeTrust - require("./promotions"); - require("./login-utils"); - const NodeUtils = require("utils/NodeUtils"), - PreferencesManager = require("preferences/PreferencesManager"), - Commands = require("command/Commands"), - CommandManager = require("command/CommandManager"); - const EntitlementsDirectImport = require("./EntitlementsManager"); // this adds Entitlements to KernalModeTrust - - const Metrics = require("utils/Metrics"), - Strings = require("strings"); - - const PREF_STATE_LICENSED_DEVICE_CHECK = "LICENSED_DEVICE_CHECK"; - PreferencesManager.stateManager.definePreference(PREF_STATE_LICENSED_DEVICE_CHECK, "boolean", false); - - const MS_IN_DAY = 10 * 24 * 60 * 60 * 1000; - const TEN_MINUTES = 10 * 60 * 1000; - const FREE_PLAN_VALIDITY_DAYS = 10000; - - // the fallback salt is always a constant as this will only fail in rare circumstatnces and it needs to - // be exactly same across versions of the app. Changing this will not affect the large majority of users and - // for the ones who are affected, the app will reset the signed data with new salt but will not grant ant trial - // when tampering is detected. - const FALLBACK_SALT = 'fallback-salt-2f309322-b32d-4d59-85b4-2baef666a9f4'; - let currentSalt; - - // Cache file path for desktop app entitlements - const CACHED_ENTITLEMENTS_FILE = path.join(Phoenix.app.getApplicationSupportDirectory(), - "cached_entitlements.json"); - - // save a copy of window.fetch so that extensions wont tamper with it. - let fetchFn = window.fetch; - let dateNowFn = Date.now; - - 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 LoginService = KernalModeTrust.loginService; - - // Event constants - const EVENT_ENTITLEMENTS_CHANGED = "entitlements_changed"; - - // Cached entitlements data - let cachedEntitlements = undefined; - - // Last recorded state for entitlements monitoring - let lastRecordedState = null; - - // Debounced trigger for entitlements changed - let entitlementsChangedTimer = null; - - const ENTITLEMENT_CHANGED_DEBOUNCE_WINDOW = Phoenix.isTestWindow ? 100 : 1000; - - function _debounceEntitlementsChanged() { - if (entitlementsChangedTimer) { - // already scheduled, skip - return; - } - - // atmost 1 entitlement changed event will be triggered in this window to prevent too many entitlment changed - // events firing. - entitlementsChangedTimer = setTimeout(() => { - LoginService.trigger(EVENT_ENTITLEMENTS_CHANGED); - entitlementsChangedTimer = null; - }, ENTITLEMENT_CHANGED_DEBOUNCE_WINDOW); - } - - - /** - * Get per-user salt for signature generation, creating and persisting one if it doesn't exist - * Used for signing cached data to prevent tampering - */ - async function getSalt() { - // Fallback salt constant for rare circumstances where salt generation fails - if(currentSalt) { - return currentSalt; - } - - try { - if (Phoenix.isNativeApp) { - // Native app: use KernalModeTrust credential store - let salt = await KernalModeTrust.getCredential(KernalModeTrust.SIGNATURE_SALT_KEY); - if (!salt) { - // Generate and store new salt - salt = crypto.randomUUID(); - await KernalModeTrust.setCredential(KernalModeTrust.SIGNATURE_SALT_KEY, salt); - } - currentSalt = salt; - return salt; - } - // In browser app, there is no way to securely store salt without extensions being able to - // read it. Return a static salt for basic integrity checking. - currentSalt = FALLBACK_SALT; - return FALLBACK_SALT; - } catch (error) { - console.error("Error getting signature salt:", error); - // Return a fallback salt to prevent crashes - Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, "saltGet", "Err"); - currentSalt = FALLBACK_SALT; - return FALLBACK_SALT; - } - } - - /** - * Load cached entitlements from disk with signature validation - * Returns null if no cache, invalid signature, or error - */ - async function _loadCachedEntitlements() { - if (!Phoenix.isNativeApp) { - return null; // No caching for browser app - } - - try { - const fileData = await Phoenix.VFS.readFileResolves(CACHED_ENTITLEMENTS_FILE, 'utf8'); - - if (fileData.error || !fileData.data) { - return null; // No cached file exists - } - - const cachedData = JSON.parse(fileData.data); - if (!cachedData.jsonData || !cachedData.sign) { - console.warn("Invalid cached entitlements format - missing jsonData or sign"); - await _clearCachedEntitlements(); - return null; - } - - // Validate signature - const salt = await getSalt(); - const isValidSignature = await KernalModeTrust.validateDataSignature( - cachedData.jsonData, - cachedData.sign, - salt - ); - - if (!isValidSignature) { - console.warn("Cached entitlements signature validation failed - possible tampering detected"); - Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "entCacheLD", "signInvalid"); - await _clearCachedEntitlements(); - return null; - } - - // Parse and return the entitlements - return JSON.parse(cachedData.jsonData); - } catch (error) { - console.error("Error loading cached entitlements:", error); - Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "entCacheLD", "error"); - await _clearCachedEntitlements(); // Clear corrupted cache - return null; - } - } - - /** - * Save entitlements to cache with signature - */ - async function _saveCachedEntitlements(entitlements) { - if (!Phoenix.isNativeApp || !entitlements) { - return; // No caching for browser app - } - - try { - const jsonData = JSON.stringify(entitlements); - const salt = await getSalt(); - const signature = await KernalModeTrust.generateDataSignature(jsonData, salt); - - const cacheData = { - jsonData: jsonData, - sign: signature - }; - - await Phoenix.VFS.writeFileAsync(CACHED_ENTITLEMENTS_FILE, JSON.stringify(cacheData), 'utf8'); - console.log("Entitlements cached successfully"); - } catch (error) { - Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "entCacheSave", "err"); - console.error("Error saving cached entitlements:", error); - } - } - - /** - * Clear cached entitlements file - */ - async function _clearCachedEntitlements() { - if (!Phoenix.isNativeApp) { - return; // No caching for browser app - } - - try { - await Phoenix.VFS.unlinkResolves(CACHED_ENTITLEMENTS_FILE); - console.log("Cached entitlements cleared"); - } catch (error) { - console.log("Error clearing cached entitlements:", error); - } - } - - let deviceIDCached = undefined; - async function getDeviceID() { - if(!Phoenix.isNativeApp) { - // We only grant device licenses to desktop apps. Browsers cannot be uniquely device identified obviously. - return null; - } - if(deviceIDCached !== undefined) { - return deviceIDCached; - } - try { - const deviceID = await NodeUtils.getDeviceID(); - if(!deviceID) { - deviceIDCached = null; - Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, "deviceID", "nullErr"); - return null; - } - deviceIDCached = KernalModeTrust.generateDataSignature(deviceID); - } catch (e) { - logger.reportError(e, "failed to sign deviceID"); - Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, "deviceID", "SignErr"); - deviceIDCached = null; - } - return deviceIDCached; - } - - - let deviceLicensePrimed = false, - licencedDeviceCredsAvailable = false; - - /** - * Get entitlements from API or disc cache. - * @param {string} forceRefresh If provided will always fetch from server and bypass cache. Use rarely like - * when a user logs in/out/some other user activity/ account-related events. - * Returns null if the user is not logged in - */ - async function getEntitlements(forceRefresh = false) { - if(!deviceLicensePrimed) { - deviceLicensePrimed = true; - // we cache this as device license is only checked at app start. As invoves some files in system loactions, - // we dont want file access errors to happen on every entitlement check. - licencedDeviceCredsAvailable = await isLicensedDevice(); - } - // Return null if not logged in - if (!LoginService.isLoggedIn() && !licencedDeviceCredsAvailable) { - return null; - } - - // Return cached data if available and not forcing refresh - if (cachedEntitlements !== undefined && !forceRefresh) { - return cachedEntitlements; - } - - if (cachedEntitlements && !navigator.onLine) { - return cachedEntitlements; - } - - async function _processDiscCachedEntitlement() { - const diskCachedEntitlements = await _loadCachedEntitlements(); - if (diskCachedEntitlements) { - console.log("offline/network/server error: Using cached entitlements from disk"); - const entitlementsChanged = - JSON.stringify(cachedEntitlements) !== JSON.stringify(diskCachedEntitlements); - cachedEntitlements = diskCachedEntitlements; - // Trigger event if entitlements changed - if (entitlementsChanged) { - _debounceEntitlementsChanged(); - } - return cachedEntitlements; - } - return null; - } - - try { - const accountBaseURL = LoginService.getAccountBaseURL(); - const language = brackets.getLocale(); - const currentVersion = window.AppConfig.apiVersion || "1.0.0"; - let url = `${accountBaseURL}/getAppEntitlements?lang=${language}&version=${currentVersion}`+ - `&platform=${Phoenix.platform}&appType=${Phoenix.isNativeApp ? "desktop" : "browser"}`; - if(licencedDeviceCredsAvailable) { - url += `&deviceID=${await getDeviceID()}`; - } - let fetchOptions = { - method: 'GET', - headers: { - 'Accept': 'application/json' - } - }; - - // Handle different authentication methods for browser vs desktop - if (Phoenix.isNativeApp) { - // Desktop app: use appSessionID and validationCode - const profile = LoginService.getProfile(); - if (profile && profile.apiKey && profile.validationCode) { - url += `&appSessionID=${encodeURIComponent(profile.apiKey)}&validationCode=${encodeURIComponent(profile.validationCode)}`; - } else if(!licencedDeviceCredsAvailable){ - console.error('Missing appSessionID or validationCode for desktop app entitlements'); - return null; - } - } else { - // Browser app: use session cookies - fetchOptions.credentials = 'include'; - } - - // For desktop app, if offline, try to return disc cached entitlements - if (Phoenix.isNativeApp && !navigator.onLine) { - const processedEntitlement = await _processDiscCachedEntitlement(); - if (processedEntitlement) { - return processedEntitlement; - } - } - - const response = await fetchFn(url, fetchOptions); - - if (response.ok) { - const result = await response.json(); - if (result.isSuccess) { - // Check if entitlements actually changed - const entitlementsChanged = JSON.stringify(cachedEntitlements) !== JSON.stringify(result); - - cachedEntitlements = result; - - // Save to disk cache for desktop app - if (Phoenix.isNativeApp) { - await _saveCachedEntitlements(result); - } - - // Trigger event if entitlements changed - if (entitlementsChanged) { - _debounceEntitlementsChanged(); - } - - return cachedEntitlements; - } - } else if (response.status >= 500 && response.status < 600) { - // Handle 5xx errors by loading from cache. - if (Phoenix.isNativeApp) { - console.warn('Fetch entitlements server error:', response.status); - const processedEntitlement = await _processDiscCachedEntitlement(); - if (processedEntitlement) { - return processedEntitlement; - } - } - } else if (Phoenix.isNativeApp) { - // 4xx errors are genuine auth fail errors, so our cache is not good then - console.warn('Cearing entitlements, entitlements server error:', response.status); - await _clearCachedEntitlements(); - } - } catch (error) { - console.error('Failed to fetch entitlements:', error); - - // errors that happen during the fetch operation itself, which are typically not HTTP errors - // returned by the server, but rather issues at the network or browser level. - // For desktop app, fall back to cached entitlements if available - if (Phoenix.isNativeApp) { - const processedEntitlement = await _processDiscCachedEntitlement(); - if (processedEntitlement) { - return processedEntitlement; - } - } - } - - return null; - } - - /** - * Clear cached entitlements and trigger change event - * Called when user logs out - */ - async function clearEntitlements() { - if (cachedEntitlements) { - cachedEntitlements = undefined; - _debounceEntitlementsChanged(); - } - // Reset device license state so it's re-evaluated on next entitlement check - deviceLicensePrimed = false; - await _clearCachedEntitlements(); - } - - - /** - * Start the 10-minute interval timer for monitoring entitlements - */ - function startEffectiveEntitlementsMonitor() { - // Reconcile effective entitlements from server. So the effective entitlements api injects trial - // entitlements data. but only the server fetch will trigger the entitlements change event. - // so in here, we observe the effective entitlements, and if the effective entitlements are changed, - // since the last triggered state, we trigger a change event. This only concerens with the effective - // entitlement changes. This will not logout the user if user logged out from the server admin panel, - // but his entitlements will be cleared by this call anyways. - - // At app start we refresh entitlements, then only one each user action like user clicks on profile icon, - // or if some user hits some backend api, we will refresh entitlements. But here, we periodically refresh - // entitlements from the server every 10 minutes, but only trigger entitlement change events only if some - // effective entitlement(Eg. trial) data changed or any validity expired. - if(Phoenix.isTestWindow){ - return; - } - setTimeout( async function() { - // prime the entitlement monitor with the current effective entitlements, after app start, the system would - // have resolved any existing login info by now and effective entitlements would be available if any. - lastRecordedState = await getEffectiveEntitlements(false); - }, 30000); - setInterval(async () => { - try { - // Get fresh effective entitlements - const freshEntitlements = await getEffectiveEntitlements(true); - - // Check if we need to refresh - const expiredPlanName = KernalModeTrust.LoginUtils - .validTillExpired(freshEntitlements, lastRecordedState); - const hasChanged = KernalModeTrust.LoginUtils - .haveEntitlementsChanged(freshEntitlements, 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"); - // 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 = freshEntitlements; - } catch (error) { - console.error('Entitlements monitor error:', error); - } - }, TEN_MINUTES); - - console.log('Entitlements monitor started (10-minute interval)'); - } - - function _validateAndFilterEntitlements(entitlements) { - if (!entitlements) { - return; - } - - const currentDate = dateNowFn(); - - if(entitlements.plan && (!entitlements.plan.validTill || currentDate > entitlements.plan.validTill)) { - entitlements.plan = { - ...entitlements.plan, - isSubscriber: false, - paidSubscriber: false, - name: Strings.USER_FREE_PLAN_NAME_DO_NOT_TRANSLATE, - fullName: Strings.USER_FREE_PLAN_NAME_DO_NOT_TRANSLATE, - validTill: currentDate + (FREE_PLAN_VALIDITY_DAYS * MS_IN_DAY) - }; - } - - const featureEntitlements = entitlements.entitlements; - if (!featureEntitlements) { - return; - } - - for(const featureName in featureEntitlements) { - const feature = featureEntitlements[featureName]; - if(feature && (!feature.validTill || currentDate > feature.validTill)) { - feature.activated = false; - feature.upgradeToPlan = feature.upgradeToPlan || brackets.config.main_pro_plan; - feature.subscribeURL = feature.subscribeURL || brackets.config.purchase_url; - feature.validTill = feature.validTill || (currentDate - MS_IN_DAY); - } - } - } - - /** - * Get effective entitlements for determining feature availability. - * This is for internal use only. All consumers in phoenix should use `KernalModeTrust.EntitlementsManager` APIs. - * - * @returns {Promise} Entitlements object or null if not logged in and no trial active - * - * @description Response shapes vary based on user state: - * - * **For non-logged-in users:** - * - Returns `null` if no trial is active - * - Returns synthetic entitlements if trial is active: - * ```javascript - * { - * plan: { - * isSubscriber: true, // Always true for trial users - * paidSubscriber: false, // if the user is a paid for the plan, or is it an unpaid promo - * name: "Phoenix Pro" - * fullName: "Phoenix Pro" // this can be deceptive name like "Phoenix Pro For Education" to use in - * // profile popup, not main branding - * }, - * isInProTrial: true, // Indicates this is a trial user - * trialDaysRemaining: number, // Days left in trial - * entitlements: { - * liveEdit: { - * activated: true // Trial users get liveEdit access - * } - * } - * } - * ``` - * - * **For logged-in trial users:** - * - If remote response has `plan.isSubscriber: false`, injects `isSubscriber: true` - * - Adds `isInProTrial: true` and `trialDaysRemaining` - * - Injects `entitlements.liveEdit.activated: true` - * - Note: Trial users may not be actual paid subscribers, but `isSubscriber: true` is set - * so all Phoenix code treats them as subscribers. to check if they actually paid or not, use - * `paidSubscriber` field. - * - * **For logged-in users (full remote response):** - * ```javascript - * { - * isSuccess: boolean, - * lang: string, - * plan: { - * name: "Phoenix Pro", - * fullName: "Phoenix Pro" // this can be deceptive name like "Phoenix Pro For Education" to use in - * // profile popup, not main branding - * isSubscriber: boolean, - * paidSubscriber: boolean, - * validTill: number // Timestamp - * }, - * profileview: { - * quota: { - * titleText: "Ai Quota Used", - * usageText: "100 / 200 credits", - * usedPercent: number - * }, - * htmlMessage: string // HTML alert message - * }, - * entitlements: { - * liveEdit: { - * activated: boolean, - * subscribeURL: string, // URL to subscribe if not activated - * upgradeToPlan: string, // Plan name that includes this entitlement - * validTill: number // Timestamp when entitlement expires - * }, - * aiAgent: { - * activated: boolean, - * aiBrandName: string, - * subscribeURL: string, - * upgradeToPlan: string, - * validTill: number, - * upsellDialog: { - * title: "if activated is false, server can send a custom upsell dialog to show", - * message: "this is the message to show", - * buyURL: "if this url is present from server, this will be shown to as buy link" - * } - * } - * } - * } - * ``` - * - * @example - * // Listen for entitlements changes - * const LoginService = window.KernelModeTrust.loginService; - * LoginService.on(LoginService.EVENT_ENTITLEMENTS_CHANGED, async() => { - * const entitlements = await LoginService.getEffectiveEntitlements(); - * console.log('Entitlements changed:', entitlements); - * // Update UI based on new entitlements - * }); - * - * // Get current entitlements - * const entitlements = await LoginService.getEffectiveEntitlements(); - * if (entitlements?.plan?.isSubscriber) { - * // Enable pro features - * } - * if (entitlements?.entitlements?.liveEdit?.activated) { - * // Enable live edit feature - * } - */ - async function getEffectiveEntitlements(forceRefresh = false) { - // Get raw server entitlements - const serverEntitlements = await getEntitlements(forceRefresh); - _validateAndFilterEntitlements(serverEntitlements); // will prune invalid entitlements - - // Get trial days remaining - const trialDaysRemaining = await LoginService.getProTrialDaysRemaining(); - - // If no trial is active, return server entitlements as-is - if (trialDaysRemaining <= 0) { - return serverEntitlements; - } - - // now we need to grant trial, as user is entitled to trial if he is here. - // User has active server plan(either with login or device license) - if (serverEntitlements && serverEntitlements.plan) { - if (serverEntitlements.plan.isSubscriber) { - // Already a subscriber(or has device license), return as-is - // never inject trail data in this case. - return serverEntitlements; - } - // Enhance entitlements for trial user - // user in not a paid subscriber(nor he has device license), inject trial - return { - ...serverEntitlements, - plan: { - ...serverEntitlements.plan, - isSubscriber: true, - paidSubscriber: serverEntitlements.plan.paidSubscriber || false, - name: brackets.config.main_pro_plan, - fullName: brackets.config.main_pro_plan, - validTill: dateNowFn() + trialDaysRemaining * MS_IN_DAY - }, - isInProTrial: true, - trialDaysRemaining: trialDaysRemaining, - entitlements: { - ...serverEntitlements.entitlements, - // below we only override things we grant in trial. AI which is not part of trial - // is always server injected. the EntitlementsManager will resolve it appropriately. - liveEdit: { - activated: true, - subscribeURL: brackets.config.purchase_url, - upgradeToPlan: brackets.config.main_pro_plan, - validTill: dateNowFn() + trialDaysRemaining * MS_IN_DAY - } - } - }; - } - - // Non-logged-in, non licensed user with trial - return synthetic entitlements - return { - plan: { - isSubscriber: true, - paidSubscriber: false, - name: brackets.config.main_pro_plan, - fullName: brackets.config.main_pro_plan, - validTill: dateNowFn() + trialDaysRemaining * MS_IN_DAY - }, - isInProTrial: true, - trialDaysRemaining: trialDaysRemaining, - entitlements: { - // below we only override things we grant in trial. AI which is not part of trial - // is always server injected. the EntitlementsManager will resolve it appropriately. - liveEdit: { - activated: true, - subscribeURL: brackets.config.purchase_url, - upgradeToPlan: brackets.config.main_pro_plan, - validTill: dateNowFn() + trialDaysRemaining * MS_IN_DAY - } - } - }; - } - - async function addDeviceLicense() { - deviceLicensePrimed = false; - PreferencesManager.stateManager.set(PREF_STATE_LICENSED_DEVICE_CHECK, true); - return NodeUtils.addDeviceLicenseSystemWide(); - } - - async function removeDeviceLicense() { - deviceLicensePrimed = false; - PreferencesManager.stateManager.set(PREF_STATE_LICENSED_DEVICE_CHECK, false); - return NodeUtils.removeDeviceLicenseSystemWide(); - } - - async function isLicensedDeviceSystemWide() { - return NodeUtils.isLicensedDeviceSystemWide(); - } - - let _isLicensedDeviceFlagForTest = false; - - /** - * Checks if app is configured to check for device licenses at app start at system or user level. - * - * @returns {Promise} - Resolves with `true` if the device is licensed, `false` otherwise. - */ - async function isLicensedDevice() { - if(Phoenix.isTestWindow) { - return _isLicensedDeviceFlagForTest; - } - if(!Phoenix.isNativeApp) { - // browser app doesn't support device licence keys, obviously. - return false; - } - const userCheck = PreferencesManager.stateManager.get(PREF_STATE_LICENSED_DEVICE_CHECK); - const systemCheck = await isLicensedDeviceSystemWide(); - return userCheck || systemCheck; - } - - // Add functions to secure exports - LoginService.getEntitlements = getEntitlements; - LoginService.getEffectiveEntitlements = getEffectiveEntitlements; - LoginService.clearEntitlements = clearEntitlements; - LoginService.getSalt = getSalt; - LoginService.addDeviceLicense = addDeviceLicense; - LoginService.removeDeviceLicense = removeDeviceLicense; - LoginService.isLicensedDevice = isLicensedDevice; - LoginService.isLicensedDeviceSystemWide = isLicensedDeviceSystemWide; - LoginService.getDeviceID = getDeviceID; - LoginService._debounceEntitlementsChanged = _debounceEntitlementsChanged; - LoginService.EVENT_ENTITLEMENTS_CHANGED = EVENT_ENTITLEMENTS_CHANGED; - - async function handleReinstallCreds() { - if(!Phoenix.isNativeApp) { - throw new Error("Reinstall credentials is only available in native apps"); - } - try { - await KernalModeTrust.reinstallCreds(); - console.log("Credentials reinstalled successfully"); - } catch (error) { - console.error("Error reinstalling credentials:", error); - throw error; - } - } - - let inited = false; - function init() { - if(inited){ - return; - } - inited = true; - EntitlementsDirectImport.init(); - - // Register reinstall credentials command for native apps only - if(Phoenix.isNativeApp) { - CommandManager.register("Reinstall Credentials", Commands.REINSTALL_CREDS, handleReinstallCreds); - } - } - // Test-only exports for integration testing - if (Phoenix.isTestWindow) { - window._test_login_service_exports = { - LoginService, - setIsLicensedDevice: function (_isLicensedDevice) { - _isLicensedDeviceFlagForTest = _isLicensedDevice; - }, - setFetchFn: function (fn) { - fetchFn = fn; - }, - setDateNowFn: function (fn) { - dateNowFn = fn; - }, - _validateAndFilterEntitlements: _validateAndFilterEntitlements - }; - } - - // Start the entitlements monitor timer - startEffectiveEntitlementsMonitor(); - - exports.init = init; - // no public exports to prevent extension tampering -}); diff --git a/src/services/login-utils.js b/src/services/login-utils.js deleted file mode 100644 index 3eec9b39cf..0000000000 --- a/src/services/login-utils.js +++ /dev/null @@ -1,150 +0,0 @@ -/* - * 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) { - - const KernalModeTrust = window.KernalModeTrust; - if(!KernalModeTrust){ - // integrated extensions will have access to kernal mode, but not external extensions - throw new Error("Login utils should have access to KernalModeTrust. Cannot boot without trust ring"); - } - - /** - * 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; - // Check isSubscriber changes - const currentIsSubscriber = current.plan && current.plan.isSubscriber; - const lastIsSubscriber = last.plan && last.plan.isSubscriber; - if (currentIsSubscriber !== lastIsSubscriber || 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; - } - - KernalModeTrust.LoginUtils = { - validTillExpired, - haveEntitlementsChanged - }; - // Test only Export functions - if(Phoenix.isTestWindow) { - exports.validTillExpired = validTillExpired; - exports.haveEntitlementsChanged = haveEntitlementsChanged; - } -}); diff --git a/src/services/manage-licenses.js b/src/services/manage-licenses.js deleted file mode 100644 index 1c67aa0146..0000000000 --- a/src/services/manage-licenses.js +++ /dev/null @@ -1,331 +0,0 @@ -/* - * 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 logger*/ - -/** - * Shared Login Service - * - * This module contains shared login service functionality used by both - * browser and desktop login implementations, including entitlements management. - */ - -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("manage-licenses should have access to KernalModeTrust. Cannot boot without trust ring"); - } - - const Strings = require("strings"), - Dialogs = require("widgets/Dialogs"), - Mustache = require("thirdparty/mustache/mustache"), - licenseManagementHTML = require("text!./html/license-management.html"); - - // Save a copy of window.fetch so that extensions won't tamper with it - let fetchFn = window.fetch; - - /** - * Get the API base URL for license operations - */ - function _getAPIBaseURL() { - if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') { - return '/proxy/accounts'; - } - return Phoenix.config.account_url.replace(/\/$/, ''); // Remove trailing slash - } - - /** - * Call the validateDeviceLicense API - */ - async function _validateDeviceLicense(deviceLicenseKey) { - const apiURL = `${_getAPIBaseURL()}/validateDeviceLicense`; - - try { - const response = await fetchFn(apiURL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - deviceLicenseKey: deviceLicenseKey - }) - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - return await response.json(); - } catch (error) { - console.error('Error validating device license:', error); - throw error; - } - } - - /** - * Call the registerDevice API to activate a license - */ - async function _registerDevice(licenseKey, deviceLicenseKey, platform, deviceLabel) { - const apiURL = `${_getAPIBaseURL()}/registerDevice`; - - try { - const response = await fetchFn(apiURL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - licenseKey: licenseKey, - deviceLicenseKey: deviceLicenseKey, - platform: platform, - deviceLabel: deviceLabel - }) - }); - - const result = await response.json(); - - if (!response.ok) { - throw new Error(result.errorMessage || `HTTP ${response.status}: ${response.statusText}`); - } - - return result; - } catch (error) { - console.error('Error registering device:', error); - throw error; - } - } - - /** - * Format date for display - */ - function _formatDate(timestamp) { - if (!timestamp) { - return Strings.LICENSE_VALID_NEVER; - } - const date = new Date(timestamp); - return date.toLocaleDateString(Phoenix.getLocale(), { - year: 'numeric', - month: 'long', - day: 'numeric' - }); - } - - /** - * Update the license status display in the dialog - */ - async function _updateLicenseStatusDisplay($dialog, licenseData) { - const $loading = $dialog.find('#license-status-loading'); - const $none = $dialog.find('#license-status-none'); - const $valid = $dialog.find('#license-status-valid'); - const $error = $dialog.find('#license-status-error'); - const $reapplyContainer = $dialog.find('#reapply-license-container'); - - // Hide all status sections - $loading.hide(); - $none.hide(); - $valid.hide(); - $error.hide(); - $reapplyContainer.hide(); - - if (licenseData && licenseData.isValid) { - // Show valid license info - $dialog.find('#licensed-to-name').text(licenseData.licensedToName || Strings.LICENSE_STATUS_UNKNOWN); - $dialog.find('#license-type-name').text(licenseData.licenseTypeName || Strings.LICENSE_STATUS_UNKNOWN); - $dialog.find('#license-valid-till').text(_formatDate(licenseData.validTill)); - $valid.show(); - - // Show reapply button if license is valid but not applied system-wide - const isLicensed = await KernalModeTrust.loginService.isLicensedDeviceSystemWide(); - if (!isLicensed) { - $reapplyContainer.show(); - } - } else if (licenseData && licenseData.isValid === false) { - // No valid license - $none.show(); - } else { - // Error state - $dialog.find('#license-error-message').text(Strings.LICENSE_STATUS_ERROR_CHECK); - $error.show(); - } - } - - /** - * Show activation result message - */ - function _showActivationMessage($dialog, isSuccess, message) { - const $messageDiv = $dialog.find('#activation-message'); - const $messageText = $dialog.find('#activation-message-text'); - - $messageText.text(message); - - // Remove previous classes - $messageDiv.removeClass('success error'); - - // Add appropriate class - if (isSuccess) { - $messageDiv.addClass('success'); - } else { - $messageDiv.addClass('error'); - } - - $messageDiv.show(); - - // Hide message after 5 seconds - setTimeout(() => { - $messageDiv.fadeOut(); - }, 5000); - } - - /** - * Load and display current license status - */ - async function _loadLicenseStatus($dialog) { - try { - const deviceID = await KernalModeTrust.loginService.getDeviceID(); - if (!deviceID) { - _updateLicenseStatusDisplay($dialog, { isValid: false }); - return; - } - - const licenseData = await _validateDeviceLicense(deviceID); - _updateLicenseStatusDisplay($dialog, licenseData); - } catch (error) { - console.error('Error loading license status:', error); - _updateLicenseStatusDisplay($dialog, null); - } - } - - /** - * Handle license activation - */ - async function _handleLicenseActivation($dialog, licenseKey) { - const $btn = $dialog.find('#activate-license-btn'); - const $btnText = $btn.find('.btn-text'); - const $btnSpinner = $btn.find('.btn-spinner'); - - try { - // Show loading state - $btn.prop('disabled', true); - $btnText.hide(); - $btnSpinner.show(); - - const deviceID = await KernalModeTrust.loginService.getDeviceID(); - if (!deviceID) { - throw new Error('Unable to get device ID. Device licenses are only supported on desktop applications.'); - } - - const platform = Phoenix.platform || 'unknown'; - const deviceLabel = `Phoenix Code - ${platform}`; - - const result = await _registerDevice(licenseKey, deviceID, platform, deviceLabel); - - if (result.isSuccess) { - const addSuccess = await KernalModeTrust.loginService.addDeviceLicense(); - const successString = addSuccess ? - Strings.LICENSE_ACTIVATE_SUCCESS : Strings.LICENSE_ACTIVATE_SUCCESS_PARTIAL; - _showActivationMessage($dialog, true, successString); - - // Clear the input field - $dialog.find('#license-key-input').val(''); - - // Refresh license status - await _loadLicenseStatus($dialog); - } else { - _showActivationMessage($dialog, false, result.errorMessage || Strings.LICENSE_ACTIVATE_FAIL); - } - } catch (error) { - _showActivationMessage($dialog, false, error.message || Strings.LICENSE_ACTIVATE_FAIL); - } finally { - // Reset button state - $btn.prop('disabled', false); - $btnText.show(); - $btnSpinner.hide(); - } - } - - /** - * Handle reapply license to device - */ - async function _handleReapplyLicense($dialog) { - const $link = $dialog.find('#reapply-license-link'); - const originalText = $link.html(); - - try { - // Show loading state - $link.html('Applying...'); - $link.css('pointer-events', 'none'); - - const addSuccess = await KernalModeTrust.loginService.addDeviceLicense(); - if (addSuccess) { - _showActivationMessage($dialog, true, Strings.LICENSE_ACTIVATE_SUCCESS); - // Refresh license status - await _loadLicenseStatus($dialog); - } else { - _showActivationMessage($dialog, false, Strings.LICENSE_ACTIVATE_FAIL_APPLY); - } - } catch (error) { - _showActivationMessage($dialog, false, Strings.LICENSE_ACTIVATE_FAIL_APPLY); - } finally { - // Reset link state - $link.html(originalText); - $link.css('pointer-events', 'auto'); - } - } - - async function showManageLicensesDialog() { - const $template = $(Mustache.render(licenseManagementHTML, {Strings})); - - Dialogs.showModalDialogUsingTemplate($template); - - // Set up event handlers - const $dialog = $template; - const $licenseInput = $dialog.find('#license-key-input'); - const $activateBtn = $dialog.find('#activate-license-btn'); - const $reapplyLink = $dialog.find('#reapply-license-link'); - - // Handle activate button click - $activateBtn.on('click', async function() { - const licenseKey = $licenseInput.val().trim(); - if (!licenseKey) { - _showActivationMessage($dialog, false, Strings.LICENSE_ENTER_KEY); - return; - } - - await _handleLicenseActivation($dialog, licenseKey); - }); - - // Handle Enter key in license input - $licenseInput.on('keypress', function(e) { - if (e.which === 13) { // Enter key - $activateBtn.click(); - } - }); - - // Handle reapply license link click - $reapplyLink.on('click', async function(e) { - e.preventDefault(); - await _handleReapplyLicense($dialog); - }); - - // Load current license status - await _loadLicenseStatus($dialog); - } - - exports.showManageLicensesDialog = showManageLicensesDialog; -}); diff --git a/src/services/pro-dialogs.js b/src/services/pro-dialogs.js deleted file mode 100644 index a6224f14d8..0000000000 --- a/src/services/pro-dialogs.js +++ /dev/null @@ -1,231 +0,0 @@ -/* - * 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 logger*/ - -/** - * Phoenix pro pre and post promo dialogs - * shows dialog where we give Phoenix pro to all users on app install - * and dialogs on pro trial ends. - * - */ - -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("pro-dialogs.js should have access to KernalModeTrust. Cannot boot without trust ring"); - } - - const proTitle = ` - ${brackets.config.main_pro_plan} - - `, - proTitlePlain = `${brackets.config.main_pro_plan} - `; - require("./setup-login-service"); // this adds loginService to KernalModeTrust - const Dialogs = require("widgets/Dialogs"), - Mustache = require("thirdparty/mustache/mustache"), - Strings = require("strings"), - StringUtils = require("utils/StringUtils"), - ThemeManager = require("view/ThemeManager"), - Metrics = require("utils/Metrics"), - proUpgradeHTML = require("text!./html/pro-upgrade.html"), - proEndedHTML = require("text!./html/promo-ended.html"); - - // save a copy of window.fetch so that extensions wont tamper with it. - let fetchFn = window.fetch; - - 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, { - title, message, Strings, - secondaryButton: Strings.PROMO_LEARN_MORE, - primaryButton: Strings.OK - })); - Dialogs.showModalDialogUsingTemplate($template).done(function (id) { - console.log("Dialog closed with id: " + id); - Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "dlgShow", "promo"); - if(id === 'secondaryButton') { - Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "dlgAct", "promoLearn"); - Phoenix.app.openURLInDefaultBrowser(brackets.config.purchase_url); - } else { - Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "dlgAct", "promoCancel"); - } - }); - } - - 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: dlgText.localDialogMessage, - secondaryButton: Strings.CANCEL, - primaryButton: buttonGetPro - })); - Dialogs.showModalDialogUsingTemplate($template).done(function (id) { - console.log("Dialog closed with id: " + id); - Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "dlgShow", "localUpgrade"); - if(id === 'ok') { - Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "dlgAct", "localGetPro"); - Phoenix.app.openURLInDefaultBrowser(brackets.config.purchase_url); - } else { - Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "dlgAct", "localCancel"); - } - }); - } - - 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}&upsellType=${upsellType}`; - const $template = $(Mustache.render(proEndedHTML, {Strings, title, buttonGetPro, promoURL})); - Dialogs.showModalDialogUsingTemplate($template).done(function (id) { - console.log("Dialog closed with id: " + id); - Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "dlgShow", "remoteUpgrade"); - if(id === 'get_pro') { - Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "dlgAct", "remoteGetPro"); - Phoenix.app.openURLInDefaultBrowser(upsellPurchaseURL || brackets.config.purchase_url); - } else { - Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "dlgAct", "remoteCancel"); - } - }); - } - - async function showProUpsellDialog(upsellType) { - const currentVersion = window.AppConfig.apiVersion; - - if (!navigator.onLine) { - _showLocalProEndedDialog(upsellType); - return; - } - - try { - const configURL = `${brackets.config.promotions_url}app/config.json`; - const response = await fetchFn(configURL); - if (!response.ok) { - _showLocalProEndedDialog(upsellType); - return; - } - - const config = await response.json(); - if (config.upsell_after_trial_url) { - _showRemoteProEndedDialog(upsellType, currentVersion, - config.upsell_after_trial_url, config.upsell_purchase_url); - } else { - _showLocalProEndedDialog(upsellType); - } - } catch (error) { - _showLocalProEndedDialog(upsellType); - } - } - - function showAIUpsellDialog(getAIEntitlementResponse) { - // Only show dialog if upsellDialog field is present - if (!getAIEntitlementResponse || !getAIEntitlementResponse.upsellDialog) { - return; - } - - const upsellDialog = getAIEntitlementResponse.upsellDialog; - const title = upsellDialog.title; - const message = upsellDialog.message; - const buyURL = upsellDialog.buyURL; - const needsLogin = getAIEntitlementResponse.needsLogin; - - let buttons; - if (needsLogin || buyURL) { - // Show primary action button and Cancel - const primaryButtonText = needsLogin ? Strings.PROFILE_SIGN_IN : Strings.AI_LOGIN_DIALOG_BUTTON; - buttons = [ - { className: Dialogs.DIALOG_BTN_CLASS_NORMAL, id: Dialogs.DIALOG_BTN_CANCEL, text: Strings.CANCEL }, - { className: Dialogs.DIALOG_BTN_CLASS_PRIMARY, id: "mainAction", text: primaryButtonText } - ]; - } else { - // Show only OK button (for disabled AI messages) - buttons = [ - { className: Dialogs.DIALOG_BTN_CLASS_PRIMARY, id: Dialogs.DIALOG_BTN_OK, text: Strings.OK } - ]; - } - - Dialogs.showModalDialog(Dialogs.DIALOG_ID_INFO, title, message, buttons).done(function (id) { - Metrics.countEvent(Metrics.EVENT_TYPE.AI, "dlgUpsell", "show"); - if(id === 'mainAction') { - if (needsLogin) { - Metrics.countEvent(Metrics.EVENT_TYPE.AI, "dlgUpsell", "signIn"); - KernalModeTrust.EntitlementsManager.loginToAccount(); - } else { - Metrics.countEvent(Metrics.EVENT_TYPE.AI, "dlgUpsell", "buyClick"); - Phoenix.app.openURLInDefaultBrowser(buyURL); - } - } else { - Metrics.countEvent(Metrics.EVENT_TYPE.AI, "dlgUpsell", id); - } - }); - } - - if (Phoenix.isTestWindow) { - window._test_pro_dlg_login_exports = { - setFetchFn: function _setDdateNowFn(fn) { - fetchFn = fn; - } - }; - } - - exports.showProTrialStartDialog = showProTrialStartDialog; - exports.showProUpsellDialog = showProUpsellDialog; - exports.showAIUpsellDialog = showAIUpsellDialog; - 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 deleted file mode 100644 index 36b3f50168..0000000000 --- a/src/services/profile-menu.js +++ /dev/null @@ -1,704 +0,0 @@ -define(function (require, exports, module) { - const Mustache = require("thirdparty/mustache/mustache"), - PopUpManager = require("widgets/PopUpManager"), - ThemeManager = require("view/ThemeManager"), - Strings = require("strings"), - StringUtils = require("utils/StringUtils"), - LoginService = require("./login-service"); - - const KernalModeTrust = window.KernalModeTrust; - if(!KernalModeTrust){ - // integrated extensions will have access to kernal mode, but not external extensions - throw new Error("profile menu should have access to KernalModeTrust. Cannot boot without trust ring"); - } - - let $icon; - - function _createSVGIcon(initials, bgColor) { - return ` - - - ${initials} - `; - } - - function _updateProfileIcon(initials, bgColor) { - $icon.empty() - .append(_createSVGIcon(initials, bgColor)); - } - - function _removeProfileIcon() { - $icon.empty(); - } - - // HTML templates - const loginTemplate = require("text!./html/login-popup.html"); - const profileTemplate = require("text!./html/profile-popup.html"); - - // for the popup DOM element - let $popup = null; - - // this is to track whether the popup is visible or not - let isPopupVisible = false; - - // Track if we're doing a background refresh to avoid closing user-opened popups - let isBackgroundRefresh = false; - - // this is to handle document click events to close popup - let documentClickHandler = null; - - function _handleSignInBtnClick() { - closePopup(); // need to close the current popup to show the new one - KernalModeTrust.loginService.signInToAccount(); - } - - function _handleSignOutBtnClick() { - closePopup(); - KernalModeTrust.loginService.signOutAccount(); - } - - function _handleContactSupportBtnClick() { - Phoenix.app.openURLInDefaultBrowser(brackets.config.support_url); - } - - function _handleAccountDetailsBtnClick() { - Phoenix.app.openURLInDefaultBrowser(brackets.config.account_url); - } - - /** - * Close the popup if it's open - * this is called at various instances like when the user click on the profile icon even if the popup is open - * or when user clicks somewhere else on the document - */ - function closePopup() { - if ($popup) { - PopUpManager.removePopUp($popup); - $popup = null; - isPopupVisible = false; - } - - // we need to remove document click handler if it already exists - if (documentClickHandler) { - $(document).off("mousedown", documentClickHandler); - documentClickHandler = null; - } - } - - /** - * this function is to position the popup near the profile button - */ - function positionPopup() { - const $profileButton = $("#user-profile-button"); - - if ($profileButton.length && $popup) { - const buttonPos = $profileButton.offset(); - const popupWidth = $popup.outerWidth(); - const windowWidth = $(window).width(); - - // pos above the profile button - let top = buttonPos.top - $popup.outerHeight() - 10; - - // If popup would go off the right edge of the window, align right edge of popup with right edge of button - let left = Math.min( - buttonPos.left - popupWidth + $profileButton.outerWidth(), - windowWidth - popupWidth - 10 - ); - - // never go off left edge - left = Math.max(10, left); - - $popup.css({ - top: top + "px", - left: left + "px" - }); - } - } - - /** - * this function is responsible to set up a click handler to close the popup when clicking outside - */ - function _setupDocumentClickHandler() { - // remove any existing handlers - if (documentClickHandler) { - $(document).off("mousedown", documentClickHandler); - } - - // add the new click handler - documentClickHandler = function (event) { - // if the click is outside the popup and not on the profile button (which toggles the popup) - if ($popup && !$popup[0].contains(event.target) && !$("#user-profile-button")[0].contains(event.target)) { - closePopup(); - } - }; - - // this is needed so we don't close the popup immediately as the profile button is clicked - setTimeout(function() { - $(document).on("mousedown", documentClickHandler); - }, 100); - } - - /** - * Shows the sign-in popup when the user is not logged in - */ - function showLoginPopup() { - // If popup is already visible, just close it - if (isPopupVisible) { - closePopup(); - return; - } - - // create the popup element - closePopup(); // close any existing popup first - - // Render template with basic data first for instant response - const renderedTemplate = Mustache.render(loginTemplate, { - Strings, - getProLink: brackets.config.purchase_url - }); - $popup = $(renderedTemplate); - - $("body").append($popup); - isPopupVisible = true; - - positionPopup(); - - // Check for trial info or device license asynchronously and update popup - KernalModeTrust.loginService.getEffectiveEntitlements().then(effectiveEntitlements => { - // this is the login popup, so user is not logged in yet if we are here. - if (effectiveEntitlements && isPopupVisible && $popup) { - let proInfoHtml = null; - - if (effectiveEntitlements.isInProTrial) { - // isInProTrial will never be set if user has a pro device license(or a pro sub, - // but that isn't relevant here). Add trial info to the existing popup. - const planName = StringUtils.format(Strings.PROMO_PRO_TRIAL_DAYS_LEFT, - effectiveEntitlements.trialDaysRemaining); - proInfoHtml = `
- - ${planName} - - -
`; - } else if (effectiveEntitlements.plan && effectiveEntitlements.plan.isSubscriber) { - // Device-licensed user: show Phoenix Pro branding - const planName = effectiveEntitlements.plan.fullName || brackets.config.main_pro_plan; - proInfoHtml = `
- - ${planName} - - -
`; - } - - if (proInfoHtml) { - $popup.find('.popup-title').after(proInfoHtml); - positionPopup(); // Reposition after adding content - } - } - }).catch(error => { - console.error('Failed to check entitlements for login popup:', error); - }); - - PopUpManager.addPopUp($popup, function() { - $popup.remove(); - $popup = null; - isPopupVisible = false; - }, true, { closeCurrentPopups: true }); - - // event handlers for buttons - $popup.find("#phoenix-signin-btn").on("click", function () { - _handleSignInBtnClick(); - }); - - $popup.find("#phoenix-support-btn").on("click", function () { - _handleContactSupportBtnClick(); - closePopup(); - }); - - // handle window resize to reposition popup - $(window).on("resize.profilePopup", function () { - if (isPopupVisible) { - positionPopup(); - } - }); - - _setupDocumentClickHandler(); - } - - /** - * Update main navigation branding based on entitlements - */ - function _updateBranding(entitlements) { - const $brandingLink = $("#phcode-io-main-nav"); - if (!entitlements) { - // Phoenix.pro is only for display purposes and should not be used to gate features. - // Use kernal mode apis for trusted check of pro features. - Phoenix.pro.plan = { - isSubscriber: false, - name: Strings.USER_FREE_PLAN_NAME_DO_NOT_TRANSLATE, - fullName: Strings.USER_FREE_PLAN_NAME_DO_NOT_TRANSLATE - }; - } - - if (entitlements && entitlements.plan){ - Phoenix.pro.plan = { - isSubscriber: entitlements.plan.isSubscriber, - name: entitlements.plan.name, - fullName: entitlements.plan.fullName, - validTill: entitlements.plan.validTill - }; - } - if (entitlements && entitlements.plan && entitlements.plan.isSubscriber) { - // Pro user (paid subscriber or trial): show short name branding with `name feather icon`(not full name) - let displayName = entitlements.plan.name || brackets.config.main_pro_plan; - if (entitlements.isInProTrial) { - displayName = brackets.config.main_pro_plan; // Just "Phoenix Pro" for branding, not "Phoenix Pro Trial" - } - $brandingLink - .attr("href", "https://account.phcode.dev") - .addClass("phoenix-pro") - .html(`${displayName}`); - } else { - // Free user: show phcode.io branding - $brandingLink - .attr("href", "https://phcode.io") - .removeClass("phoenix-pro") - .text("phcode.io"); - } - } - - let userEmail=""; - class SecureEmail extends HTMLElement { - constructor() { - super(); - // Create closed shadow root - this is for security that extensions wont be able to read email from DOM - const shadow = this.attachShadow({ mode: 'closed' }); - // Create the email display with some obfuscation techniques - shadow.innerHTML = `${userEmail}`; - } - } - // Register the custom element - /* eslint-disable-next-line*/ - customElements.define ('secure-email', SecureEmail); // space is must in define ( to prevent build fail - - let userName=""; - class SecureName extends HTMLElement { - constructor() { - super(); - // Create closed shadow root - this is for security that extensions wont be able to read name from DOM - const shadow = this.attachShadow({ mode: 'closed' }); - // Create the email display with some obfuscation techniques - shadow.innerHTML = `${userName}`; - } - } - // Register the custom element - - /* eslint-disable-next-line*/ - customElements.define ('secure-name', SecureName); // space is must in define ( to prevent build fail - - /** - * Load user details iframe with secure user information - */ - function _loadUserDetailsIframe() { - if (!Phoenix.isNativeApp && $popup) { - const $iframe = $popup.find("#user-details-frame"); - const $secureName = $popup.find(".user-name secure-name"); - const $secureEmail = $popup.find(".user-email secure-email"); - - if ($iframe.length) { - // Get account base URL for iframe using login service - const accountBaseURL = KernalModeTrust.loginService.getAccountBaseURL(); - const currentTheme = ThemeManager.getCurrentTheme(); - const nameColor = (currentTheme && currentTheme.dark) ? "FFFFFF" : "000000"; - - // Configure iframe URL with styling parameters - const iframeURL = `${accountBaseURL}/getUserDetailFrame?` + - `includeName=true&` + - `nameFontSize=14px&` + - `emailFontSize=12px&` + - `nameColor=%23${nameColor}&` + - `emailColor=%23666666&` + - `backgroundColor=transparent`; - - // Listen for iframe load events - const messageHandler = function(event) { - // Only accept messages from trusted account domain - // Handle proxy case where accountBaseURL is '/proxy/accounts' - let trustedOrigin; - if (accountBaseURL.startsWith('/proxy/accounts')) { - // For localhost with proxy, accept messages from current origin - trustedOrigin = window.location.origin; - } else { - // For production, get origin from account URL - trustedOrigin = new URL(accountBaseURL).origin; - } - - if (event.origin !== trustedOrigin) { - return; - } - - if (event.data && event.data.loaded) { - // Hide secure DOM elements and show iframe - $secureName.hide(); - $secureEmail.hide(); - $iframe.show(); - - // Adjust iframe height based on content - $iframe.css('height', '36px'); // Approximate height for name + email - - // Remove event listener - window.removeEventListener('message', messageHandler); - } - }; - - // Add message listener - window.addEventListener('message', messageHandler); - - // Set iframe source to load user details - $iframe.attr('src', iframeURL); - - // Fallback timeout - if iframe doesn't load in 5 seconds, keep secure elements - setTimeout(() => { - if ($iframe.is(':hidden')) { - console.log('User details iframe failed to load, keeping secure elements'); - window.removeEventListener('message', messageHandler); - } - }, 5000); - } - } - } - - /** - * Update popup content with entitlements data - */ - function _updatePopupWithEntitlements(entitlements) { - if (!$popup || !entitlements) { - return; - } - // entitlements will always be present for login popup. - // Update plan information - const $getProLink = $popup.find('.get-phoenix-pro-profile'); - if (entitlements.plan) { - const $planName = $popup.find('.user-plan-name'); - - // Update plan class and content based on paid subscriber status - $planName.removeClass('user-plan-free user-plan-paid'); - - if (entitlements.plan.isSubscriber) { - // Use pro styling with feather icon for pro users (paid or trial) - if (entitlements.isInProTrial) { - // For trial users: separate "Phoenix Pro" with icon from "(X days left)" text - const planName = StringUtils.format(Strings.PROMO_PRO_TRIAL_DAYS_LEFT, - entitlements.trialDaysRemaining); - const proTitle = ` - ${planName} - - `; - $planName.addClass('user-plan-paid').html(proTitle); - $getProLink.removeClass('forced-hidden'); - } else { - // For paid users: regular plan name with icon - const proTitle = ` - ${entitlements.plan.fullName} - - `; - $planName.addClass('user-plan-paid').html(proTitle); - $getProLink.addClass('forced-hidden'); - } - } else { - // Use simple text for free users - $planName.addClass('user-plan-free').text(entitlements.plan.fullName); - } - } else { - $getProLink.removeClass('forced-hidden'); - } - - // Update quota section if available - if (entitlements.profileview && entitlements.profileview.quota) { - const $quotaSection = $popup.find('.quota-section'); - const quota = entitlements.profileview.quota; - - // Remove forced-hidden and show quota section - $quotaSection.removeClass('forced-hidden'); - - // Update quota content - $quotaSection.find('.titleText').text(quota.titleText); - $quotaSection.find('.usageText').text(quota.usageText); - $quotaSection.find('.progress-fill').css('width', quota.usedPercent + '%'); - } - - // Update HTML message if available - if (entitlements.profileview && entitlements.profileview.htmlMessage) { - const $htmlMessageSection = $popup.find('.html-message'); - $htmlMessageSection.removeClass('forced-hidden'); - $htmlMessageSection.html(entitlements.profileview.htmlMessage); - } - - // Reposition popup after content changes - positionPopup(); - } - - /** - * Shows the user profile popup when the user is logged in - */ - function showProfilePopup() { - // If popup is already visible, just close it - if (isPopupVisible) { - closePopup(); - return; - } - const profileData = KernalModeTrust.loginService.getProfile(); - userEmail = profileData.email; - userName = profileData.firstName + " " + profileData.lastName; - - // Default template data (fallback) - start with cached plan info if available - const templateData = { - initials: profileData.profileIcon.initials, - avatarColor: profileData.profileIcon.color, - planClass: "user-plan-free", - planName: Strings.USER_FREE_PLAN_NAME_DO_NOT_TRANSLATE, - titleText: "Ai Quota Used", - usageText: "100 / 200 credits", - usedPercent: 0, - Strings: Strings, - getProLink: brackets.config.purchase_url - }; - - // Note: We don't await here to keep popup display instant - // Cached entitlements will be applied asynchronously after popup is shown - - // Render template with data immediately - const renderedTemplate = Mustache.render(profileTemplate, templateData); - $popup = $(renderedTemplate); - - $("body").append($popup); - isPopupVisible = true; - - positionPopup(); - - // Apply cached effective entitlements immediately if available (including quota/messages) - KernalModeTrust.loginService.getEffectiveEntitlements(false).then(cachedEntitlements => { - if (cachedEntitlements && isPopupVisible) { - _updatePopupWithEntitlements(cachedEntitlements); - } - }).catch(error => { - console.error('Failed to apply cached entitlements to popup:', error); - }); - - PopUpManager.addPopUp($popup, function() { - $popup.remove(); - $popup = null; - isPopupVisible = false; - }, true, { closeCurrentPopups: true }); - - $popup.find("#phoenix-account-btn").on("click", function () { - _handleAccountDetailsBtnClick(); - closePopup(); - }); - - $popup.find("#phoenix-support-btn").on("click", function () { - _handleContactSupportBtnClick(); - closePopup(); - }); - - $popup.find("#phoenix-signout-btn").on("click", function () { - _handleSignOutBtnClick(); - }); - - // handle window resize to reposition popup - $(window).on("resize.profilePopup", function () { - if (isPopupVisible) { - positionPopup(); - } - }); - - _setupDocumentClickHandler(); - - // Load user details iframe for browser apps (after popup is created) - _loadUserDetailsIframe(); - - // Refresh entitlements in background and update popup if still visible - _refreshEntitlementsInBackground(); - } - - /** - * Refresh entitlements in background and update popup if still visible - */ - async function _refreshEntitlementsInBackground() { - try { - const freshEntitlements = await KernalModeTrust.loginService.getEffectiveEntitlements(true); - - // Only update popup if it's still visible - if (isPopupVisible && $popup && freshEntitlements) { - _updatePopupWithEntitlements(freshEntitlements); - } - } catch (error) { - console.error('Failed to refresh entitlements in background:', error); - } - } - - /** - * Toggle the profile popup based on the user's login status - */ - function togglePopup() { - // check if the popup is already visible or not. if visible close it - if (isPopupVisible) { - closePopup(); - return; - } - - // Show popup immediately with cached status for instant response - if (KernalModeTrust.loginService.isLoggedIn()) { - showProfilePopup(); - } else { - showLoginPopup(); - } - - // Schedule background verification to update the popup if status changed - // Store the current login state before verification - const wasLoggedInBefore = KernalModeTrust.loginService.isLoggedIn(); - - // Set flag to indicate this is a background refresh - isBackgroundRefresh = true; - - KernalModeTrust.loginService._verifyLoginStatus().then(() => { - // Clear the background refresh flag - isBackgroundRefresh = false; - - // If the login status changed while popup is open, update it - if (isPopupVisible) { - const isLoggedInNow = KernalModeTrust.loginService.isLoggedIn(); - - if (wasLoggedInBefore !== isLoggedInNow) { - // Status changed, close current popup and show correct one - closePopup(); - if (isLoggedInNow) { - showProfilePopup(); - } else { - showLoginPopup(); - } - } - // If status didn't change, don't do anything to avoid closing popup - } - }).catch(error => { - // Clear the background refresh flag even on error - isBackgroundRefresh = false; - console.error("Background login status verification failed:", error); - }); - } - - /** - * Check if user has Pro access (active trial or device license) - * Works for both logged-in and non-logged-in users - */ - async function _hasProActive() { - try { - const effectiveEntitlements = await KernalModeTrust.loginService.getEffectiveEntitlements(); - return effectiveEntitlements && - (effectiveEntitlements.isInProTrial || - (effectiveEntitlements.plan && effectiveEntitlements.plan.isSubscriber)); - } catch (error) { - console.error('Failed to check Pro access status:', error); - return false; - } - } - - /** - * Initialize branding for non-logged-in users with Pro access (trial or device license) on startup - */ - async function _setBrandingForNonLoggedInUser() { - try { - const effectiveEntitlements = await KernalModeTrust.loginService.getEffectiveEntitlements(); - if (effectiveEntitlements && - (effectiveEntitlements.isInProTrial || - (effectiveEntitlements.plan && effectiveEntitlements.plan.isSubscriber))) { - console.log('Profile Menu: Found Pro entitlements (trial or device license), updating branding...'); - _updateBranding(effectiveEntitlements); - } else { - console.log('Profile Menu: No Pro entitlements found'); - _updateBranding(null); - } - } catch (error) { - console.error('Failed to initialize branding for non-logged-in Pro users:', error); - } - } - - let inited = false; - function init() { - if (inited) { - return; - } - inited = true; - const helpButtonID = "user-profile-button"; - $icon = $("") - .attr({ - id: helpButtonID, - href: "#", - class: "user", - title: Strings.CMD_USER_PROFILE - }) - .appendTo($("#main-toolbar .bottom-buttons")); - $icon.on('click', ()=>{ - togglePopup(); - }); - - // Initialize branding for non-logged-in users with Pro access (trial or device license) - _setBrandingForNonLoggedInUser(); - - // Listen for entitlements changes to update branding for non-logged-in Pro users - KernalModeTrust.loginService.on(KernalModeTrust.loginService.EVENT_ENTITLEMENTS_CHANGED, () => { - // When entitlements change (trial activation or device license) for non-logged-in users, update branding - if (!KernalModeTrust.loginService.isLoggedIn()) { - _setBrandingForNonLoggedInUser(); - } - }); - } - - function setNotLoggedIn() { - // Only close popup if it's not a background refresh - if (isPopupVisible && !isBackgroundRefresh) { - closePopup(); - } - _removeProfileIcon(); - - // Reset branding, but preserve Pro branding if user has active trial or device license - _hasProActive().then(hasProActive => { - if (!hasProActive) { - // Only reset branding if no trial or device license exists - console.log('Profile Menu: No Pro access, resetting branding to free'); - _updateBranding(null); - } else { - // User has trial or device license, maintain pro branding - console.log('Profile Menu: Pro access exists, maintaining pro branding'); - _setBrandingForNonLoggedInUser(); - } - }).catch(error => { - console.error('Failed to check Pro access status during logout:', error); - // Fallback to resetting branding - _updateBranding(null); - }); - // Clear cached entitlements when user logs out - KernalModeTrust.loginService.clearEntitlements(); - } - - function setLoggedIn(initial, color) { - // Only close popup if it's not a background refresh - if (isPopupVisible && !isBackgroundRefresh) { - closePopup(); - } - _updateProfileIcon(initial, color); - - // Preload effective entitlements when user logs in - KernalModeTrust.loginService.getEffectiveEntitlements() - .then(_updateBranding) - .catch(error => { - console.error('Failed to preload effective entitlements on login:', error); - }); - } - - exports.init = init; - exports.setNotLoggedIn = setNotLoggedIn; - exports.setLoggedIn = setLoggedIn; - - // dont public exports things that extensions can use to get/put credentials and entitlements, display mods is fine -}); diff --git a/src/services/promotions.js b/src/services/promotions.js deleted file mode 100644 index 4286d2ce97..0000000000 --- a/src/services/promotions.js +++ /dev/null @@ -1,451 +0,0 @@ -/* - * 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 logger, path*/ - -/** - * Promotions Service - * - * Manages pro trial promotions for both native and browser applications. - * Provides loginless pro trials - * - * - First install: 30-day trial on first usage - * - Subsequent versions: 7-day trial (or remaining from 30-day if still valid) - * - Older versions: No new trial, but existing 30-day trial remains valid - */ - -define(function (require, exports, module) { - - require("./setup-login-service"); // this adds loginService to KernalModeTrust - const Metrics = require("utils/Metrics"), - semver = require("thirdparty/semver.browser"), - ProDialogs = require("./pro-dialogs"); - - let dateNowFn = Date.now; - const KernalModeTrust = window.KernalModeTrust; - if (!KernalModeTrust) { - throw new Error("Promotions service requires access to KernalModeTrust. Cannot boot without trust ring"); - } - - const LoginService = KernalModeTrust.loginService; - - // Constants - const EVENT_PRO_UPGRADE_ON_INSTALL = "pro_upgrade_on_install"; - const PROMO_LOCAL_FILE = path.join(Phoenix.app.getApplicationSupportDirectory(), - Phoenix.isTestWindow ? "entitlements_promo_test.json" : "entitlements_promo.json"); - const TRIAL_POLL_MS = 1000; // We assign a free trial if possible as soon as user comes in for best UX. - const FIRST_INSTALL_TRIAL_DAYS = 30; - const SUBSEQUENT_TRIAL_DAYS = 7; - const MS_PER_DAY = 24 * 60 * 60 * 1000; - - // Error constants for _getTrialData - const ERR_CORRUPTED = "corrupted"; - - /** - * Async wrapper for fs.writeFile in browser - */ - function _writeFileAsync(filePath, data) { - return new Promise((resolve, reject) => { - window.fs.writeFile(filePath, data, 'utf8', (err) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); - }); - } - - /** - * Clear trial data from storage (reusable function) - */ - async function _clearTrialData() { - try { - if (Phoenix.isNativeApp) { - await KernalModeTrust.removeCredential(KernalModeTrust.CRED_KEY_PROMO); - } else { - await new Promise((resolve) => { - window.fs.unlink(PROMO_LOCAL_FILE, () => resolve()); // Always resolve, ignore errors - }); - } - } catch (error) { - console.log("Error clearing trial data:", error); - } - } - - /** - * Generate SHA-256 signature for trial data integrity - */ - async function _generateSignature(proVersion, endDate) { - const salt = await LoginService.getSalt(); - const data = proVersion + "|" + endDate; - return KernalModeTrust.generateDataSignature(data, salt); - } - - /** - * Validate trial data signature - */ - async function _isValidSignature(trialData) { - if (!trialData.signature || !trialData.proVersion || !trialData.endDate) { - return false; - } - - const expectedSignature = await _generateSignature(trialData.proVersion, trialData.endDate); - return trialData.signature === expectedSignature; - } - - /** - * Get stored trial data with validation and corruption detection - * Returns: {data: {...}} for valid data, {error: ERR_CORRUPTED} for errors, or null for no data - */ - async function _getTrialData() { - try { - if (Phoenix.isNativeApp) { - // Native app: use KernalModeTrust credential store - const data = await KernalModeTrust.getCredential(KernalModeTrust.CRED_KEY_PROMO); - if (!data) { - return null; // No data exists - genuine first install - } - try { - const trialData = JSON.parse(data); - const isValid = await _isValidSignature(trialData); - if (isValid) { - return { data: trialData }; // Valid trial data - } - return { error: ERR_CORRUPTED }; // Data exists but signature invalid - } catch (e) { - return { error: ERR_CORRUPTED }; // JSON parse error - } - } else { - // Browser app: use virtual filesystem. in future we need to always fetch from remote about trial - // entitlements for browser app. - const fileData = await Phoenix.VFS.readFileResolves(PROMO_LOCAL_FILE, 'utf8'); - - if (fileData.error) { - return null; // No data exists - genuine first install - } - - try { - const trialData = JSON.parse(fileData.data); - const isValid = await _isValidSignature(trialData); - if (isValid) { - return { data: trialData }; // Valid trial data - } - return { error: ERR_CORRUPTED }; // Data exists but signature invalid - } catch (e) { - return { error: ERR_CORRUPTED }; // JSON parse error - } - } - } catch (error) { - console.error("Error getting trial data:", error); - return { error: ERR_CORRUPTED }; // Treat error as corrupted/tampered data - } - } - - /** - * Store trial data with signature - */ - async function _setTrialData(trialData) { - trialData.signature = await _generateSignature(trialData.proVersion, trialData.endDate); - - try { - if (Phoenix.isNativeApp) { - // Native app: use KernalModeTrust credential store - await KernalModeTrust.setCredential(KernalModeTrust.CRED_KEY_PROMO, JSON.stringify(trialData)); - } else { - // Browser app: use virtual filesystem - await _writeFileAsync(PROMO_LOCAL_FILE, JSON.stringify(trialData)); - } - } catch (error) { - console.error("Error setting trial data:", error); - throw error; - } - } - - /** - * Calculate remaining trial days from end date - */ - function _calculateRemainingTrialDays(existingTrialData) { - const now = dateNowFn(); - const trialEndDate = existingTrialData.endDate; - - // Calculate days remaining until trial ends - const msRemaining = trialEndDate - now; - return Math.max(0, Math.ceil(msRemaining / MS_PER_DAY)); // days remaining - } - - /** - * Check if version1 is newer than version2 using semver - */ - function _isNewerVersion(version1, version2) { - try { - return semver.gt(version1, version2); - } catch (error) { - console.error("Error comparing versions:", error, version1, version2); - // Assume not newer if comparison fails - return false; - } - } - - /** - * Check if user has active pro subscription. this calls actual login endpoint and is not to be used frequently!. - * Returns true if user is logged in and has a paid subscription - */ - async function _hasProSubscription() { - try { - // First verify login status to ensure login state is properly resolved - await LoginService._verifyLoginStatus(); - - // getEntitlements() returns null if not logged in - const entitlements = await LoginService.getEntitlements(); - return entitlements && entitlements.plan && entitlements.plan.isSubscriber === true; - } catch (error) { - console.error("Error checking pro subscription:", error); - return false; - } - } - - function _isTrialClosedForCurrentVersion(currentTrialData) { - if(!currentTrialData) { - return false; - } - const currentVersion = window.AppConfig ? window.AppConfig.apiVersion : "1.0.0"; - const remainingDays = _calculateRemainingTrialDays(currentTrialData); - const trialVersion = currentTrialData.proVersion; - const isNewerVersion = _isNewerVersion(currentVersion, trialVersion); - const trialClosedDialogShown = currentTrialData.upgradeDialogShownVersion === currentVersion; - // if isCurrentVersionTrialClosed and if remainingDays > 0, it means that user put back system time to - // before trial end. in this case we should not grant any trial. - return trialClosedDialogShown || (remainingDays <= 0 && !isNewerVersion); - } - - /** - * Get remaining pro trial days - * Returns 0 if no trial or trial expired - */ - async function getProTrialDaysRemaining() { - const result = await _getTrialData(); - if (!result || result.error || _isTrialClosedForCurrentVersion(result.data)) { - return 0; - } - - return _calculateRemainingTrialDays(result.data); - } - - async function activateProTrial() { - const currentVersion = window.AppConfig ? window.AppConfig.apiVersion : "1.0.0"; - const result = await _getTrialData(); - - let trialDays = FIRST_INSTALL_TRIAL_DAYS; - let endDate; - const now = dateNowFn(); - let metricString = `${currentVersion.replaceAll(".", "_")}`; // 3.1.0 -> 3_1_0 - - // Handle corrupted or parse failed data - reset trial state and deny any trial grants - if (result && result.error) { - console.warn(`Trial data error detected (${result.error}) - resetting trial state without granting trial`); - Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "trial", "corrupt"); - - // Check if user has pro subscription - const hasProSubscription = await _hasProSubscription(); - if (hasProSubscription) { - console.log("User has pro subscription - resetting corrupted trial marker"); - await _setTrialData({ - proVersion: currentVersion, - endDate: now // Expires immediately - }); - return; - } - - // 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.showProUpsellDialog(ProDialogs.UPSELL_TYPE_PRO_TRIAL_ENDED); // Show ended dialog for security - - // Create expired trial marker to prevent future trial grants - await _setTrialData({ - proVersion: currentVersion, - endDate: now // Expires immediately - }); - return; - } - - const existingTrialData = result ? result.data : null; - if (existingTrialData) { - // Existing trial found - const remainingDays = _calculateRemainingTrialDays(existingTrialData); - const trialVersion = existingTrialData.proVersion; - const isNewerVersion = _isNewerVersion(currentVersion, trialVersion); - - // Check if we should grant any trial - if (_isTrialClosedForCurrentVersion(existingTrialData)) { - // Check if promo ended dialog was already shown for this version - if (existingTrialData.upgradeDialogShownVersion !== currentVersion) { - // Check if user has pro subscription before showing promo dialog - const hasProSubscription = await _hasProSubscription(); - if (!hasProSubscription) { - console.log("Existing trial expired, showing promo ended dialog"); - ProDialogs.showProUpsellDialog(ProDialogs.UPSELL_TYPE_PRO_TRIAL_ENDED); - } else { - console.log("Existing trial expired, but user has pro subscription - skipping promo dialog"); - } - // Store that dialog was shown for this version - await _setTrialData({ - ...existingTrialData, - upgradeDialogShownVersion: currentVersion - }); - } else { - console.log("Existing trial expired, upgrade dialog already shown for this version"); - } - return; - } - - // Determine trial days and end date - if (isNewerVersion) { - if (remainingDays >= SUBSEQUENT_TRIAL_DAYS) { - // Newer version but existing trial is longer - keep existing - console.log(`Newer version, keeping existing trial (${remainingDays} days)`); - trialDays = remainingDays; - endDate = existingTrialData.endDate; - metricString = `nD_${metricString}_upgrade`; - } else { - // Newer version with shorter existing trial - give 7 days - console.log(`Newer version - granting ${SUBSEQUENT_TRIAL_DAYS} days trial`); - trialDays = SUBSEQUENT_TRIAL_DAYS; - endDate = now + (trialDays * MS_PER_DAY); - metricString = `3D_${metricString}`; - } - } else { - // Same/older version: keep existing trial - no changes needed - console.log(`Same/older version - keeping existing ${remainingDays} day trial.`); - return; - } - } else { - // First install - 30 days from now - endDate = now + (FIRST_INSTALL_TRIAL_DAYS * MS_PER_DAY); - metricString = `1Mo_${metricString}`; - } - - const trialData = { - proVersion: currentVersion, - endDate: endDate - }; - - await _setTrialData(trialData); - Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "trialAct", metricString); - Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "trial", "activated"); - console.log(`Pro trial activated for ${trialDays} days`); - - // Check if user has pro subscription before showing upgrade dialog - const hasProSubscription = await _hasProSubscription(); - if (!hasProSubscription) { - ProDialogs.showProTrialStartDialog(trialDays); - } else { - console.log("Pro trial activated, but user has pro subscription - skipping upgrade dialog"); - } - // Trigger the event for UI to handle - LoginService.trigger(EVENT_PRO_UPGRADE_ON_INSTALL, { - trialDays: trialDays, - isFirstInstall: !existingTrialData - }); - - // Also trigger entitlements changed event since effective entitlements have changed - // This allows UI components to update based on the new trial status - await LoginService.getEffectiveEntitlements(); - LoginService.trigger(LoginService.EVENT_ENTITLEMENTS_CHANGED); - } - - function _isAnyDialogsVisible() { - const dialogsVisible = $(`.modal.instance`).is(':visible'); - const notificationsVisible = $(`.notification-ui-tooltip`).is(':visible'); - return dialogsVisible || notificationsVisible; - } - - /** - * Start the pro trial activation process - */ - console.log(`Checking pro trial activation in ${TRIAL_POLL_MS / 1000} seconds...`); - - const trialActivatePoller = setInterval(()=> { - if(Phoenix.isTestWindow) { - clearInterval(trialActivatePoller); - return; - } - if(_isAnyDialogsVisible()){ - // maybe the user hasn't dismissed the new project dialog - return; - } - clearInterval(trialActivatePoller); - activateProTrial().catch(error => { - Metrics.countEvent(Metrics.EVENT_TYPE.PRO, "trial", `errActivate`); - logger.reportError(error, "Error activating pro trial:"); - }); - }, TRIAL_POLL_MS); - - // Add to secure exports - LoginService.getProTrialDaysRemaining = getProTrialDaysRemaining; - LoginService.EVENT_PRO_UPGRADE_ON_INSTALL = EVENT_PRO_UPGRADE_ON_INSTALL; - - // Test-only exports for integration testing - if (Phoenix.isTestWindow) { - window._test_promo_login_exports = { - LoginService: LoginService, - ProDialogs: ProDialogs, - _getTrialData: _getTrialData, - _setTrialData: _setTrialData, - _isTrialClosedForCurrentVersion: _isTrialClosedForCurrentVersion, - _cleanTrialData: _clearTrialData, - _cleanSaltData: async function() { - try { - if (Phoenix.isNativeApp) { - await KernalModeTrust.removeCredential(KernalModeTrust.SIGNATURE_SALT_KEY); - console.log("Salt data cleanup completed"); - } - // in browser app we always return a static salt, so no need to clear it - } catch (error) { - // Ignore cleanup errors - console.log("Salt data cleanup completed (ignoring errors)"); - } - }, - // Test-only functions for manipulating credentials directly (bypassing validation) - _testSetPromoJSON: async function(data) { - if (Phoenix.isNativeApp) { - await KernalModeTrust.setCredential(KernalModeTrust.CRED_KEY_PROMO, JSON.stringify(data)); - } else { - await _writeFileAsync(PROMO_LOCAL_FILE, JSON.stringify(data)); - } - }, - activateProTrial: activateProTrial, - getProTrialDaysRemaining: getProTrialDaysRemaining, - setDateNowFn: function _setDdateNowFn(fn) { - dateNowFn = fn; - }, - EVENT_PRO_UPGRADE_ON_INSTALL: EVENT_PRO_UPGRADE_ON_INSTALL, - TRIAL_CONSTANTS: { - FIRST_INSTALL_TRIAL_DAYS, - SUBSEQUENT_TRIAL_DAYS, - MS_PER_DAY - }, - ERROR_CONSTANTS: { - ERR_CORRUPTED - } - }; - } - - // no public exports to prevent extension tampering -}); diff --git a/src/services/readme-login-browser-no_dist.md b/src/services/readme-login-browser-no_dist.md deleted file mode 100644 index 837dc4b2b3..0000000000 --- a/src/services/readme-login-browser-no_dist.md +++ /dev/null @@ -1,215 +0,0 @@ -# Phoenix Browser Login Service Integration - -This document provides comprehensive documentation for integrating with the Phoenix login service in browser applications specifically. For desktop application authentication, see `readme-login-desktop-no_dist.md`. - -## Overview - -The Phoenix browser application uses a login service to authenticate users across the phcode.dev domain ecosystem. The login service handles user authentication, session management, and provides secure API endpoints for login operations. - -**Key Features:** -- Domain-wide session management using session cookies -- Secure user profile display via iframe integration -- Proxy server support for localhost development - -**Key Files:** -- `src/services/login-browser.js` - Main browser login implementation -- `serve-proxy.js` - Proxy server for localhost development -- `readme-login-browser-no_dist.md` - This documentation file for detailed integration guide -- `readme-login-desktop-no_dist.md` - Desktop authentication documentation - -## Architecture - -### Production Environment - -In production, the browser application uses `https://account.phcode.dev` as the login service endpoint. - -**Domain-Wide Session Management:** -- Login service sets a `session` cookie at the `.phcode.dev` domain level -- This cookie is automatically shared across all subdomains: - - `phcode.dev` - - `dev.phcode.dev` - - `*.phcode.dev` (any subdomain) -- Users login once and stay authenticated across the entire ecosystem - -**Communication Flow:** -``` -Browser App (*.phcode.dev) → account.phcode.dev - ← session cookie set for .phcode.dev -``` - -### Development Environment (localhost:8000) - -**Challenge:** -localhost:8000 doesn't share the `.phcode.dev` domain, so session cookies from account.phcode.dev are not automatically available. - -**Solution:** -Manual session cookie copying with proxy server routing. - -#### Proxy Server Architecture - -The `serve-proxy.js` server handles API routing for localhost development: - -``` -Browser (localhost:8000) → /proxy/accounts/* → serve-proxy.js → https://account.phcode.dev/* - ← Response with cookies - ← Cookies forwarded back to browser -``` - -**Key Function in login-browser.js:** -```javascript -function _getAccountBaseURL() { - if (location.hostname === 'localhost' || location.hostname === '127.0.0.1') { - return '/proxy/accounts'; // Use proxy for localhost - } - return Phoenix.config.account_url.replace(/\/$/, ''); // Direct URL for production -} -``` - -## Development Setup Instructions - -### Standard Development (localhost:8000 → account.phcode.dev) - -1. **Login to Production Account Service:** - - Open browser and navigate to `https://account.phcode.dev` - - Login with your credentials - - Account service sets `session` cookie for `.phcode.dev` domain - -2. **Copy Session Cookie to Localhost:** - - Open Chrome DevTools (F12) on `https://account.phcode.dev` - - Go to Application → Cookies → `https://account.phcode.dev` - - Find the `session` cookie and copy its value - -3. **Set Cookie in Localhost App:** - - Navigate to `http://localhost:8000/src/` - - Open Chrome DevTools (F12) - - Go to Application → Cookies → `http://localhost:8000` - - Create new cookie: - - **Name:** `session` - - **Value:** [paste copied value] - - **Domain:** `localhost` - - **Path:** `/` - - **HttpOnly:** ✓ (check if available) - -4. **Verify Integration:** - - Refresh `http://localhost:8000/src/` - - Login should work automatically using the copied session - -### Custom Login Server Development (localhost:5000) - -For testing with a local account server instance: - -1. **Configure Proxy Server:** - - use `npm run serveLocalAccount` to serve phoenix repo server, instead of using npm run serve command. - - use `npm run serveStagingAccount` to use the staging endpoint. To get access to staging server, contact team. - -2. **Setup Local Account Server:** - - Start your local account development stack on `localhost:5000` - - Ensure all login endpoints are properly configured - -Now just visit login server at `http://localhost:5000` and login. It should work with phoenix code dev server -at https://localhost:8000/src when you run phoenix code dev server via `npm run serve`. This works without any -manual cookie copy needed as the dev server sets cookies localhost wide. But if that didnt work, please see -manual cookie copy instructions below. - -For staging server at https://account-stage.phcode.dev , you may need to copy cookie manually like below: - -1. **Login and Copy Session:** - - Navigate to `http://localhost:5000` in browser. (if staging, use https://account-stage.phcode.dev) - - Login with your credentials - - Copy `session` cookie value from DevTools - -2. **Set Cookie in Phoenix App:** - - Navigate to `http://localhost:8000/src/` - - Open Chrome DevTools → Application → Cookies - - Create `session` cookie with copied value (same process as above) - -3. **Verify Local Integration:** - - API calls from localhost:8000 now route through serve-proxy.js to localhost:5000 - - Authentication should work with local account server - -## API Endpoints - -The login service provides these key endpoints: - -### Authentication -- `POST /signOutPost` - Sign out user (new endpoint with proper JSON handling) -- `GET /resolveBrowserSession` - Validate and resolve current session (returns masked user data for security) -- `GET /signOut` - Legacy signout endpoint (deprecated for browser use) - -### User Profile Display -- `GET /getUserDetailFrame` - Returns HTML iframe with full user details for secure display - - Query parameters for styling: `includeName`, `nameFontSize`, `emailFontSize`, `nameColor`, `emailColor`, `backgroundColor` - - CSP-protected to only allow embedding in trusted domains - - Cross-origin communication via postMessage when loaded - -### Session Management -- Session validation through `session` cookie -- Automatic session invalidation on logout -- Session sharing across domain ecosystem - -## Communication Paths - -### Production (phcode.dev subdomains): -``` -Browser App → Direct HTTPS → account.phcode.dev - ← Session cookie for .phcode.dev ← -``` - -### Development (localhost): -``` -Browser (localhost:8000) → /proxy/accounts/* → serve-proxy.js - ↓ - account.phcode.dev (or localhost:5000) - ↓ - ← API response ← serve-proxy.js -``` - -## Troubleshooting - -### Common Issues - -**1. "No session found" errors:** -- Verify `session` cookie is set correctly in browser -- Check cookie domain and path settings -- Ensure cookie hasn't expired - -**2. CORS errors in development:** -- Verify serve-proxy.js is running on port 8000 -- Check proxy configuration in serve-proxy.js -- Confirm account server URL is correct - -**3. Login popup doesn't appear:** -- Check if popup blockers are enabled -- Verify account service URL is accessible -- Check browser console for JavaScript errors - -**4. Session not persisting:** -- Ensure cookie is set with correct domain -- Check if HttpOnly flag is properly configured -- Verify account service is responding correctly - -### Development Tips - -1. **Always use Chrome DevTools** to inspect cookies and network requests -2. **Monitor Network tab** to see actual API calls and responses -3. **Check Console** for authentication-related errors -4. **Verify proxy routing** by checking serve-proxy.js logs -5. **Test both login and logout flows** to ensure complete functionality - -## Security Considerations - -- Session cookies are HttpOnly and secure in production -- Always use HTTPS in production environments -- Local development should never use production user credentials in local account servers -- Session cookies should have appropriate expiration times -- Logout should properly invalidate sessions on both client and server - -### User Data Security -- **Masked API Data**: The `resolveBrowserSession` endpoint returns masked user data (e.g., "J***", "j***@g***.com") to prevent exposure to browser extensions -- **Secure iframe Display**: Full user details are displayed via iframe from trusted account server -- **CSP Protection**: iframe is protected by Content Security Policy headers restricting embedding domains -- **Cross-Origin Safety**: iframe communication uses secure postMessage protocol - ---- - -For browser implementation details, see the source code in `src/services/login-browser.js` and related files. For desktop authentication, see `src/services/login-desktop.js` and `readme-login-desktop-no_dist.md`. diff --git a/src/services/readme-login-desktop-no_dist.md b/src/services/readme-login-desktop-no_dist.md deleted file mode 100644 index 97673f1197..0000000000 --- a/src/services/readme-login-desktop-no_dist.md +++ /dev/null @@ -1,325 +0,0 @@ -# Phoenix Desktop Login Service Integration - -This document provides comprehensive documentation for integrating with the Phoenix login service in desktop applications. For browser application authentication, see `readme-login-browser-no_dist.md`. - -## Overview - -The Phoenix desktop application uses a fundamentally different authentication approach compared to browser applications. Instead of session cookies, desktop apps use API keys with enhanced security measures to prevent phishing attacks and ensure secure credential storage. - -**Key Files:** -- `src/services/login-desktop.js` - Main desktop login implementation -- `readme-login-desktop-no_dist.md` - This documentation file -- `readme-login-browser-no_dist.md` - Browser authentication documentation -- Kernel Mode Trust files - For secure credential storage (referenced for further reading) - -## Architecture Overview - -### Core Differences from Browser Authentication - -| Feature | Desktop Application | Browser Application | -|---------|-------------------|-------------------| -| **Authentication Method** | API Keys | Session Cookies | -| **Storage** | System Keychain via Tauri APIs | Browser cookies | -| **Security Layer** | Kernel Mode Trust | Domain-based security | -| **Phishing Protection** | Verification Codes | Domain validation | -| **Cross-window sync** | Preference-based notifications | Shared domain cookies | - -## Desktop Authentication Flow - -### 1. API Key-Based Authentication - -Desktop applications do **NOT** use session cookies. Instead, they use API keys that are: - -- Obtained through a secure authentication flow -- Stored securely in the system keychain via Tauri APIs -- Inaccessible to browser extensions due to Kernel Mode Trust security posture -- Required with every API request - -### 2. Verification Code Security System - -To prevent phishing attacks where malicious users could send authentication URLs to unsuspecting victims, the desktop app implements a verification code system: - -**The Attack Vector:** -- Malicious user generates an authentication URL -- Sends it to victim via email/message -- Victim clicks and logs in, unknowingly giving access to malicious user - -**The Protection:** -- Desktop app generates a unique verification code for each login session -- User must enter this verification code to complete authentication -- Even if a victim logs in with a malicious URL, they cannot provide the verification code - -### 3. Auto-Verification Flow - -For improved user experience, the desktop app includes an automatic verification system: - -**Components:** -- **Local Node.js Server:** Started by desktop app on a dynamically detected free port (port 0) -- **Random URL Security:** Auto auth endpoint uses randomly generated URL path (`/AutoAuth${randomNonce(8)}`) for security -- **Account Service Integration:** account.phcode.dev/auth communicates with the secure localhost endpoint -- **Automatic Code Exchange:** Verification code automatically provided if on same machine - -**Browser Compatibility:** -- ✅ **Chrome/Chromium:** Full auto-verification support -- ✅ **Firefox:** Full auto-verification support -- ❌ **Safari:** Auto-verification **BLOCKED** - Safari's security policy prevents HTTPS sites from connecting to localhost -- ⚠️ **Other Browsers:** May vary based on security policies - -**Flow:** -1. Desktop app starts local Node.js server on a dynamically detected free port -2. Random secure auto auth URL is generated: `http://localhost:{port}/AutoAuth{randomNonce}` -3. User initiates login, gets verification code and auto auth URL -4. Desktop app sends verification code to local server via `setVerificationCode()` -5. User clicks "Open in Browser" → goes to account.phcode.dev/auth -6. Account service attempts GET to `{autoAuthURL}/autoVerifyCode` endpoint -7. **If successful (Chrome/Firefox):** verification code automatically retrieved and used -8. Account service calls `{autoAuthURL}/appVerified` to notify desktop app -9. **If failed (Safari/blocked):** user manually enters verification code - -## Secure Credential Storage - -### Kernel Mode Trust Integration - -Desktop applications leverage Kernel Mode Trust for secure credential management: - -- **API Key Storage:** Securely stored in system keychain via Tauri APIs -- **Extension Isolation:** External extensions cannot access credentials -- **Integrated Extensions Only:** Only integrated extensions have access to Kernel Mode Trust -- **Cross-Platform Security:** Tauri provides secure storage across Windows, Mac, Linux - -**For detailed technical implementation of Kernel Mode Trust security architecture, refer to the Kernel Mode Trust source files (out of scope for this document).** - -## Authentication Endpoints and APIs - -### Key API Endpoints - -#### Authentication Session Management -```javascript -// Get app authentication session -GET ${Phoenix.config.account_url}getAppAuthSession?autoAuthPort=${authPortURL}&appName=${appName} -// Response: {"isSuccess":true,"appSessionID":"uuid...","validationCode":"SWXP07"} -``` - -#### API Key Resolution -```javascript -// Resolve API key with verification code -GET ${Phoenix.config.account_url}resolveAppSessionID?appSessionID=${apiKey}&validationCode=${validationCode} -// Response: User profile details if valid -``` - -#### Session Logout -```javascript -// Logout session -POST ${Phoenix.config.account_url}logoutSession -// Body: {"appSessionID": "api_key"} -``` - -#### Auto-Verification Endpoints (Local Server) - -The desktop app creates a local Node.js server with secure auto-authentication endpoints: - -```javascript -// Auto auth base URL (generated with random nonce for security) -// Example: http://localhost:43521/AutoAuthDI0zAUJo -const autoAuthURL = KernalModeTrust.localAutoAuthURL; - -// Get verification code endpoint -GET {autoAuthURL}/autoVerifyCode -// Response: {"code": "SWXP07"} or 404 if no code available -// Headers: Access-Control-Allow-Origin: https://account.phcode.dev - -// App verified notification endpoint -GET {autoAuthURL}/appVerified -// Response: "ok" -// Triggers desktop app to check login status -``` - -**Security Features:** -- **Random URL Path:** `/AutoAuth{randomNonce(8)}` makes URL unguessable -- **Origin Restrictions:** Only `https://account.phcode.dev` allowed -- **One-time Use:** Verification code returned only once, then cleared -- **Localhost Only:** Server binds to localhost interface only - -### API Request Authentication - -Unlike browser applications that rely on automatic cookie transmission, desktop applications must explicitly include the API key with every request: - -```javascript -// Every API call must include the API key -const userProfile = await KernalModeTrust.getCredential(KernalModeTrust.CRED_KEY_API); -const apiKey = JSON.parse(userProfile).apiKey; -// Include apiKey in request headers or parameters -``` - -## Implementation Details - -### Login Process - -1. **Initiate Login:** - ```javascript - const appAuthSession = await _getAppAuthSession(); - const {appSessionID, validationCode} = appAuthSession; - ``` - -2. **Setup Auto-Verification:** - ```javascript - await setAutoVerificationCode(validationCode); - ``` - -3. **Show Verification Dialog:** - - Display verification code to user - - Provide "Open in Browser" button - - Allow manual code entry if auto-verification fails - -4. **Monitor Authentication Status:** - ```javascript - const resolveResponse = await _resolveAPIKey(appSessionID, validationCode); - if(resolveResponse.userDetails) { - // Authentication successful - userProfile = resolveResponse.userDetails; - await KernalModeTrust.setCredential(KernalModeTrust.CRED_KEY_API, JSON.stringify(userProfile)); - } - ``` - -### Credential Management - -#### Storing Credentials -```javascript -// Store API key securely in system keychain -await KernalModeTrust.setCredential(KernalModeTrust.CRED_KEY_API, JSON.stringify(userProfile)); -``` - -#### Retrieving Credentials -```javascript -// Retrieve stored credentials -const savedUserProfile = await KernalModeTrust.getCredential(KernalModeTrust.CRED_KEY_API); -const userProfile = JSON.parse(savedUserProfile); -``` - -#### Removing Credentials (Logout) -```javascript -// Remove credentials from keychain -await KernalModeTrust.removeCredential(KernalModeTrust.CRED_KEY_API); -``` - -## Multi-Window Synchronization - -Desktop applications handle multi-window authentication synchronization through preferences: - -```javascript -const PREF_USER_PROFILE_VERSION = "userProfileVersion"; - -// Notify other windows of login state changes -PreferencesManager.stateManager.set(PREF_USER_PROFILE_VERSION, crypto.randomUUID()); - -// Listen for changes in other windows -const pref = PreferencesManager.stateManager.definePreference(PREF_USER_PROFILE_VERSION, 'string', '0'); -pref.watchExternalChanges(); -pref.on('change', _verifyLogin); -``` - -## Security Considerations - -### Phishing Attack Prevention -- **Verification Code System:** Prevents unauthorized access even if user logs in with malicious URL -- **Time-Limited Sessions:** Authentication sessions expire after 5 minutes -- **Local Verification:** Auto-verification only works on same machine - -### Secure Storage -- **System Keychain:** Credentials stored in OS-provided secure storage -- **Tauri Security:** Leverages Tauri's security model for cross-platform protection -- **Extension Isolation:** External extensions cannot access stored credentials - -### API Key Management -- **Unique Per Session:** Each authentication generates new API key -- **Server-Side Validation:** All API keys validated server-side -- **Proper Logout:** Server-side session invalidation on logout - -## Development and Testing - -### Testing with Local Login Server - -For testing desktop authentication with a local account server: - -1. **Configure Proxy Server:** - - use `npm run serveLocalAccount` to serve phoenix repo server, instead of using npm run serve command. - - use `npm run serveStagingAccount` to use the staging endpoint. To get access to staging server, contact team. - -2. **Setup Local Account Server:** - - Start your local account development stack on `localhost:5000` - - Ensure all login endpoints are properly configured - -3. **Test Desktop Authentication:** - - Desktop app will now use your local/staging server for all authentication calls - - Verification codes and API key resolution will go through your local/staging server - - Auto-verification will attempt to connect to your local account service - -**Note:** Like browser testing which requires proxy server configuration, desktop apps also use the proxy server -for communication with backend. - -## Troubleshooting - -### Common Issues - -**1. "No savedUserProfile found" errors:** -- Check if Kernel Mode Trust is properly initialized -- Verify Tauri keychain access permissions -- Ensure credentials weren't cleared by system security policies - -**2. Verification code timeout:** -- Verification codes expire after 5 minutes -- User must restart login process if expired -- Check local Node.js server connectivity for auto-verification - -**3. Auto-verification fails:** -- **Safari Browser:** Auto-verification is blocked by Safari's security policies -- **Firewall:** May be blocking localhost communication -- **Local Server Issues:** Server may not be running properly -- **Solution:** Always fall back to manual verification code entry - -**4. API key validation failures:** -- Check network connectivity to account service -- Verify API key hasn't been invalidated server-side -- Confirm account service URL configuration - -### Development Tips - -1. **Monitor Kernel Mode Trust Access:** Ensure proper initialization and access patterns -2. **Test Auto-Verification Flow:** - - Test in Chrome/Firefox for full functionality - - Test in Safari to ensure graceful fallback to manual entry - - Verify localhost server starts and responds correctly -3. **Browser Testing Strategy:** - - Chrome/Firefox: Expect auto-verification to work - - Safari: Always expect manual verification flow - - Test user experience in both scenarios -4. **Validate Credential Storage:** Check system keychain directly if available -5. **Test Multi-Window Sync:** Verify login state propagates across application windows -6. **Security Testing:** Test phishing protection by attempting malicious URL scenarios - -## API Error Handling - -### Error Codes -```javascript -const ERR_RETRY_LATER = "retry_later"; // Network/temporary errors -const ERR_INVALID = "invalid"; // API key/verification code invalid -``` - -### Response Handling -```javascript -const resolveResponse = await _resolveAPIKey(apiKey, validationCode); -if(resolveResponse.userDetails) { - // Success: use userDetails -} else if(resolveResponse.err === ERR_INVALID) { - // Invalid credentials: force re-authentication - await _resetAccountLogin(); -} else if(resolveResponse.err === ERR_RETRY_LATER) { - // Temporary error: retry later -} -``` - ---- - -For desktop implementation details, see the source code in `src/services/login-desktop.js`. For browser authentication, see `src/services/login-browser.js` and `readme-login-browser-no_dist.md`. - -For deeper understanding of the Kernel Mode Trust security architecture and secure credential storage implementation, refer to the Kernel Mode Trust source files (out of scope for this document). diff --git a/src/services/setup-login-service.js b/src/services/setup-login-service.js deleted file mode 100644 index 1c8804d22d..0000000000 --- a/src/services/setup-login-service.js +++ /dev/null @@ -1,43 +0,0 @@ -/* - * 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. - * - */ - -/** - * Shared Login Service kernal mode trust setup - * - * This module contains shared login service functionality used by both - * browser and desktop login implementations, including entitlements management. - */ - -define(function (require, exports, module) { - const EventDispatcher = require("utils/EventDispatcher"); - - 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"); - } - - // Create secure exports and set up KernalModeTrust.loginService - const secureExports = {}; - EventDispatcher.makeEventDispatcher(secureExports); - - // Set up loginService for both native and browser apps - KernalModeTrust.loginService = secureExports; - - // no public exports to prevent extension tampering -}); diff --git a/src/utils/Metrics.js b/src/utils/Metrics.js index 46fa0142fc..da4876105e 100644 --- a/src/utils/Metrics.js +++ b/src/utils/Metrics.js @@ -268,7 +268,7 @@ define(function (require, exports, module) { async function _setPowerUserPrefix() { powerUserPrefix = null; - const EntitlementsManager = KernalModeTrust.EntitlementsManager; + const EntitlementsManager = KernalModeTrust.EntitlementsManager; // can be null in free builds if(cachedIsPowerUser){ // A power user is someone who used Phoenix at least 3 days/8 hours in the last two weeks powerUserPrefix = "P"; @@ -276,7 +276,7 @@ define(function (require, exports, module) { // A repeat user is a user who has used phoenix at least one other day before powerUserPrefix = "R"; } - if(EntitlementsManager.isLoggedIn()){ + if(EntitlementsManager && EntitlementsManager.isLoggedIn()){ if(await EntitlementsManager.isPaidSubscriber()){ powerUserPrefix = "S"; // subscriber return; @@ -306,8 +306,8 @@ define(function (require, exports, module) { _setPowerUserPrefix(); }, ONE_DAY); } - KernalModeTrust.EntitlementsManager.on(KernalModeTrust.EntitlementsManager.EVENT_ENTITLEMENTS_CHANGED, - _setPowerUserPrefix); + KernalModeTrust.EntitlementsManager && KernalModeTrust.EntitlementsManager.on( + KernalModeTrust.EntitlementsManager.EVENT_ENTITLEMENTS_CHANGED, _setPowerUserPrefix); } // some events generate too many ga events that ga can't handle. ignore them. diff --git a/test/UnitTestSuite.js b/test/UnitTestSuite.js index 0282b79436..396cbede28 100644 --- a/test/UnitTestSuite.js +++ b/test/UnitTestSuite.js @@ -119,12 +119,8 @@ define(function (require, exports, module) { require("spec/TaskManager-integ-test"); require("spec/Generic-integ-test"); require("spec/spacing-auto-detect-integ-test"); - require("spec/promotions-integ-test"); - require("spec/login-browser-integ-test"); - require("spec/login-desktop-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-browser-integ-test.js b/test/spec/login-browser-integ-test.js deleted file mode 100644 index 7d7ec32023..0000000000 --- a/test/spec/login-browser-integ-test.js +++ /dev/null @@ -1,314 +0,0 @@ -/* - * 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, beforeAll, afterAll, beforeEach, afterEach, awaitsFor, awaitsForDone, awaits*/ - -define(function (require, exports, module) { - - const SpecRunnerUtils = require("spec/SpecRunnerUtils"); - const LoginShared = require("./login-shared"); - - describe("integration: login/logout browser app tests", function () { - - if (Phoenix.isNativeApp) { - // Browser login tests are not applicable for native apps - it("This test disabled in native apps as its browser login tests", function () { - expect(1).toEqual(1); - }); - return; - } - - let testWindow, - LoginServiceExports, - LoginBrowserExports, - ProDialogsExports, - EntitlementsExports, - entitlementsService, - originalOpen, - originalFetch; - - let setupTrialState, - setupExpiredTrial, - verifyProBranding, - verifyProfilePopupContent, - cleanupTrialState, - popupToAppear, - performFullLogoutFlow, - verifyProfileIconBlanked, - VIEW_TRIAL_DAYS_LEFT, - VIEW_PHOENIX_PRO, - VIEW_PHOENIX_FREE, - SIGNIN_POPUP, - PROFILE_POPUP; - - beforeAll(async function () { - testWindow = await SpecRunnerUtils.createTestWindowAndRun(); - - // Wait for test exports to be available (KernalModeTrust is sandboxed, use test exports) - await awaitsFor( - function () { - return testWindow._test_login_service_exports && - testWindow._test_login_browser_exports && - testWindow._test_pro_dlg_login_exports && - testWindow._test_entitlements_exports; - }, - "Test exports to be available", - 5000 - ); - - // Access the login service exports from the test window - LoginServiceExports = testWindow._test_login_service_exports; - LoginBrowserExports = testWindow._test_login_browser_exports; - ProDialogsExports = testWindow._test_pro_dlg_login_exports; - EntitlementsExports = testWindow._test_entitlements_exports; - entitlementsService = EntitlementsExports.EntitlementsService; - entitlementsService.on(entitlementsService.EVENT_ENTITLEMENTS_CHANGED, - LoginShared.entitlmentsChangedHandler); - - // Store original functions for restoration - originalOpen = testWindow.open; - originalFetch = testWindow.fetch; - - // Wait for profile menu to be initialized - await awaitsFor( - function () { - return testWindow.$("#user-profile-button").length > 0; - }, - "Profile button to be available", - 3000 - ); - 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; - SIGNIN_POPUP = LoginShared.SIGNIN_POPUP; - PROFILE_POPUP = LoginShared.PROFILE_POPUP; - setupTrialState = LoginShared.setupTrialState; - setupExpiredTrial = LoginShared.setupExpiredTrial; - verifyProBranding = LoginShared.verifyProBranding; - verifyProfilePopupContent = LoginShared.verifyProfilePopupContent; - cleanupTrialState = LoginShared.cleanupTrialState; - popupToAppear = LoginShared.popupToAppear; - performFullLogoutFlow = LoginShared.performFullLogoutFlow; - verifyProfileIconBlanked = LoginShared.verifyProfileIconBlanked; - }, 30000); - - afterAll(async function () { - // Restore original functions - entitlementsService.off(entitlementsService.EVENT_ENTITLEMENTS_CHANGED, - LoginShared.entitlmentsChangedHandler); - testWindow.open = originalOpen; - - // Restore all fetch function overrides - LoginServiceExports.setFetchFn(originalFetch); - LoginBrowserExports.setFetchFn(originalFetch); - ProDialogsExports.setFetchFn(originalFetch); - - testWindow = null; - LoginServiceExports = null; - LoginBrowserExports = null; - ProDialogsExports = null; - originalOpen = null; - originalFetch = null; - await SpecRunnerUtils.closeTestWindow(); - }, 30000); - - beforeEach(function () { - // Ensure we start each test in a logged-out state - // Note: We can't easily reset login state, so tests should handle this - }); - - function setupProUserMock(hasActiveSubscription = true, expiredEntitlements = false) { - let userSignedOut = false; - - // Set fetch mock on both browser and service exports - const fetchMock = (url, options) => { - console.log("llgT: browser promo test fetchFn called with URL:", url); - - if (url.includes('/resolveBrowserSession')) { - if (userSignedOut) { - return Promise.resolve({ - ok: false, - status: 401, - json: () => Promise.resolve({ isSuccess: false }) - }); - } - const response = { - isSuccess: true, - email: "prouser@example.com", - firstName: "Pro", - lastName: "User", - customerID: "test-customer-id", - loginTime: Date.now(), - profileIcon: { - initials: "TU", - color: "#14b8a6" - } - }; - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve(response) - }); - } else if (url.includes('/getAppEntitlements')) { - // Entitlements endpoint - return user's plan and entitlements - console.log("llgT: Handling getAppEntitlements call"); - if (userSignedOut) { - return Promise.resolve({ - ok: false, - status: 401, - json: () => Promise.resolve({ isSuccess: false }) - }); - } else { - const entitlementsResponse = { - isSuccess: true, - lang: "en" - }; - - if (hasActiveSubscription) { - const validTill = expiredEntitlements ? - Date.now() - 86400000 : // expired yesterday - Date.now() + 30 * 24 * 60 * 60 * 1000; // valid for 30 days - - entitlementsResponse.plan = { - isSubscriber: true, - paidSubscriber: true, - name: "Phoenix Pro", - fullName: "Phoenix Pro", - validTill: validTill - }; - entitlementsResponse.entitlements = { - liveEdit: { - activated: true, - validTill: validTill - } - }; - } else { - entitlementsResponse.plan = { - isSubscriber: false, - paidSubscriber: false, - name: "Free Plan", - fullName: "Free Plan" - }; - } - - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve(entitlementsResponse) - }); - } - } else if (url.includes('/signOut')) { - userSignedOut = true; - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve({ isSuccess: true }) - }); - } else { - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve({ isSuccess: true }) - }); - } - }; - - // Apply fetch mock to both browser exports and login service exports - LoginBrowserExports.setFetchFn(fetchMock); - LoginServiceExports.setFetchFn(fetchMock); - ProDialogsExports.setFetchFn(fetchMock); - } - - async function performFullLoginFlow() { - // Mock window.open like the original test - let capturedURL = null; - let capturedTarget = null; - testWindow.open = function(url, target) { - capturedURL = url; - capturedTarget = target; - return { - focus: function() {}, - close: function() {}, - closed: false - }; - }; - - // Click profile button - const $profileButton = testWindow.$("#user-profile-button"); - $profileButton.trigger('click'); - await popupToAppear(SIGNIN_POPUP); - - // Find and click sign in button - let popupContent = testWindow.$('.profile-popup'); - const signInButton = popupContent.find('#phoenix-signin-btn'); - signInButton.trigger('click'); - - // Verify window.open was called - expect(capturedURL).toBeDefined(); - expect(capturedURL).toContain('phcode.dev'); - expect(capturedTarget).toBe('_blank'); - - // Wait for browser login waiting dialog - await testWindow.__PR.waitForModalDialog(".browser-login-waiting-dialog"); - - // Click "Check Now" button to verify login - const checkNowButton = testWindow.$('[data-button-id="check"]'); - checkNowButton.trigger('click'); - - // Wait for login to complete - await awaitsFor( - function () { - return LoginServiceExports.LoginService.isLoggedIn(); - }, - "User to be logged in", - 5000 - ); - - // Wait for login dialog to close - await testWindow.__PR.waitForModalDialogClosed(".modal", "Login waiting dialog"); - - // Wait for profile icon to update with user data - await awaitsFor( - function () { - const $profileIcon = testWindow.$("#user-profile-button"); - const profileIconContent = $profileIcon.html(); - return profileIconContent && profileIconContent.includes('TU'); - }, - "profile icon to update with user initials", - 3000 - ); - } - - describe("Browser Login Tests", function () { - - beforeEach(async function () { - // Ensure clean state before each test - if (LoginServiceExports.LoginService.isLoggedIn()) { - throw new Error("browser login tests require user to be logged out at start. Please log out before running these tests."); - } - await cleanupTrialState(); - }); - - LoginShared.setupSharedTests(); - }); - }); -}); diff --git a/test/spec/login-desktop-integ-test.js b/test/spec/login-desktop-integ-test.js deleted file mode 100644 index 1ac82618a2..0000000000 --- a/test/spec/login-desktop-integ-test.js +++ /dev/null @@ -1,348 +0,0 @@ -/* - * 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, beforeAll, afterAll, beforeEach, afterEach, awaitsFor, awaitsForDone, awaits*/ - -define(function (require, exports, module) { - - const SpecRunnerUtils = require("spec/SpecRunnerUtils"); - const LoginShared = require("./login-shared"); - - describe("integration: login/logout desktop app tests", function () { - - if (!Phoenix.isNativeApp) { - // Desktop login tests are only applicable for native apps - it("This test disabled in browser as its desktop login tests", function () { - expect(1).toEqual(1); - }); - return; - } - if (Phoenix.isNativeApp && Phoenix.isTestWindowGitHubActions && Phoenix.platform === "linux") { - // Credentials test doesn't work in GitHub actions in linux desktop as the runner cant reach key ring. - it("Should not run in github actions in linux desktop", async function () { - expect(1).toEqual(1); - }); - return; - } - - let testWindow, - LoginServiceExports, - LoginDesktopExports, - ProDialogsExports, - EntitlementsExports, - entitlementsService, - originalOpenURLInDefaultBrowser, - originalCopyToClipboard, - originalFetch; - - let setupTrialState, - setupExpiredTrial, - verifyProBranding, - verifyProfilePopupContent, - cleanupTrialState, - popupToAppear, - performFullLogoutFlow, - verifyProfileIconBlanked, - VIEW_TRIAL_DAYS_LEFT, - VIEW_PHOENIX_PRO, - VIEW_PHOENIX_FREE, - SIGNIN_POPUP, - PROFILE_POPUP; - - beforeAll(async function () { - testWindow = await SpecRunnerUtils.createTestWindowAndRun(); - - // Wait for test exports to be available (KernalModeTrust is sandboxed, use test exports) - await awaitsFor( - function () { - return testWindow._test_login_service_exports && - testWindow._test_login_desktop_exports && - testWindow._test_entitlements_exports; - }, - "Test exports to be available", - 5000 - ); - - // Access the login service exports from the test window - LoginServiceExports = testWindow._test_login_service_exports; - LoginDesktopExports = testWindow._test_login_desktop_exports; - ProDialogsExports = testWindow._test_pro_dlg_login_exports; - EntitlementsExports = testWindow._test_entitlements_exports; - entitlementsService = EntitlementsExports.EntitlementsService; - entitlementsService.on(entitlementsService.EVENT_ENTITLEMENTS_CHANGED, - LoginShared.entitlmentsChangedHandler); - - // Store original functions for restoration - originalOpenURLInDefaultBrowser = testWindow.Phoenix.app.openURLInDefaultBrowser; - originalCopyToClipboard = testWindow.Phoenix.app.copyToClipboard; - originalFetch = testWindow.fetch; - - // Wait for profile menu to be initialized - await awaitsFor( - function () { - return testWindow.$("#user-profile-button").length > 0; - }, - "Profile button to be available", - 3000 - ); - 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; - SIGNIN_POPUP = LoginShared.SIGNIN_POPUP; - PROFILE_POPUP = LoginShared.PROFILE_POPUP; - setupTrialState = LoginShared.setupTrialState; - setupExpiredTrial = LoginShared.setupExpiredTrial; - verifyProBranding = LoginShared.verifyProBranding; - verifyProfilePopupContent = LoginShared.verifyProfilePopupContent; - cleanupTrialState = LoginShared.cleanupTrialState; - popupToAppear = LoginShared.popupToAppear; - performFullLogoutFlow = LoginShared.performFullLogoutFlow; - verifyProfileIconBlanked = LoginShared.verifyProfileIconBlanked; - }, 30000); - - afterAll(async function () { - // Restore original functions - entitlementsService.off(entitlementsService.EVENT_ENTITLEMENTS_CHANGED, - LoginShared.entitlmentsChangedHandler); - testWindow.Phoenix.app.openURLInDefaultBrowser = originalOpenURLInDefaultBrowser; - testWindow.Phoenix.app.copyToClipboard = originalCopyToClipboard; - - // Restore all fetch function overrides - LoginDesktopExports.setFetchFn(originalFetch); - LoginServiceExports.setFetchFn(originalFetch); - ProDialogsExports.setFetchFn(originalFetch); - - testWindow = null; - LoginServiceExports = null; - LoginDesktopExports = null; - ProDialogsExports = null; - originalOpenURLInDefaultBrowser = null; - originalCopyToClipboard = null; - originalFetch = null; - await SpecRunnerUtils.closeTestWindow(); - }, 30000); - - beforeEach(function () { - // Ensure we start each test in a logged-out state - // Note: We can't easily reset login state, so tests should handle this - }); - - function setupProUserMock(hasActiveSubscription = true, expiredEntitlements = false) { - let userSignedOut = false; - - // Set fetch mock on desktop exports - const fetchMock = (url, options) => { - console.log("llgT: desktop test fetchFn called with URL:", url); - - if (url.includes('/getAppAuthSession')) { - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve({ - isSuccess: true, - appSessionID: "test-session-123", - validationCode: "123456" - }) - }); - } else if (url.includes('/resolveAppSessionID')) { - if (userSignedOut) { - return Promise.resolve({ - ok: false, - status: 401, - json: () => Promise.resolve({ isSuccess: false }) - }); - } - const response = { - isSuccess: true, - email: "prouser@example.com", - firstName: "Pro", - lastName: "User", - customerID: "test-customer-id", - apiKey: "test-api-key", - validationCode: "123456", - profileIcon: { - initials: "TU", - color: "#14b8a6" - } - }; - - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve(response) - }); - } else if (url.includes('/getAppEntitlements')) { - // Entitlements endpoint - return user's plan and entitlements - console.log("llgT: Handling getAppEntitlements call"); - - // Check if this is a device ID request (non-logged-in user with device license) - const isDeviceIDRequest = url.includes('deviceID='); - - if (userSignedOut && !isDeviceIDRequest) { - return Promise.resolve({ - ok: false, - status: 401, - json: () => Promise.resolve({ isSuccess: false }) - }); - } else { - const entitlementsResponse = { - isSuccess: true, - lang: "en" - }; - - if (hasActiveSubscription) { - const validTill = expiredEntitlements ? - Date.now() - 86400000 : // expired yesterday - Date.now() + 30 * 24 * 60 * 60 * 1000; // valid for 30 days - - entitlementsResponse.plan = { - isSubscriber: true, - paidSubscriber: !isDeviceIDRequest, // Educational device licenses are unpaid - name: "Phoenix Pro", - fullName: isDeviceIDRequest ? "Phoenix Pro Test Edu" : "Phoenix Pro", - validTill: validTill - }; - entitlementsResponse.entitlements = { - liveEdit: { - activated: true, - validTill: validTill - } - }; - } else { - entitlementsResponse.plan = { - isSubscriber: false, - paidSubscriber: false, - name: "Free Plan", - fullName: "Free Plan" - }; - } - - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve(entitlementsResponse) - }); - } - } else if (url.includes('/signOut') || url.includes('/logoutSession')) { - userSignedOut = true; - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve({ isSuccess: true }) - }); - } else { - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve({ isSuccess: true }) - }); - } - }; - - // Apply fetch mock to desktop exports and login service exports - LoginDesktopExports.setFetchFn(fetchMock); - LoginServiceExports.setFetchFn(fetchMock); - ProDialogsExports.setFetchFn(fetchMock); - } - - async function performFullLoginFlow() { - // Mock desktop app functions for login flow - let capturedBrowserURL = null; - testWindow.Phoenix.app.openURLInDefaultBrowser = function(url) { - capturedBrowserURL = url; - return true; - }; - let capturedClipboardText = null; - testWindow.Phoenix.app.copyToClipboard = function(text) { - capturedClipboardText = text; - return true; - }; - - // Click profile button - const $profileButton = testWindow.$("#user-profile-button"); - $profileButton.trigger('click'); - await popupToAppear(SIGNIN_POPUP); - - // Find and click sign in button - let popupContent = testWindow.$('.profile-popup'); - const signInButton = popupContent.find('#phoenix-signin-btn'); - signInButton.trigger('click'); - - // Wait for desktop login dialog - await testWindow.__PR.waitForModalDialog(".modal"); - const copyButton = testWindow.$('.modal').find('[data-button-id="copy"]'); - copyButton.trigger('click'); - expect(capturedClipboardText).toBe("123456"); - - // Test open browser functionality - const openBrowserButton = testWindow.$('.modal').find('[data-button-id="open"]'); - openBrowserButton.trigger('click'); - expect(capturedBrowserURL).toBeDefined(); - expect(capturedBrowserURL).toContain('authorizeApp'); - expect(capturedBrowserURL).toContain('test-session-123'); - - // Click refresh button to verify login - const refreshButton = testWindow.$('.modal').find('[data-button-id="refresh"]'); - refreshButton.trigger('click'); - - // Wait for login to complete - await awaitsFor( - function () { - return LoginServiceExports.LoginService.isLoggedIn(); - }, - "User to be logged in", - 5000 - ); - - // Wait for login dialog to close - await testWindow.__PR.waitForModalDialogClosed(".modal"); - - // Wait for profile icon to update with user data - await awaitsFor( - function () { - const $profileIcon = testWindow.$("#user-profile-button"); - const profileIconContent = $profileIcon.html(); - return profileIconContent && profileIconContent.includes('TU'); - }, - "profile icon to update with user initials", - 3000 - ); - } - - describe("Desktop Login and Promotion Tests", function () { - - beforeEach(async function () { - // Ensure clean state before each test - if (LoginServiceExports.LoginService.isLoggedIn()) { - await performFullLogoutFlow(); - } - if (LoginServiceExports.LoginService.isLoggedIn()) { - throw new Error("Promotion tests require user to be logged out at start." + - " Please log out before running these tests."); - } - await cleanupTrialState(); - }); - - LoginShared.setupSharedTests(); - }); - }); -}); diff --git a/test/spec/login-shared.js b/test/spec/login-shared.js deleted file mode 100644 index 8bbe4b4d75..0000000000 --- a/test/spec/login-shared.js +++ /dev/null @@ -1,990 +0,0 @@ - -/*global expect, it, awaitsFor*/ - -define(function (require, exports, module) { - let testWindow, LoginServiceExports, setupProUserMock, performFullLoginFlow, EntitlementsExports; - - async function setupTrialState(daysRemaining) { - const PromotionExports = testWindow._test_promo_login_exports; - const mockNow = Date.now(); - await PromotionExports._setTrialData({ - proVersion: "3.1.0", - endDate: mockNow + (daysRemaining * PromotionExports.TRIAL_CONSTANTS.MS_PER_DAY) - }); - // Trigger entitlements changed event to update branding - const LoginService = PromotionExports.LoginService; - LoginService.trigger(LoginService.EVENT_ENTITLEMENTS_CHANGED); - } - - async function setupExpiredTrial() { - const PromotionExports = testWindow._test_promo_login_exports; - const mockNow = Date.now(); - await PromotionExports._setTrialData({ - proVersion: "3.1.0", - endDate: mockNow - PromotionExports.TRIAL_CONSTANTS.MS_PER_DAY - }); - // Trigger entitlements changed event to update branding - const LoginService = PromotionExports.LoginService; - LoginService.trigger(LoginService.EVENT_ENTITLEMENTS_CHANGED); - } - - async function verifyProBranding(shouldShowPro, testDescription) { - const $brandingLink = testWindow.$("#phcode-io-main-nav"); - - if (shouldShowPro) { - await awaitsFor( - function () { - return testWindow.$("#phcode-io-main-nav").hasClass("phoenix-pro"); - }, - `Verify Pro branding to appear: ${testDescription}`, 5000 - ); - expect($brandingLink.hasClass("phoenix-pro")).toBe(true); - expect($brandingLink.text()).toContain("Phoenix Pro"); - expect($brandingLink.find(".fa-feather").length).toBe(1); - } else { - await awaitsFor( - function () { - return !testWindow.$("#phcode-io-main-nav").hasClass("phoenix-pro"); - }, - `Verify Pro branding to go away: ${testDescription}`, 5000 - ); - expect($brandingLink.hasClass("phoenix-pro")).toBe(false); - expect($brandingLink.text()).toBe("phcode.io"); - } - } - - const VIEW_TRIAL_DAYS_LEFT = "VIEW_TRIAL_DAYS_LEFT"; - const VIEW_PHOENIX_PRO = "VIEW_PHOENIX_PRO"; - const VIEW_PHOENIX_FREE = "VIEW_PHOENIX_FREE"; - async function verifyProfilePopupContent(expectedView, testDescription) { - await awaitsFor( - function () { - return testWindow.$('.profile-popup').length > 0; - }, - `Profile popup to appear: ${testDescription}`, - 3000 - ); - - if (expectedView === VIEW_PHOENIX_PRO) { - await awaitsFor( - function () { - const $popup = testWindow.$('.profile-popup'); - const $planName = $popup.find('.user-plan-name'); - const planText = $planName.text(); - return planText.includes("Phoenix Pro"); - }, - `Profile popup should say phoenix pro: ${testDescription}`, 5000 - ); - const $popup = testWindow.$('.profile-popup'); - const $planName = $popup.find('.user-plan-name'); - const planText = $planName.text(); - expect(planText).toContain("Phoenix Pro"); - expect(planText).not.toContain("days left"); - expect($popup.find(".fa-feather").length).toBe(1); - } else if (expectedView === VIEW_TRIAL_DAYS_LEFT) { - await awaitsFor( - function () { - const $popup = testWindow.$('.profile-popup'); - const $planName = $popup.find('.user-plan-name'); - const planText = $planName.text(); - return planText.includes("Phoenix Pro") && planText.includes("days left"); - }, - `Profile popup should say phoenix pro trial: ${testDescription}`, 5000 - ); - const $popup = testWindow.$('.profile-popup'); - const $planName = $popup.find('.user-plan-name'); - const planText = $planName.text(); - expect(planText).toContain("Phoenix Pro"); - expect(planText).toContain("days left"); - expect($popup.find(".fa-feather").length).toBe(1); - } else { - await awaitsFor( - function () { - const $popup = testWindow.$('.profile-popup'); - const $planName = $popup.find('.user-plan-name'); - const planText = $planName.text(); - return !planText.includes("Phoenix Pro"); - }, - `Profile popup should not say phoenix pro: ${testDescription}`, 5000 - ); - const $popup = testWindow.$('.profile-popup'); - const $planName = $popup.find('.user-plan-name'); - const planText = $planName.text(); - expect(planText).not.toContain("Phoenix Pro"); - expect($popup.find(".fa-feather").length).toBe(0); - } - } - - async function cleanupTrialState() { - const PromotionExports = testWindow._test_promo_login_exports; - await PromotionExports._cleanTrialData(); - } - - const SIGNIN_POPUP = "SIGNIN_POPUP"; - const PROFILE_POPUP = "PROFILE_POPUP"; - async function popupToAppear(popupType = SIGNIN_POPUP) { - const statusText = popupType === SIGNIN_POPUP ? - "Sign In popup to appear" : "Profile popup to appear"; - await awaitsFor( - function () { - const selector = popupType === SIGNIN_POPUP ? ".login-profile-popup" : ".user-profile-popup"; - return testWindow.$('.modal').length > 0 || testWindow.$(selector).length > 0; - }, - statusText, 3000 - ); - } - - function verifyProfileIconBlanked() { - const $profileIcon = testWindow.$("#user-profile-button"); - const initialContent = $profileIcon.html(); - expect(initialContent).not.toContain('TU'); - } - - async function performFullLogoutFlow() { - // Click profile button to open popup - const $profileButton = testWindow.$("#user-profile-button"); - $profileButton.trigger('click'); - - // Wait for profile popup - await popupToAppear(PROFILE_POPUP); - - // Find and click sign out button - let popupContent = testWindow.$('.profile-popup'); - const signOutButton = popupContent.find('#phoenix-signout-btn'); - signOutButton.trigger('click'); - - // Wait for sign out confirmation dialog and dismiss it - await testWindow.__PR.waitForModalDialog(".modal"); - testWindow.__PR.clickDialogButtonID(testWindow.__PR.Dialogs.DIALOG_BTN_OK); - await testWindow.__PR.waitForModalDialogClosed(".modal"); - - // Wait for sign out to complete - await awaitsFor( - function () { - return !LoginServiceExports.LoginService.isLoggedIn(); - }, - "User to be signed out", - 10000 - ); - verifyProfileIconBlanked(); - } - - // Entitlements test utility functions - // Note: EntitlementsExports.getPlanDetails() is eventually consistent due to - // 1 second debounce delay for entitlements changed event - async function verifyPlanEntitlements(expectedPlan, _testDescription) { - // Wait for plan details to match expected values (handles debounce delay) - let planDetails; - await awaitsFor( - async function () { - planDetails = await EntitlementsExports.getPlanDetails(); - - if (!expectedPlan) { - return planDetails !== undefined; // Should always return something (fallback) - } - - if (!planDetails) { - return false; - } - - // Check all expected properties match - if (expectedPlan.isSubscriber !== undefined && - planDetails.isSubscriber !== expectedPlan.isSubscriber) { - return false; - } - if (expectedPlan.name && planDetails.name !== expectedPlan.name) { - return false; - } - if (expectedPlan.validTill !== undefined && !planDetails.validTill) { - return false; - } - - return true; - }, - ()=>{ - return `Plan entitlements ${JSON.stringify(planDetails)} to match expected ${ - JSON.stringify(expectedPlan)}: ${_testDescription}`; - }, - 4000, - 30 - ); - - // Final assertions after condition is met - const finalPlanDetails = await EntitlementsExports.getPlanDetails(); - if (expectedPlan) { - expect(finalPlanDetails).toBeDefined(); - if (expectedPlan.isSubscriber !== undefined) { - expect(finalPlanDetails.isSubscriber).toBe(expectedPlan.isSubscriber); - } - if (expectedPlan.paidSubscriber !== undefined) { - expect(finalPlanDetails.paidSubscriber).toBe(expectedPlan.paidSubscriber); - } - if (expectedPlan.name) { - expect(finalPlanDetails.name).toBe(expectedPlan.name); - } - if (expectedPlan.validTill !== undefined) { - expect(finalPlanDetails.validTill).toBeDefined(); - } - } else { - expect(finalPlanDetails).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 verifyIsPaidSubscriber(expected, _testDescription) { - await awaitsFor(async ()=> { - const isPaidSub = await EntitlementsExports.isPaidSubscriber(); - return isPaidSub === expected; - }, `paid subscriber to be ${expected}`); - } - - 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; - } - - let entitlementsEventFired = false; - function entitlmentsChangedHandler() { - entitlementsEventFired = true; - } - - function setupSharedTests() { - - it("should complete login and logout flow", async function () { - entitlementsEventFired = false; - - // Setup basic user mock - setupProUserMock(false); - - // Perform full login flow - await performFullLoginFlow(); - expect(LoginServiceExports.LoginService.isLoggedIn()).toBe(true); - - // Wait for entitlements event to fire after login - await awaitsFor(() => entitlementsEventFired, "Entitlements event to fire after login"); - expect(entitlementsEventFired).toBe(true); - - // Reset flag for logout test - entitlementsEventFired = false; - - // Perform full logout flow - await performFullLogoutFlow(); - expect(LoginServiceExports.LoginService.isLoggedIn()).toBe(false); - verifyProfileIconBlanked(); - - // Wait for entitlements event to fire after logout - await awaitsFor(() => entitlementsEventFired, "Entitlements event to fire after logout"); - expect(entitlementsEventFired).toBe(true); - }); - - it("should update profile icon after login", async function () { - // Setup basic user mock - setupProUserMock(false); - - // Verify initial state - verifyProfileIconBlanked(); - - // Perform login - await performFullLoginFlow(); - - // Wait for profile icon to update - await awaitsFor( - function () { - const $profileIcon = testWindow.$("#user-profile-button"); - const profileIconContent = $profileIcon.html(); - return profileIconContent && profileIconContent.includes('TU'); - }, - "profile icon to contain user initials", - 5000 - ); - - // Verify profile icon updated with user initials - const $profileIcon = testWindow.$("#user-profile-button"); - const updatedContent = $profileIcon.html(); - expect(updatedContent).toContain('svg'); - expect(updatedContent).toContain('TU'); - - // Logout for cleanup - await performFullLogoutFlow(); - }); - - it("should show correct popup states", async function () { - // Setup basic user mock - setupProUserMock(false); - - const $profileButton = testWindow.$("#user-profile-button"); - - // Test initial state - should show signin popup - $profileButton.trigger('click'); - await popupToAppear(SIGNIN_POPUP); - - let popupContent = testWindow.$('.profile-popup'); - const signInButton = popupContent.find('#phoenix-signin-btn'); - const signOutButton = popupContent.find('#phoenix-signout-btn'); - - expect(signInButton.length).toBe(1); - expect(signOutButton.length).toBe(0); - - // Close popup - $profileButton.trigger('click'); - - // Perform login - await performFullLoginFlow(); - - // Test logged in state - should show profile popup - $profileButton.trigger('click'); - await popupToAppear(PROFILE_POPUP); - - popupContent = testWindow.$('.profile-popup'); - const newSignInButton = popupContent.find('#phoenix-signin-btn'); - const newSignOutButton = popupContent.find('#phoenix-signout-btn'); - - expect(newSignInButton.length).toBe(0); - expect(newSignOutButton.length).toBe(1); - - // Close popup and logout for cleanup - $profileButton.trigger('click'); - await performFullLogoutFlow(); - - // Test final state - should be back to signin popup - $profileButton.trigger('click'); - await popupToAppear(SIGNIN_POPUP); - - popupContent = testWindow.$('.profile-popup'); - const finalSignInButton = popupContent.find('#phoenix-signin-btn'); - const finalSignOutButton = popupContent.find('#phoenix-signout-btn'); - - expect(finalSignInButton.length).toBe(1); - expect(finalSignOutButton.length).toBe(0); - - // Close popup - $profileButton.trigger('click'); - }); - - it("should show pro branding for user with pro subscription (expired trial)", async function () { - console.log("llgT: Starting pro user with expired trial test"); - - entitlementsEventFired = false; - - // Setup: Pro subscription + expired trial - setupProUserMock(true); - await setupExpiredTrial(); - - // 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"); - await verifyIsPaidSubscriber(false, "logged out user should not be a paid subscriber"); - - // Perform login - await performFullLoginFlow(); - await verifyProBranding(true, "pro branding to appear after pro user login"); - - // Wait for entitlements event to fire after login - await awaitsFor(() => entitlementsEventFired, "Entitlements event to fire after login"); - expect(entitlementsEventFired).toBe(true); - - // Verify entitlements API consistency for logged in pro user - await verifyIsInProTrialEntitlement(false, "pro user should not be in trial"); - await verifyPlanEntitlements({ isSubscriber: true, paidSubscriber: true }, "pro user should have paid subscriber plan"); - await verifyIsPaidSubscriber(true, "pro user should be a paid subscriber"); - - // Check profile popup shows pro status (not trial) - const $profileButton = testWindow.$("#user-profile-button"); - $profileButton.trigger('click'); - - // Wait for profile popup to show "phoenix pro " - await verifyProfilePopupContent(VIEW_PHOENIX_PRO, "pro user profile popup"); - - // Close popup - $profileButton.trigger('click'); - - // Reset flag for logout test - entitlementsEventFired = false; - - // Perform logout - await performFullLogoutFlow(); - - // Wait for entitlements event to fire after logout - await awaitsFor(() => entitlementsEventFired, "Entitlements event to fire after logout"); - expect(entitlementsEventFired).toBe(true); - - // For user with pro subscription + expired trial: - // After logout, pro branding should disappear because: - // 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"); - await verifyIsPaidSubscriber(false, "logged out pro user should not be a paid subscriber (no server entitlements)"); - }); - - it("should show trial branding for user without pro subscription (active trial)", async function () { - console.log("llgT: Starting trial user test"); - - entitlementsEventFired = false; - - // Setup: No pro subscription + active trial (15 days) - setupProUserMock(false); - await setupTrialState(15); - - // 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"); - - // Wait for entitlements event to fire after login - await awaitsFor(() => entitlementsEventFired, "Entitlements event to fire after login"); - expect(entitlementsEventFired).toBe(true); - - // Verify entitlements API consistency for logged in trial user - await verifyIsInProTrialEntitlement(true, "user should still be in trial after login"); - await verifyPlanEntitlements({ isSubscriber: true, paidSubscriber: false }, "trial user should have isSubscriber true but paidSubscriber false"); - await verifyIsPaidSubscriber(false, "trial user should not be a paid subscriber"); - - // Check profile popup shows trial status - const $profileButton = testWindow.$("#user-profile-button"); - $profileButton.trigger('click'); - await popupToAppear(PROFILE_POPUP); - await verifyProfilePopupContent(VIEW_TRIAL_DAYS_LEFT, - "trial user profile popup for logged in user"); - - // Close popup - $profileButton.trigger('click'); - - // Reset flag for logout test - entitlementsEventFired = false; - - // Perform logout - await performFullLogoutFlow(); - - // Wait for entitlements event to fire after logout - await awaitsFor(() => entitlementsEventFired, "Entitlements event to fire after logout"); - expect(entitlementsEventFired).toBe(true); - - // 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"); - await verifyIsPaidSubscriber(false, "logged out trial user should not be a paid subscriber"); - - // Check profile popup still shows trial status - $profileButton.trigger('click'); - await popupToAppear(SIGNIN_POPUP); - await verifyProfilePopupContent(VIEW_TRIAL_DAYS_LEFT, - "trial user profile popup for logged out user"); - - // Close popup - $profileButton.trigger('click'); - }); - - it("should prioritize pro subscription over trial in profile popup", async function () { - console.log("llgT: Starting trial user with pro subscription test"); - - // Setup: Pro subscription + active trial - setupProUserMock(true); - await setupTrialState(10); - - // Perform login - await performFullLoginFlow(); - - // Verify pro branding appears - await verifyProBranding(true, "Pro branding to appear for pro user"); - - // Verify entitlements API consistency for logged in pro user with trial - await verifyIsPaidSubscriber(true, "pro user with trial should be a paid subscriber"); - - // Check profile popup shows pro status (not trial text) - const $profileButton = testWindow.$("#user-profile-button"); - $profileButton.trigger('click'); - await popupToAppear(PROFILE_POPUP); - - // Should show pro, not trial, since user has paid subscription - await verifyProfilePopupContent(VIEW_PHOENIX_PRO, - "pro+trial user profile should not show trial branding"); - - // Close popup - $profileButton.trigger('click'); - - // Perform logout - await performFullLogoutFlow(); - - // Verify pro branding remains due to trial (even though subscription is gone) - await verifyProBranding(true, "Pro branding should remain after logout as trial user"); - await verifyIsPaidSubscriber(false, "logged out user should not be a paid subscriber (no server entitlements)"); - $profileButton.trigger('click'); - await popupToAppear(SIGNIN_POPUP); - await verifyProfilePopupContent(VIEW_TRIAL_DAYS_LEFT, - "trial user profile popup for logged out user"); - - // Close popup - $profileButton.trigger('click'); - }); - - it("should show free branding for user without pro subscription (expired trial)", async function () { - console.log("llgT: Starting desktop trial user test"); - - entitlementsEventFired = false; - - // 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({ isSubscriber: false, paidSubscriber: false, name: testWindow.Strings.USER_FREE_PLAN_NAME_DO_NOT_TRANSLATE }, - "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"); - await verifyIsPaidSubscriber(false, "logged out free user should not be a paid subscriber"); - - // Perform login - await performFullLoginFlow(); - - // Verify pro branding remains after login - await verifyProBranding(false, "after trial free user login"); - - // Wait for entitlements event to fire after login - await awaitsFor(() => entitlementsEventFired, "Entitlements event to fire after login"); - expect(entitlementsEventFired).toBe(true); - - // Verify entitlements API consistency for logged in free user - await verifyPlanEntitlements({ isSubscriber: false, paidSubscriber: false, name: testWindow.Strings.USER_FREE_PLAN_NAME_DO_NOT_TRANSLATE }, - "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"); - await verifyIsPaidSubscriber(false, "logged in free user should not be a paid subscriber"); - - // Check profile popup shows free plan status - const $profileButton = testWindow.$("#user-profile-button"); - $profileButton.trigger('click'); - await popupToAppear(PROFILE_POPUP); - await verifyProfilePopupContent(VIEW_PHOENIX_FREE, - "free plan user profile popup for logged in user"); - - // Close popup - $profileButton.trigger('click'); - - // Reset flag for logout test - entitlementsEventFired = false; - - // Perform logout - await performFullLogoutFlow(); - - // Wait for entitlements event to fire after logout - await awaitsFor(() => entitlementsEventFired, "Entitlements event to fire after logout"); - expect(entitlementsEventFired).toBe(true); - - // 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"); - await verifyIsPaidSubscriber(false, "logged out free user should not be a paid subscriber"); - - // Check profile popup still shows free plan status as trial expired - $profileButton.trigger('click'); - await popupToAppear(SIGNIN_POPUP); - // not logged in user, we wont show free plan tag as base editor is always free. - expect(testWindow.$(`.profile-popup .trial-plan-info`).length).toBe(0); - - // Close popup - $profileButton.trigger('click'); - }); - - it("should show free user popup when entitlements are expired (no trial)", async function () { - console.log("llgT: Starting expired entitlements without trial test"); - - // Setup: Expired pro subscription + no trial - setupProUserMock(true, true); - await cleanupTrialState(); // Ensure no trial is active - - // 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({ isSubscriber: false, paidSubscriber: false, name: testWindow.Strings.USER_FREE_PLAN_NAME_DO_NOT_TRANSLATE }, - "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"); - await verifyIsPaidSubscriber(false, "logged out user with expired entitlements should not be a paid subscriber"); - - // 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({ isSubscriber: false, 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"); - await verifyIsPaidSubscriber(false, "logged in user with expired entitlements should not be a paid subscriber"); - - // Check profile popup shows free plan status - const $profileButton = testWindow.$("#user-profile-button"); - $profileButton.trigger('click'); - await popupToAppear(PROFILE_POPUP); - await verifyProfilePopupContent(VIEW_PHOENIX_FREE, - "free plan user profile popup for user with expired entitlements"); - - // Close popup - $profileButton.trigger('click'); - - // Perform logout - await performFullLogoutFlow(); - - // 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"); - await verifyIsPaidSubscriber(false, "logged out user with expired entitlements should not be a paid subscriber"); - - // Check profile popup (signed out state) - $profileButton.trigger('click'); - await popupToAppear(SIGNIN_POPUP); - // Not logged in user with no trial - no special branding expected - expect(testWindow.$(`.profile-popup .trial-plan-info`).length).toBe(0); - - // Close popup - $profileButton.trigger('click'); - }); - - it("should show trial user popup when entitlements are expired (active trial)", async function () { - console.log("llgT: Starting expired entitlements with active trial test"); - - // Setup: Expired pro subscription + active trial (10 days) - setupProUserMock(true, true); - await setupTrialState(10); - - // 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({ isSubscriber: true, paidSubscriber: false, 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"); - await verifyIsPaidSubscriber(false, "logged out trial user should not be a paid subscriber"); - - // 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({ isSubscriber: true, paidSubscriber: false }, - "trial overrides expired server entitlements - user is subscriber but not paid"); - 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"); - await verifyIsPaidSubscriber(false, "logged in trial user should not be a paid subscriber (expired entitlements)"); - - // Check profile popup shows trial status (not expired server entitlements) - const $profileButton = testWindow.$("#user-profile-button"); - $profileButton.trigger('click'); - await popupToAppear(PROFILE_POPUP); - await verifyProfilePopupContent(VIEW_TRIAL_DAYS_LEFT, - "trial user profile popup - trial overrides expired server entitlements"); - - // Close popup - $profileButton.trigger('click'); - - // Perform logout - await performFullLogoutFlow(); - - // 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"); - await verifyIsPaidSubscriber(false, "logged out trial user should not be a paid subscriber"); - - // Check profile popup still shows trial status - $profileButton.trigger('click'); - await popupToAppear(SIGNIN_POPUP); - await verifyProfilePopupContent(VIEW_TRIAL_DAYS_LEFT, - "trial user profile popup for logged out user"); - - // 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(); - } - }); - - it("should test isPaidSubscriber API across different user states", async function () { - console.log("isPaidSubscriber: Testing API across different user states"); - - try { - // Test 1: Logged out user should not be a paid subscriber - await cleanupTrialState(); - expect(LoginServiceExports.LoginService.isLoggedIn()).toBe(false); - await verifyIsPaidSubscriber(false, "logged out user should not be a paid subscriber"); - - // Test 2: Free user (no subscription) should not be a paid subscriber - setupProUserMock(false); - await performFullLoginFlow(); - expect(LoginServiceExports.LoginService.isLoggedIn()).toBe(true); - await verifyIsPaidSubscriber(false, "free user should not be a paid subscriber"); - await performFullLogoutFlow(); - - // Test 3: Trial user (no paid subscription) should not be a paid subscriber - setupProUserMock(false); - await setupTrialState(10); - await performFullLoginFlow(); - expect(LoginServiceExports.LoginService.isLoggedIn()).toBe(true); - await verifyIsPaidSubscriber(false, "trial user should not be a paid subscriber"); - await performFullLogoutFlow(); - await cleanupTrialState(); - - // Test 4: Pro user with paid subscription should be a paid subscriber - setupProUserMock(true); - await performFullLoginFlow(); - expect(LoginServiceExports.LoginService.isLoggedIn()).toBe(true); - await verifyIsPaidSubscriber(true, "pro user should be a paid subscriber"); - - // Test 5: After logout, should not be a paid subscriber (no server entitlements) - await performFullLogoutFlow(); - expect(LoginServiceExports.LoginService.isLoggedIn()).toBe(false); - await verifyIsPaidSubscriber(false, "logged out pro user should not be a paid subscriber"); - - } finally { - await cleanupTrialState(); - } - }); - - if (Phoenix.isNativeApp) { - it("should show device-licensed Pro branding and popup when not logged in", async function () { - console.log("llgT: Starting device license Pro branding test"); - - try { - // Setup: Enable device license flag - LoginServiceExports.setIsLicensedDevice(true); - - // Setup mock that handles device ID requests (returns Pro entitlements) - setupProUserMock(true, false); - - // Ensure no trial is active - await cleanupTrialState(); - - // Ensure user is logged out - if (LoginServiceExports.LoginService.isLoggedIn()) { - await performFullLogoutFlow(); - } - - // Clear and refresh entitlements to trigger device license check - await LoginServiceExports.LoginService.clearEntitlements(); - await LoginServiceExports.LoginService.getEffectiveEntitlements(true); - - // Wait for branding to update in the navbar - await awaitsFor( - function () { - const $branding = testWindow.$("#phcode-io-main-nav"); - return $branding.hasClass("phoenix-pro"); - }, - "navbar branding to show Phoenix Pro", - 3000 - ); - - // Verify navbar shows Pro branding (uses plan.name) - await verifyProBranding(true, "device license shows Phoenix Pro branding in navbar"); - - // Verify entitlements API shows Pro access - // Note: Device licenses can be paid (paidSubscriber: true) or educational (paidSubscriber: false) - // This test uses educational license (deviceID request) so paidSubscriber should be false - await verifyPlanEntitlements( - { isSubscriber: true, paidSubscriber: false, name: "Phoenix Pro" }, - "device license provides Pro plan (educational license is unpaid)" - ); - await verifyIsInProTrialEntitlement(false, "device license is not a trial"); - await verifyLiveEditEntitlement({ activated: true }, "live edit activated via device license"); - await verifyIsPaidSubscriber(false, "educational device license should not be a paid subscriber (not logged in)"); - - // Verify raw entitlements are present (not null) - const rawEntitlements = await EntitlementsExports.getRawEntitlements(); - expect(rawEntitlements).toBeDefined(); - expect(rawEntitlements).not.toBeNull(); - expect(rawEntitlements.plan.isSubscriber).toBe(true); - - // Verify login popup shows Pro branding with fullName - const $profileButton = testWindow.$("#user-profile-button"); - $profileButton.trigger('click'); - await popupToAppear(SIGNIN_POPUP); - - // Wait for Pro branding to appear in popup (async entitlements load) - await awaitsFor( - function () { - const $popup = testWindow.$('.profile-popup'); - const $proInfo = $popup.find('.trial-plan-info'); - return $proInfo.length > 0; - }, - "Pro branding to appear in login popup", - 3000 - ); - - // Check for Pro branding in popup (uses plan.fullName) - const $popup = testWindow.$('.profile-popup'); - const $proInfo = $popup.find('.trial-plan-info'); - expect($proInfo.length).toBe(1); - - const proText = $proInfo.text(); - expect(proText).toContain("Phoenix Pro Test Edu"); - - // Verify feather icon is present - const $featherIcon = $proInfo.find('.fa-feather'); - expect($featherIcon.length).toBe(1); - - // Close popup - $profileButton.trigger('click'); - - console.log("llgT: Device license Pro branding test completed successfully"); - } finally { - // Cleanup: Reset device license flag - LoginServiceExports.setIsLicensedDevice(false); - await LoginServiceExports.LoginService.clearEntitlements(); - } - }); - } - } - - exports.setup = setup; - exports.setupTrialState = setupTrialState; - exports.setupExpiredTrial = setupExpiredTrial; - exports.verifyProBranding = verifyProBranding; - exports.verifyProfilePopupContent = verifyProfilePopupContent; - exports.cleanupTrialState = cleanupTrialState; - exports.popupToAppear = popupToAppear; - exports.performFullLogoutFlow = performFullLogoutFlow; - exports.verifyProfileIconBlanked = verifyProfileIconBlanked; - exports.entitlmentsChangedHandler = entitlmentsChangedHandler; - exports.VIEW_TRIAL_DAYS_LEFT = VIEW_TRIAL_DAYS_LEFT; - exports.VIEW_PHOENIX_PRO = VIEW_PHOENIX_PRO; - exports.VIEW_PHOENIX_FREE = VIEW_PHOENIX_FREE; - exports.SIGNIN_POPUP = SIGNIN_POPUP; - exports.PROFILE_POPUP = PROFILE_POPUP; - - // test runner - exports.setupSharedTests = setupSharedTests; -}); diff --git a/test/spec/login-utils-test.js b/test/spec/login-utils-test.js deleted file mode 100644 index cf7e8cf8f1..0000000000 --- a/test/spec/login-utils-test.js +++ /dev/null @@ -1,528 +0,0 @@ -/* - * 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", isSubscriber: 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", isSubscriber: 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", isSubscriber: true, validTill: pastTime }, - entitlements: {} - }; - const lastRecorded = { - plan: { name: "Phoenix Pro", isSubscriber: 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: { isSubscriber: true, validTill: pastTime }, - entitlements: {} - }; - const lastRecorded = { - plan: { isSubscriber: 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", isSubscriber: true, validTill: pastTime }, - entitlements: {} - }; - const lastRecorded = { - plan: { name: "Phoenix Pro", isSubscriber: 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", isSubscriber: true }, - entitlements: { - liveEdit: { activated: true, validTill: pastTime }, - liveEditAI: { activated: true, validTill: futureTime } - } - }; - const lastRecorded = { - plan: { name: "Phoenix Pro", isSubscriber: 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", isSubscriber: true }, - entitlements: { - liveEdit: { activated: true, validTill: pastTime } - } - }; - const lastRecorded = { - plan: { name: "Phoenix Pro", isSubscriber: 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", isSubscriber: 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", isSubscriber: true }, - entitlements: { - liveEdit: null, - liveEditAI: { activated: true, validTill: pastTime } - } - }; - const lastRecorded = { - plan: { name: "Phoenix Pro", isSubscriber: 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", - isSubscriber: true, - validTill: pastTime - }, - profileview: { - quota: { - titleText: "Ai Quota Used", - usageText: "100 / 200 credits", - usedPercent: 20 - }, - htmlMessage: "
Quota exceeded: Your quota will reset at 3pm today.
" - }, - entitlements: { - liveEdit: { - activated: false, - subscribeURL: "https://account.phcode.dev/...", - upgradeToPlan: "Phoenix Pro", - validTill: futureTime - }, - liveEditAI: { - activated: false, - subscribeURL: "https://account.phcode.dev/...", - purchaseCreditsURL: "https://account.phcode.dev/...", - upgradeToPlan: "Phoenix Pro", - validTill: futureTime - } - } - }; - const lastRecorded = { - plan: { name: "Phoenix Pro", isSubscriber: true, validTill: futureTime }, - entitlements: { - liveEdit: { activated: false, validTill: futureTime }, - liveEditAI: { activated: false, validTill: futureTime } - } - }; - const result = LoginUtils.validTillExpired(serverEntitlements, lastRecorded); - expect(result).toBe("Phoenix Pro"); - }); - - it("should test trial-enhanced shape", function () { - const trialEnhanced = { - isSuccess: true, - plan: { - name: "Phoenix Pro", - isSubscriber: true, - validTill: pastTime - }, - isInProTrial: true, - trialDaysRemaining: 5, - entitlements: { - liveEdit: { - activated: true, - subscribeURL: "https://account.phcode.dev/...", - upgradeToPlan: "Phoenix Pro", - validTill: futureTime - } - } - }; - const lastRecorded = { - plan: { name: "Phoenix Pro", isSubscriber: true, validTill: futureTime }, - entitlements: { - liveEdit: { activated: true, validTill: futureTime } - } - }; - const result = LoginUtils.validTillExpired(trialEnhanced, lastRecorded); - expect(result).toBe("Phoenix Pro"); - }); - - it("should test synthetic trial shape", function () { - const syntheticTrial = { - plan: { - isSubscriber: true, - name: "Phoenix Pro", - validTill: pastTime - }, - isInProTrial: true, - trialDaysRemaining: 3, - entitlements: { - liveEdit: { - activated: true, - subscribeURL: "https://account.phcode.dev/...", - upgradeToPlan: "Phoenix Pro", - validTill: futureTime - } - } - }; - const lastRecorded = { - plan: { name: "Phoenix Pro", isSubscriber: true, validTill: futureTime }, - entitlements: { - liveEdit: { activated: true, validTill: futureTime } - } - }; - const result = LoginUtils.validTillExpired(syntheticTrial, lastRecorded); - expect(result).toBe("Phoenix Pro"); - }); - }); - - describe("haveEntitlementsChanged", function () { - - it("should return false when both entitlements are null", function () { - const result = LoginUtils.haveEntitlementsChanged(null, null); - expect(result).toBe(false); - }); - - it("should return false when both entitlements are undefined", function () { - const result = LoginUtils.haveEntitlementsChanged(undefined, undefined); - expect(result).toBe(false); - }); - - it("should return true when current is null and last exists", function () { - const last = { plan: { name: "Phoenix Pro" } }; - const result = LoginUtils.haveEntitlementsChanged(null, last); - expect(result).toBe(true); - }); - - it("should return true when current exists and last is null", function () { - const current = { plan: { name: "Phoenix Pro" } }; - const result = LoginUtils.haveEntitlementsChanged(current, null); - expect(result).toBe(true); - }); - - it("should return true when current has entitlements and last doesn't", function () { - const current = { - plan: { name: "Phoenix Pro" }, - entitlements: { liveEdit: { activated: true } } - }; - const last = { - plan: { name: "Phoenix Pro" } - }; - const result = LoginUtils.haveEntitlementsChanged(current, last); - expect(result).toBe(true); - }); - - it("should return true when last has entitlements and current doesn't", function () { - const current = { - plan: { name: "Phoenix Pro" } - }; - const last = { - plan: { name: "Phoenix Pro" }, - entitlements: { liveEdit: { activated: true } } - }; - const result = LoginUtils.haveEntitlementsChanged(current, last); - expect(result).toBe(true); - }); - - it("should return true when isSubscriber status changes", function () { - const current = { - plan: { name: "Phoenix Pro", isSubscriber: true }, - entitlements: {} - }; - const last = { - plan: { name: "Phoenix Pro", isSubscriber: false }, - entitlements: {} - }; - const result = LoginUtils.haveEntitlementsChanged(current, last); - expect(result).toBe(true); - }); - - it("should return true when paidSubscriber status changes", function () { - const current = { - plan: { name: "Phoenix Pro", isSubscriber: true, paidSubscriber: true }, - entitlements: {} - }; - const last = { - plan: { name: "Phoenix Pro", isSubscriber: true, paidSubscriber: false }, - entitlements: {} - }; - const result = LoginUtils.haveEntitlementsChanged(current, last); - expect(result).toBe(true); - }); - - it("should return true when user goes from trial to paid (paidSubscriber changes)", function () { - // Trial user: isSubscriber true, paidSubscriber false - const trialUser = { - plan: { name: "Phoenix Pro", isSubscriber: true, paidSubscriber: false }, - entitlements: { liveEdit: { activated: true } } - }; - // Paid user: isSubscriber true, paidSubscriber true - const paidUser = { - plan: { name: "Phoenix Pro", isSubscriber: true, paidSubscriber: true }, - entitlements: { liveEdit: { activated: true } } - }; - const result = LoginUtils.haveEntitlementsChanged(paidUser, trialUser); - expect(result).toBe(true); - }); - - it("should return true when plan name changes", function () { - const current = { - plan: { name: "Phoenix Pro", isSubscriber: true }, - entitlements: {} - }; - const last = { - plan: { name: "Phoenix Basic", isSubscriber: true }, - entitlements: {} - }; - const result = LoginUtils.haveEntitlementsChanged(current, last); - expect(result).toBe(true); - }); - - it("should return true when entitlement activation changes", function () { - const current = { - plan: { name: "Phoenix Pro", isSubscriber: true }, - entitlements: { - liveEdit: { activated: true }, - liveEditAI: { activated: false } - } - }; - const last = { - plan: { name: "Phoenix Pro", isSubscriber: true }, - entitlements: { - liveEdit: { activated: false }, - liveEditAI: { activated: false } - } - }; - const result = LoginUtils.haveEntitlementsChanged(current, last); - expect(result).toBe(true); - }); - - it("should return false when nothing has changed", function () { - const current = { - plan: { name: "Phoenix Pro", isSubscriber: true }, - entitlements: { - liveEdit: { activated: true }, - liveEditAI: { activated: false } - } - }; - const last = { - plan: { name: "Phoenix Pro", isSubscriber: true }, - entitlements: { - liveEdit: { activated: true }, - liveEditAI: { activated: false } - } - }; - const result = LoginUtils.haveEntitlementsChanged(current, last); - expect(result).toBe(false); - }); - - it("should handle missing plan objects", function () { - const current = { - entitlements: { liveEdit: { activated: true } } - }; - const last = { - entitlements: { liveEdit: { activated: true } } - }; - const result = LoginUtils.haveEntitlementsChanged(current, last); - expect(result).toBe(false); - }); - - it("should handle missing entitlement objects", function () { - const current = { - plan: { name: "Phoenix Pro", isSubscriber: true }, - entitlements: { - liveEdit: { activated: true }, - liveEditAI: null - } - }; - const last = { - plan: { name: "Phoenix Pro", isSubscriber: true }, - entitlements: { - liveEdit: { activated: true }, - liveEditAI: null - } - }; - const result = LoginUtils.haveEntitlementsChanged(current, last); - expect(result).toBe(false); - }); - - it("should test server response shape changes", function () { - const serverCurrent = { - isSuccess: true, - lang: "en", - plan: { - name: "Phoenix Pro", - isSubscriber: true, - validTill: 1756625665847 - }, - entitlements: { - liveEdit: { activated: true }, - liveEditAI: { activated: false } - } - }; - const serverLast = { - isSuccess: true, - lang: "en", - plan: { - name: "Phoenix Pro", - isSubscriber: true, - validTill: 1756625665847 - }, - entitlements: { - liveEdit: { activated: false }, - liveEditAI: { activated: false } - } - }; - const result = LoginUtils.haveEntitlementsChanged(serverCurrent, serverLast); - expect(result).toBe(true); - }); - - it("should test trial-enhanced shape changes", function () { - const trialCurrent = { - plan: { - name: "Phoenix Pro", - isSubscriber: true, - validTill: 1756625665847 - }, - isInProTrial: true, - trialDaysRemaining: 5, - entitlements: { - liveEdit: { activated: true } - } - }; - const trialLast = { - plan: { - name: "Phoenix Pro", - isSubscriber: false, - validTill: 1756625665847 - }, - entitlements: { - liveEdit: { activated: false } - } - }; - const result = LoginUtils.haveEntitlementsChanged(trialCurrent, trialLast); - expect(result).toBe(true); - }); - - it("should test synthetic trial shape changes", function () { - const syntheticCurrent = { - plan: { - isSubscriber: true, - name: "Phoenix Pro", - validTill: 1756625665847 - }, - isInProTrial: true, - trialDaysRemaining: 3, - entitlements: { - liveEdit: { activated: true } - } - }; - const syntheticLast = null; - const result = LoginUtils.haveEntitlementsChanged(syntheticCurrent, syntheticLast); - expect(result).toBe(true); - }); - }); - }); -}); diff --git a/test/spec/promotions-integ-test.js b/test/spec/promotions-integ-test.js deleted file mode 100644 index 38c46dee3a..0000000000 --- a/test/spec/promotions-integ-test.js +++ /dev/null @@ -1,954 +0,0 @@ -/* - * 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, beforeAll, afterAll, beforeEach, afterEach, awaits*/ - -define(function (require, exports, module) { - - const SpecRunnerUtils = require("spec/SpecRunnerUtils"); - - describe("integration:Promotions", function () { - - if (Phoenix.isNativeApp && Phoenix.isTestWindowGitHubActions && Phoenix.platform === "linux") { - // Credentials test doesn't work in GitHub actions in linux desktop as the runner cant reach key ring. - it("Should not run in github actions in linux desktop", async function () { - expect(1).toEqual(1); - }); - return; - } - - let testWindow, - LoginServicePromo, - ProDialogs, - originalAppConfig, - originalFetch, - mockNow = 1000000000000; // Fixed timestamp for consistent testing - - beforeAll(async function () { - testWindow = await SpecRunnerUtils.createTestWindowAndRun(); - - // Access modules from test window - LoginServicePromo = testWindow._test_promo_login_exports; - ProDialogs = testWindow._test_promo_login_exports.ProDialogs; - - // Debug: Check what's available in the exports - console.log('Debug: Available exports:', Object.keys(LoginServicePromo)); - console.log('Debug: setDateNowFn available?', !!LoginServicePromo.setDateNowFn); - - // Use the new setDateNowFn injection mechanism - if (LoginServicePromo.setDateNowFn) { - LoginServicePromo.setDateNowFn(() => { - return mockNow; - }); - } else { - throw new Error('setDateNowFn not available in test exports'); - } - - // Set up fetch mocking for pro dialogs - if (testWindow._test_pro_dlg_login_exports && testWindow._test_pro_dlg_login_exports.setFetchFn) { - // Store reference for later restoration - originalFetch = testWindow.fetch; - } - - // Store original config and mock AppConfig for tests - originalAppConfig = testWindow.AppConfig; - testWindow.AppConfig = { - version: "3.1.0", - apiVersion: "3.1.0" - }; - }, 30000); - - afterAll(async function () { - // Restore original values - if (originalAppConfig) { - testWindow.AppConfig = originalAppConfig; - } - - // Restore original fetch if it was mocked - if (originalFetch && testWindow._test_pro_dlg_login_exports && testWindow._test_pro_dlg_login_exports.setFetchFn) { - testWindow._test_pro_dlg_login_exports.setFetchFn(originalFetch); - } - - LoginServicePromo.setDateNowFn(Date.now); - - testWindow = null; - LoginServicePromo = null; - ProDialogs = null; - originalFetch = null; - await SpecRunnerUtils.closeTestWindow(); - }, 30000); - - it("should require user to be logged out for promotion tests to work", async function () { - // Check if user is logged in - these tests only work for non-logged-in users - const isLoggedIn = LoginServicePromo.LoginService.isLoggedIn(); - if (isLoggedIn) { - throw new Error("Promotion tests require user to be logged out. Please log out before running these tests. Logged-in users with pro subscriptions will not trigger trial activation logic."); - } - // If we reach here, user is not logged in - tests should work - expect(isLoggedIn).toBe(false); - }); - - describe("Trial Activation", function () { - - it("should have access to trial functions", function () { - // Basic test to verify our exports work - expect(LoginServicePromo._getTrialData).toBeDefined(); - expect(LoginServicePromo._setTrialData).toBeDefined(); - expect(LoginServicePromo._isTrialClosedForCurrentVersion).toBeDefined(); - expect(LoginServicePromo._cleanTrialData).toBeDefined(); - expect(LoginServicePromo._cleanSaltData).toBeDefined(); - expect(LoginServicePromo.activateProTrial).toBeDefined(); - expect(LoginServicePromo.getProTrialDaysRemaining).toBeDefined(); - expect(LoginServicePromo.setDateNowFn).toBeDefined(); - }); - - it("should activate 30-day trial on first install (not logged in)", async function () { - // Note: This test assumes user is not logged in, so _hasProSubscription will return false - // Clear any existing trial data first - await LoginServicePromo._cleanTrialData(); - - // Call the function - this simulates first install scenario - await LoginServicePromo.activateProTrial(); - - // Get the trial data that was actually stored - const storedResult = await LoginServicePromo._getTrialData(); - - // Verify trial data was set correctly - expect(storedResult).not.toBeNull(); - expect(storedResult.data).toBeDefined(); - expect(storedResult.data.proVersion).toBe("3.1.0"); - - // Check that a 30-day trial was activated with mocked time - const expectedEndDate = mockNow + (30 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY); - expect(storedResult.data.endDate).toBe(expectedEndDate); - - // Verify upgrade dialog appears with correct content - await testWindow.__PR.waitForModalDialog(".modal"); - const modalContent = testWindow.$('.modal'); - expect(modalContent.length).toBeGreaterThan(0); - - // Check dialog content - const dialogText = modalContent.text(); - expect(dialogText.toLowerCase()).toContain('you’ve been upgraded to'); - expect(dialogText).toContain('Phoenix Pro'); - expect(dialogText).toContain('30 days'); - - // Close the dialog - testWindow.__PR.clickDialogButtonID(testWindow.__PR.Dialogs.DIALOG_BTN_OK); - await testWindow.__PR.waitForModalDialogClosed(".modal"); - }); - - it("should activate 7-day trial on version upgrade (not logged in)", async function () { - const existingTrial = { - proVersion: "3.0.0", - endDate: mockNow - (1 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY), // Expired yesterday - signature: "mock_signature" - }; - - // Set up existing trial data first - await LoginServicePromo._setTrialData(existingTrial); - - await LoginServicePromo.activateProTrial(); - - // Get the updated trial data - const updatedResult = await LoginServicePromo._getTrialData(); - - // Verify new trial data was set for newer version - expect(updatedResult).not.toBeNull(); - expect(updatedResult.data).toBeDefined(); - expect(updatedResult.data.proVersion).toBe("3.1.0"); - - // Check that 3-day trial was granted with mocked time - const expectedEndDate = mockNow + (LoginServicePromo.TRIAL_CONSTANTS.SUBSEQUENT_TRIAL_DAYS - * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY); - expect(updatedResult.data.endDate).toBe(expectedEndDate); - - // Verify upgrade dialog appears with correct content - await testWindow.__PR.waitForModalDialog(".modal"); - const modalContent = testWindow.$('.modal'); - expect(modalContent.length).toBeGreaterThan(0); - - // Check dialog content - const dialogText = modalContent.text(); - expect(dialogText.toLowerCase()).toContain('you’ve been upgraded to'); - expect(dialogText).toContain('Phoenix Pro'); - expect(dialogText).toContain('7 days'); - - // Close the dialog - testWindow.__PR.clickDialogButtonID(testWindow.__PR.Dialogs.DIALOG_BTN_OK); - await testWindow.__PR.waitForModalDialogClosed(".modal"); - }); - - it("should keep existing trial if longer than 7 days on version upgrade (not logged in)", async function () { - const futureEndDate = mockNow + (10 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY); - const existingTrial = { - proVersion: "3.0.0", - endDate: futureEndDate, - signature: "mock_signature" - }; - - // Set up existing trial data first - await LoginServicePromo._setTrialData(existingTrial); - - await LoginServicePromo.activateProTrial(); - - // Get the updated trial data - const updatedResult = await LoginServicePromo._getTrialData(); - - // Verify existing trial was preserved but version updated - expect(updatedResult).not.toBeNull(); - expect(updatedResult.data).toBeDefined(); - expect(updatedResult.data.proVersion).toBe("3.1.0"); - expect(updatedResult.data.endDate).toBe(futureEndDate); - - await testWindow.__PR.waitForModalDialog(".modal"); - // Check dialog content - const modalContent = testWindow.$('.modal'); - const dialogText = modalContent.text(); - expect(dialogText.toLowerCase()).toContain('you’ve been upgraded to'); - expect(dialogText).toContain('Phoenix Pro'); - expect(dialogText).toContain('10 days'); - - testWindow.__PR.clickDialogButtonID(testWindow.__PR.Dialogs.DIALOG_BTN_OK); - await testWindow.__PR.waitForModalDialogClosed(".modal"); - }); - - // Note: Cannot easily test pro user scenarios in integration tests - // since _hasProSubscription is private and depends on actual login state - - it("should not activate trial for same version (not logged in)", async function () { - const existingTrial = { - proVersion: "3.1.0", // Same version - endDate: mockNow + (5 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY), - signature: "mock_signature" - }; - - // Set up existing trial data first - await LoginServicePromo._setTrialData(existingTrial); - - await LoginServicePromo.activateProTrial(); - - // Get the trial data after activation - const currentResult = await LoginServicePromo._getTrialData(); - - // Verify trial data remains unchanged (same version, same end date) - expect(currentResult.data).toBeDefined(); - expect(currentResult.data.proVersion).toBe("3.1.0"); - expect(currentResult.data.endDate).toBe(existingTrial.endDate); - - // For same version, no dialog should appear. lets wait a bit to make sure it doesn't show up' - await awaits(500); - const modalContent = testWindow.$('.modal:visible'); - expect(modalContent.length).toBe(0); - }); - - it("should not activate trial for older current version (not logged in)", async function () { - const existingTrial = { - proVersion: "3.2.0", // Newer than current 3.1.0 - endDate: mockNow + (5 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY), - signature: "mock_signature" - }; - - // Set up existing trial data first - await LoginServicePromo._setTrialData(existingTrial); - - await LoginServicePromo.activateProTrial(); - - // Get the trial data after activation - const currentResult = await LoginServicePromo._getTrialData(); - - // Verify trial data remains unchanged (older current version scenario) - expect(currentResult.data).toBeDefined(); - expect(currentResult.data.proVersion).toBe("3.2.0"); // Should preserve original version - expect(currentResult.data.endDate).toBe(existingTrial.endDate); // Should preserve end date - - // For same version, no dialog should appear. lets wait a bit to make sure it doesn't show up - await awaits(500); - const modalContent = testWindow.$('.modal:visible'); - expect(modalContent.length).toBe(0); - }); - }); - - describe("Trial Expiration", function () { - - async function setupExpiredTrialAndActivate() { - const expiredTrial = { - proVersion: "3.1.0", // Same version as current to trigger ended dialog - endDate: mockNow - LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY, // Expired yesterday - signature: "mock_signature" - }; - - // Set up expired trial data first - await LoginServicePromo._setTrialData(expiredTrial); - - await LoginServicePromo.activateProTrial(); - - // Get the updated trial data - const updatedResult = await LoginServicePromo._getTrialData(); - - // Verify upgrade dialog shown flag was set - expect(updatedResult).not.toBeNull(); - expect(updatedResult.data).toBeDefined(); - expect(updatedResult.data.upgradeDialogShownVersion).toBe("3.1.0"); - expect(updatedResult.data.proVersion).toBe("3.1.0"); // Should preserve original version - expect(updatedResult.data.endDate).toBe(expiredTrial.endDate); // Should preserve end date - - return { expiredTrial, updatedTrialData: updatedResult.data }; - } - - it("should show local promo ended dialog when trial expires (offline/fetch fails)", async function () { - // Mock fetch to fail (network error) - if (testWindow._test_pro_dlg_login_exports && testWindow._test_pro_dlg_login_exports.setFetchFn) { - testWindow._test_pro_dlg_login_exports.setFetchFn(() => { - return Promise.reject(new Error('Network error')); - }); - } - - // Set up expired trial and activate - await setupExpiredTrialAndActivate(); - - // Wait for modal dialog and verify it's the local "ended" dialog - await testWindow.__PR.waitForModalDialog(".modal"); - const modalContent = testWindow.$('.modal'); - expect(modalContent.length).toBeGreaterThan(0); - - // Verify it's the local dialog (has text content, no iframe) - const dialogText = modalContent.text(); - expect(dialogText).toContain('Phoenix Pro'); - expect(dialogText).toContain('Trial has ended'); - - // Verify NO iframe present (local dialog) - const iframes = modalContent.find('iframe'); - expect(iframes.length).toBe(0); - - // Close local dialog - simpler button structure - testWindow.__PR.clickDialogButtonID("secondaryButton"); - await testWindow.__PR.waitForModalDialogClosed(".modal"); - }); - - it("should show remote promo ended dialog when trial expires (online)", async function () { - // Mock fetch to succeed with remote config - if (testWindow._test_pro_dlg_login_exports && testWindow._test_pro_dlg_login_exports.setFetchFn) { - testWindow._test_pro_dlg_login_exports.setFetchFn(() => { - return Promise.resolve({ - ok: true, - json: () => Promise.resolve({ - upsell_after_trial_url: "https://phcode.io", - upsell_purchase_url: "https://phcode.dev/pricing" - }) - }); - }); - } - - // Set up expired trial and activate - await setupExpiredTrialAndActivate(); - - // Wait for modal dialog and verify it's the remote dialog - await testWindow.__PR.waitForModalDialog(".modal"); - const modalContent = testWindow.$('.modal'); - expect(modalContent.length).toBeGreaterThan(0); - - // Verify it's the remote dialog (contains iframe) - const iframes = modalContent.find('iframe'); - expect(iframes.length).toBeGreaterThan(0); - - // Close remote dialog - may have complex button structure - testWindow.__PR.clickDialogButtonID(testWindow.__PR.Dialogs.DIALOG_BTN_CANCEL); - await testWindow.__PR.waitForModalDialogClosed(".modal"); - }); - - it("should grant new trial when expired trial is from older version (not logged in)", async function () { - const expiredTrial = { - proVersion: "3.0.0", // Older version than current 3.1.0 - endDate: mockNow - LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY, // Expired yesterday - signature: "mock_signature" - }; - - // Set up expired trial data first - await LoginServicePromo._setTrialData(expiredTrial); - - await LoginServicePromo.activateProTrial(); - - // Get the updated trial data - const updatedTrialData = await LoginServicePromo._getTrialData(); - - // Verify new trial was granted for version upgrade - expect(updatedTrialData).not.toBeNull(); - expect(updatedTrialData.data).toBeDefined(); - expect(updatedTrialData.data.proVersion).toBe("3.1.0"); // Should update to current version - - // Should grant 7-day trial for version upgrade - const expectedEndDate = mockNow + (7 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY); - expect(updatedTrialData.data.endDate).toBe(expectedEndDate); - - // Should show upgrade dialog (not ended dialog) - await testWindow.__PR.waitForModalDialog(".modal"); - const modalContent = testWindow.$('.modal'); - expect(modalContent.length).toBeGreaterThan(0); - - const dialogText = modalContent.text(); - expect(dialogText.toLowerCase()).toContain('you’ve been upgraded to'); - expect(dialogText).toContain('Phoenix Pro'); - expect(dialogText).toContain('7 days'); - - // Close the dialog - testWindow.__PR.clickDialogButtonID(testWindow.__PR.Dialogs.DIALOG_BTN_OK); - await testWindow.__PR.waitForModalDialogClosed(".modal"); - }); - - // Note: Additional expiration scenarios (dialog already shown, pro users) - // are difficult to test without mocking private functions - }); - - describe("Trial Days Calculation", function () { - - it("should return remaining trial days", async function () { - const futureEndDate = mockNow + (5.7 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY); - const trialData = { - proVersion: "3.1.0", - endDate: futureEndDate - }; - - // Set up trial data - await LoginServicePromo._setTrialData(trialData); - - const remainingDays = await LoginServicePromo.getProTrialDaysRemaining(); - - // Should round up to 6 days - expect(remainingDays).toBe(6); - }); - - it("should return 0 for expired trials", async function () { - const pastEndDate = mockNow - (2 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY); - const trialData = { - proVersion: "3.1.0", - endDate: pastEndDate - }; - - // Set up expired trial data - await LoginServicePromo._setTrialData(trialData); - - const remainingDays = await LoginServicePromo.getProTrialDaysRemaining(); - - expect(remainingDays).toBe(0); - }); - }); - - // Note: Version comparison, pro subscription checks, and event triggering - // are internal implementation details that are difficult to test reliably - // in integration tests without extensive mocking of private functions - - describe("Security Tests", function () { - - beforeEach(async function() { - // Restore original fetch to ensure clean state between tests - // This prevents fetch mocks from previous tests affecting security tests - if (originalFetch && testWindow._test_pro_dlg_login_exports && testWindow._test_pro_dlg_login_exports.setFetchFn) { - testWindow._test_pro_dlg_login_exports.setFetchFn(originalFetch); - } - }); - - it("should detect and prevent signature tampering attacks", async function () { - // Setup: Create a valid trial first - const validTrial = { - proVersion: "3.1.0", - endDate: mockNow + (5 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY) - }; - await LoginServicePromo._setTrialData(validTrial); - - // Get the valid trial data (should include signature) - let storedResult = await LoginServicePromo._getTrialData(); - expect(storedResult).not.toBeNull(); - expect(storedResult.data).toBeDefined(); // Should have valid data - expect(storedResult.error).toBeUndefined(); // Should not have error - - // Attack: Tamper with the signature - const tamperedTrial = { ...storedResult.data, signature: "fake_signature" }; - - // Manually store the tampered data (bypassing _setTrialData validation) - await LoginServicePromo._testSetPromoJSON(tamperedTrial); - - // Verify: _getTrialData should detect corruption - const corruptedResult = await LoginServicePromo._getTrialData(); - expect(corruptedResult).not.toBeNull(); - expect(corruptedResult.error).toBe(LoginServicePromo.ERROR_CONSTANTS.ERR_CORRUPTED); - - // Verify: activateProTrial should create expired trial marker and deny trial - await LoginServicePromo.activateProTrial(); - - // Should create expired trial marker instead of clearing - const resultAfterSecurity = await LoginServicePromo._getTrialData(); - expect(resultAfterSecurity).not.toBeNull(); // Should have expired trial data - expect(resultAfterSecurity.data).toBeDefined(); - expect(resultAfterSecurity.data.proVersion).toBe("3.1.0"); - expect(resultAfterSecurity.data.endDate).toBe(mockNow); // Should be expired immediately (endDate: now) - - // Should return 0 remaining days (expired trial) - const remainingDays = await LoginServicePromo.getProTrialDaysRemaining(); - expect(remainingDays).toBe(0); - - // Should show trial ended dialog (security notice) - await testWindow.__PR.waitForModalDialog(".modal"); - const modalContent = testWindow.$('.modal'); - expect(modalContent.length).toBeGreaterThan(0); - const dialogText = modalContent.text(); - expect(dialogText).toContain('Trial has ended'); - - // Close dialog - testWindow.__PR.clickDialogButtonID("secondaryButton"); - await testWindow.__PR.waitForModalDialogClosed(".modal"); - }); - - it("should handle version downgrade without losing valid trials", async function () { - // Setup: Create trial with newer app version salt - testWindow.AppConfig.version = "3.2.0"; - testWindow.AppConfig.apiVersion = "3.2.0"; - - // Clean any existing salt to simulate fresh install - await LoginServicePromo._cleanSaltData(); - - const futureTrial = { - proVersion: "3.2.0", - endDate: mockNow + (10 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY) - }; - await LoginServicePromo._setTrialData(futureTrial); - - // Verify trial is valid with 3.2.0 - let trialResult = await LoginServicePromo._getTrialData(); - expect(trialResult).not.toBeNull(); - expect(trialResult.data).toBeDefined(); - expect(trialResult.error).toBeUndefined(); - expect(trialResult.data.proVersion).toBe("3.2.0"); - - // Simulate version downgrade - change app version - testWindow.AppConfig.version = "3.1.0"; - testWindow.AppConfig.apiVersion = "3.1.0"; - - // The per-user salt should remain the same, so signature should still be valid - const downgradeResult = await LoginServicePromo._getTrialData(); - expect(downgradeResult).not.toBeNull(); - expect(downgradeResult.data).toBeDefined(); // Should have valid data - expect(downgradeResult.error).toBeUndefined(); // Should NOT have error - expect(downgradeResult.data.proVersion).toBe("3.2.0"); // Should preserve original version - - // Should still have valid remaining days - const remainingDays = await LoginServicePromo.getProTrialDaysRemaining(); - expect(remainingDays).toBe(10); - - // activateProTrial should preserve the existing valid trial - await LoginServicePromo.activateProTrial(); - const finalTrial = await LoginServicePromo._getTrialData(); - expect(finalTrial.data).toBeDefined(); - expect(finalTrial.data.proVersion).toBe("3.2.0"); // Should preserve newer version - expect(finalTrial.data.endDate).toBe(futureTrial.endDate); // Should preserve end date - }); - - it("should handle missing signature fields as tampered", async function () { - // Setup: Create trial data with missing signature field - const trialWithoutSignature = { - proVersion: "3.1.0", - endDate: mockNow + (5 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY) - // No signature field - }; - - // Manually store data without signature (bypassing _setTrialData) - await LoginServicePromo._testSetPromoJSON(trialWithoutSignature); - - // Should detect corruption due to missing signature - const result = await LoginServicePromo._getTrialData(); - expect(result.error).toBe(LoginServicePromo.ERROR_CONSTANTS.ERR_CORRUPTED); - - // Should create expired trial marker for security - await LoginServicePromo.activateProTrial(); - const afterActivation = await LoginServicePromo._getTrialData(); - expect(afterActivation).not.toBeNull(); // Should have expired trial data - expect(afterActivation.data).toBeDefined(); - expect(afterActivation.data.proVersion).toBe("3.1.0"); - expect(afterActivation.data.endDate).toBe(mockNow); // Should be expired immediately (endDate: now) - - // Should return 0 remaining days (expired trial) - const remainingDays = await LoginServicePromo.getProTrialDaysRemaining(); - expect(remainingDays).toBe(0); - - // Should show security dialog - await testWindow.__PR.waitForModalDialog(".modal"); - const modalContent = testWindow.$('.modal'); - expect(modalContent.length).toBeGreaterThan(0); - - // Close dialog - testWindow.__PR.clickDialogButtonID("secondaryButton"); - await testWindow.__PR.waitForModalDialogClosed(".modal"); - }); - - it("should persist salt correctly", async function () { - // Clean existing salt - await LoginServicePromo._cleanSaltData(); - - // Get salt (should generate new one) - const salt1 = await LoginServicePromo.LoginService.getSalt(); - expect(salt1).toBeDefined(); - expect(typeof salt1).toBe('string'); - expect(salt1.length).toBeGreaterThan(10); // Should be substantial UUID - - // Get salt again (should return same one) - const salt2 = await LoginServicePromo.LoginService.getSalt(); - expect(salt2).toBe(salt1); - - // Create and store trial with this salt - const trial = { - proVersion: "3.1.0", - endDate: mockNow + (5 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY) - }; - await LoginServicePromo._setTrialData(trial); - - // Verify trial is valid - const storedResult = await LoginServicePromo._getTrialData(); - expect(storedResult.data).toBeDefined(); - expect(storedResult.error).toBeUndefined(); - - const salt3 = await LoginServicePromo.LoginService.getSalt(); - expect(salt3).toBe(salt1); // Should be persistent - - // Trial should still be valid after "restart" - const restartResult = await LoginServicePromo._getTrialData(); - expect(restartResult.data).toBeDefined(); - expect(restartResult.error).toBeUndefined(); - expect(restartResult.data.proVersion).toBe("3.1.0"); - }); - - it("should prevent future trial grants after corruption creates expired marker", async function () { - // Setup: Create a valid trial first - const validTrial = { - proVersion: "3.1.0", - endDate: mockNow + (5 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY) - }; - await LoginServicePromo._setTrialData(validTrial); - - // Attack: Corrupt the trial data - const storedResult = await LoginServicePromo._getTrialData(); - const tamperedTrial = { ...storedResult.data, signature: "fake_signature" }; - - // Manually store the tampered data (bypassing validation) - await LoginServicePromo._testSetPromoJSON(tamperedTrial); - - // First activation should create expired marker - await LoginServicePromo.activateProTrial(); - - // Dismiss the security dialog - await testWindow.__PR.waitForModalDialog(".modal"); - testWindow.__PR.clickDialogButtonID("secondaryButton"); - await testWindow.__PR.waitForModalDialogClosed(".modal"); - - // Verify expired marker exists - const expiredResult = await LoginServicePromo._getTrialData(); - expect(expiredResult.data).toBeDefined(); - expect(expiredResult.data.endDate).toBe(mockNow); // Should be expired immediately - - // Simulate app restart by calling activateProTrial again - // This should NOT grant a new 30-day trial - await LoginServicePromo.activateProTrial(); - - // Should show trial ended dialog again (since trial is still expired) - await testWindow.__PR.waitForModalDialog(".modal"); - testWindow.__PR.clickDialogButtonID("secondaryButton"); - await testWindow.__PR.waitForModalDialogClosed(".modal"); - - // Should still have the expired marker, not a new 30-day trial - const afterRestartResult = await LoginServicePromo._getTrialData(); - expect(afterRestartResult.data).toBeDefined(); - expect(afterRestartResult.data.endDate).toBe(mockNow); // Still expired immediately - expect(afterRestartResult.data.endDate).toBe(expiredResult.data.endDate); // Same end date - - // Should still return 0 days - const remainingDays = await LoginServicePromo.getProTrialDaysRemaining(); - expect(remainingDays).toBe(0); - }); - - it("should detect time manipulation attacks (system clock rollback)", async function () { - // Setup: Create an expired trial with dialog shown flag - const expiredTrial = { - proVersion: "3.1.0", - endDate: mockNow - (5 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY), // Expired 5 days ago - upgradeDialogShownVersion: "3.1.0" // Dialog already shown for current version - }; - await LoginServicePromo._setTrialData(expiredTrial); - - // Verify trial is properly expired - const expiredResult = await LoginServicePromo._getTrialData(); - expect(expiredResult.data).toBeDefined(); - expect(expiredResult.error).toBeUndefined(); - - // Verify _isTrialClosedForCurrentVersion detects closed trial - const isClosedBefore = await LoginServicePromo._isTrialClosedForCurrentVersion(expiredResult.data); - expect(isClosedBefore).toBe(true); - - // Attack: User rolls back system time to make trial appear valid - const rolledBackTime = mockNow - (10 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY); // 10 days ago - LoginServicePromo.setDateNowFn(() => rolledBackTime); - - const remainingDaysAfterRollback = await LoginServicePromo.getProTrialDaysRemaining(); - // But getProTrialDaysRemaining should still return 0 due to closure detection - expect(remainingDaysAfterRollback).toBe(0); - - // Verify _isTrialClosedForCurrentVersion still detects closed trial despite time manipulation - const isClosedAfterRollback = await LoginServicePromo._isTrialClosedForCurrentVersion(expiredResult.data); - expect(isClosedAfterRollback).toBe(true); // Should still be closed - - // Reset time - LoginServicePromo.setDateNowFn(() => mockNow); - - // activateProTrial should not grant new trial even after time manipulation - await LoginServicePromo.activateProTrial(); - - // Should still have the same expired trial, not a new one - const finalResult = await LoginServicePromo._getTrialData(); - expect(finalResult.data).toBeDefined(); - expect(finalResult.data.endDate).toBe(expiredTrial.endDate); // Same end date - expect(finalResult.data.upgradeDialogShownVersion).toBe("3.1.0"); // Flag preserved - }); - - it("should respect trial closure flags across version changes", async function () { - // Setup: Create a trial that's expired for current version but not time-expired - const validTrial = { - proVersion: "3.0.0", // Older version - endDate: mockNow + (5 * LoginServicePromo.TRIAL_CONSTANTS.MS_PER_DAY), // Still has time remaining - upgradeDialogShownVersion: "3.1.0" // Dialog was already shown for current version - }; - await LoginServicePromo._setTrialData(validTrial); - - // Current version is 3.1.0, which is newer than trial version 3.0.0 - // Trial has remaining time but dialog was shown for current version - const trialResult = await LoginServicePromo._getTrialData(); - expect(trialResult.data).toBeDefined(); - - // _isTrialClosedForCurrentVersion should return true because dialog was shown for current version - const isClosed = await LoginServicePromo._isTrialClosedForCurrentVersion(trialResult.data); - expect(isClosed).toBe(true); - - // getProTrialDaysRemaining should return 0 due to closure flag - const remainingDays = await LoginServicePromo.getProTrialDaysRemaining(); - expect(remainingDays).toBe(0); - - // activateProTrial should not grant new trial due to closure flag - await LoginServicePromo.activateProTrial(); - - // Should preserve the existing trial with dialog shown flag - const finalResult = await LoginServicePromo._getTrialData(); - expect(finalResult.data).toBeDefined(); - expect(finalResult.data.endDate).toBe(validTrial.endDate); // Same end date - expect(finalResult.data.upgradeDialogShownVersion).toBe("3.1.0"); // Flag preserved - - // Test version upgrade scenario - newer version should work - testWindow.AppConfig.apiVersion = "3.2.0"; // Upgrade to newer version - - // Now _isTrialClosedForCurrentVersion should return false for the newer version - const isClosedAfterUpgrade = await LoginServicePromo._isTrialClosedForCurrentVersion(finalResult.data); - expect(isClosedAfterUpgrade).toBe(false); // Should not be closed for newer version - - // Should now have remaining days since it's a newer version - const remainingAfterUpgrade = await LoginServicePromo.getProTrialDaysRemaining(); - expect(remainingAfterUpgrade).toBeGreaterThan(0); - - // Reset version for cleanup - testWindow.AppConfig.apiVersion = "3.1.0"; - }); - - afterEach(async function() { - // Clean up after each security test - await LoginServicePromo._cleanTrialData(); - await LoginServicePromo._cleanSaltData(); - - // Reset app config to default - testWindow.AppConfig = { - version: "3.1.0", - apiVersion: "3.1.0" - }; - }); - }); - - describe("Entitlements Validation", function () { - let LoginServiceExports; - - beforeEach(function() { - // Access login service exports - LoginServiceExports = testWindow._test_login_service_exports; - - // Set up time mocking - if (LoginServiceExports.setDateNowFn) { - LoginServiceExports.setDateNowFn(() => mockNow); - } - }); - - afterEach(function() { - // Reset time function - if (LoginServiceExports.setDateNowFn) { - LoginServiceExports.setDateNowFn(Date.now); - } - }); - - it("should handle expired plans correctly", function () { - - // Test expired plan gets reset to free plan - const expiredPlanEntitlements = { - plan: { - isSubscriber: true, - name: "Phoenix Pro", - validTill: mockNow - 86400000 // 1 day ago - }, - entitlements: {} - }; - - LoginServiceExports._validateAndFilterEntitlements(expiredPlanEntitlements); - - expect(expiredPlanEntitlements.plan.isSubscriber).toBe(false); - expect(expiredPlanEntitlements.plan.paidSubscriber).toBe(false); - expect(expiredPlanEntitlements.plan.name).toBe(testWindow.Strings.USER_FREE_PLAN_NAME_DO_NOT_TRANSLATE); - expect(expiredPlanEntitlements.plan.validTill).toBeGreaterThan(mockNow); - - // Test valid plan remains unchanged - const validPlanEntitlements = { - plan: { - isSubscriber: true, - name: "Phoenix Pro", - validTill: mockNow + 86400000 // 1 day from now - }, - entitlements: {} - }; - const originalPlan = JSON.parse(JSON.stringify(validPlanEntitlements.plan)); - - LoginServiceExports._validateAndFilterEntitlements(validPlanEntitlements); - - expect(validPlanEntitlements.plan).toEqual(originalPlan); - - // Test missing validTill gets reset - const noValidTillEntitlements = { - plan: { - isSubscriber: true, - name: "Phoenix Pro" - }, - entitlements: {} - }; - - LoginServiceExports._validateAndFilterEntitlements(noValidTillEntitlements); - - expect(noValidTillEntitlements.plan.isSubscriber).toBe(false); - expect(noValidTillEntitlements.plan.paidSubscriber).toBe(false); - expect(noValidTillEntitlements.plan.name).toBe(testWindow.Strings.USER_FREE_PLAN_NAME_DO_NOT_TRANSLATE); - }); - - it("should validate and filter expired feature entitlements", function () { - // Test expired features get deactivated - const entitlementsWithExpiredFeatures = { - plan: { - isSubscriber: true, - name: "Phoenix Pro", - validTill: mockNow + 86400000 - }, - entitlements: { - liveEdit: { - activated: true, - validTill: mockNow - 86400000 // expired - }, - liveEditAI: { - activated: true, - validTill: mockNow + 86400000 // valid - } - } - }; - - LoginServiceExports._validateAndFilterEntitlements(entitlementsWithExpiredFeatures); - - expect(entitlementsWithExpiredFeatures.entitlements.liveEdit.activated).toBe(false); - expect(entitlementsWithExpiredFeatures.entitlements.liveEditAI.activated) - .toBe(true); // should remain unchanged - expect(entitlementsWithExpiredFeatures.entitlements.liveEdit.upgradeToPlan) - .toBe(testWindow.brackets.config.main_pro_plan); - expect(entitlementsWithExpiredFeatures.entitlements.liveEdit.subscribeURL) - .toBe(testWindow.brackets.config.purchase_url); - - // Test features without validTill get deactivated (treated as expired) - const entitlementsNoValidTill = { - plan: { - isSubscriber: true, - name: "Phoenix Pro", - validTill: mockNow + 86400000 - }, - entitlements: { - liveEdit: { - activated: true - // no validTill property - should be treated as expired - } - } - }; - - LoginServiceExports._validateAndFilterEntitlements(entitlementsNoValidTill); - - expect(entitlementsNoValidTill.entitlements.liveEdit.activated) - .toBe(false); // should be deactivated - expect(entitlementsNoValidTill.entitlements.liveEdit.upgradeToPlan) - .toBe(testWindow.brackets.config.main_pro_plan); - expect(entitlementsNoValidTill.entitlements.liveEdit.subscribeURL) - .toBe(testWindow.brackets.config.purchase_url); - expect(entitlementsNoValidTill.entitlements.liveEdit.validTill < mockNow) - .toBeTrue(); // should be set to past date - }); - - it("should handle null and edge cases safely", function () { - const validateFn = LoginServiceExports._validateAndFilterEntitlements; - - // Test null entitlements - expect(function() { - validateFn(null); - }).not.toThrow(); - - // Test undefined entitlements - expect(function() { - validateFn(undefined); - }).not.toThrow(); - - // Test entitlements without plan - const noPlanEntitlements = { - entitlements: {} - }; - expect(function() { - validateFn(noPlanEntitlements); - }).not.toThrow(); - - // Test entitlements without entitlements object - const noEntitlementsObj = { - plan: { - isSubscriber: true, - name: "Phoenix Pro", - validTill: mockNow + 86400000 - } - }; - expect(function() { - validateFn(noEntitlementsObj); - }).not.toThrow(); - - // Test empty entitlements object - const emptyEntitlements = {}; - expect(function() { - validateFn(emptyEntitlements); - }).not.toThrow(); - }); - }); - }); -}); diff --git a/tracking-repos.json b/tracking-repos.json index 227e8f2260..e85f686277 100644 --- a/tracking-repos.json +++ b/tracking-repos.json @@ -1,5 +1,5 @@ { "phoenixPro": { - "commitID": "f58be6fcf8d698f0320b0da71dded01d14619f5d" + "commitID": "1bda799c4c6221d33c85e218226c9d66707e873d" } }