diff --git a/docs/API-Reference/editor/CodeHintManager.md b/docs/API-Reference/editor/CodeHintManager.md index 653b60d6a4..ede9eae725 100644 --- a/docs/API-Reference/editor/CodeHintManager.md +++ b/docs/API-Reference/editor/CodeHintManager.md @@ -271,7 +271,7 @@ Test if a hint popup is open. Register a handler to show hints at the top of the hint list. This API allows extensions to add their own hints at the top of the standard hint list. -**Kind**: inner method of [CodeHintManager](#module_CodeHintManager) +**Kind**: inner method of [CodeHintManager](#module_CodeHintManager) | Param | Type | Description | | --- | --- | --- | @@ -282,4 +282,4 @@ This API allows extensions to add their own hints at the top of the standard hin ### CodeHintManager.clearHintsAtTop() Unregister the hints at top handler. -**Kind**: inner method of [CodeHintManager](#module_CodeHintManager) +**Kind**: inner method of [CodeHintManager](#module_CodeHintManager) diff --git a/serve-proxy.js b/serve-proxy.js index 93ca243ced..8c3d236e54 100644 --- a/serve-proxy.js +++ b/serve-proxy.js @@ -1,4 +1,5 @@ #!/usr/bin/env node +/* eslint-env node */ const http = require('http'); const https = require('https'); @@ -7,6 +8,10 @@ const path = require('path'); const fs = require('fs'); const httpProxy = require('http-proxy'); +// Account server configuration - switch between local and production +const ACCOUNT_SERVER = 'https://account.phcode.dev'; // Production +// const ACCOUNT_SERVER = 'http://localhost:5000'; // Local development + // Default configuration let config = { port: 8000, @@ -20,10 +25,10 @@ let config = { // Parse command line arguments function parseArgs() { const args = process.argv.slice(2); - + for (let i = 0; i < args.length; i++) { const arg = args[i]; - + if (arg === '-p' && args[i + 1]) { config.port = parseInt(args[i + 1]); i++; @@ -64,15 +69,15 @@ proxy.on('error', (err, req, res) => { }); // Modify proxy request headers -proxy.on('proxyReq', (proxyReq, req, res) => { +proxy.on('proxyReq', (proxyReq, req) => { // Transform localhost:8000 to appear as phcode.dev domain - const originalHost = req.headers.host; const originalReferer = req.headers.referer; const originalOrigin = req.headers.origin; - + // Set target host - proxyReq.setHeader('Host', 'account.phcode.dev'); - + const accountHost = new URL(ACCOUNT_SERVER).hostname; + proxyReq.setHeader('Host', accountHost); + // Transform referer from localhost:8000 to phcode.dev if (originalReferer && originalReferer.includes('localhost:8000')) { const newReferer = originalReferer.replace(/localhost:8000/g, 'phcode.dev'); @@ -80,7 +85,7 @@ proxy.on('proxyReq', (proxyReq, req, res) => { } else if (!originalReferer) { proxyReq.setHeader('Referer', 'https://phcode.dev/'); } - + // Transform origin from localhost:8000 to phcode.dev if (originalOrigin && originalOrigin.includes('localhost:8000')) { const newOrigin = originalOrigin.replace(/localhost:8000/g, 'phcode.dev'); @@ -88,18 +93,18 @@ proxy.on('proxyReq', (proxyReq, req, res) => { } else if (!originalOrigin) { proxyReq.setHeader('Origin', 'https://phcode.dev'); } - + // Ensure HTTPS scheme proxyReq.setHeader('X-Forwarded-Proto', 'https'); proxyReq.setHeader('X-Forwarded-For', req.connection.remoteAddress); - + }); // Modify proxy response headers proxy.on('proxyRes', (proxyRes, req, res) => { // Pass through cache control and other security headers // But translate any domain references back to localhost for the browser - + const setCookieHeader = proxyRes.headers['set-cookie']; if (setCookieHeader) { // Transform any phcode.dev domain cookies back to localhost @@ -108,7 +113,7 @@ proxy.on('proxyRes', (proxyRes, req, res) => { }); proxyRes.headers['set-cookie'] = modifiedCookies; } - + // Ensure CORS headers if needed if (config.cors) { proxyRes.headers['Access-Control-Allow-Origin'] = '*'; @@ -148,7 +153,7 @@ function serveStaticFile(req, res, filePath) { res.end('File not found'); return; } - + if (stats.isDirectory()) { // Try to serve index.html from directory const indexPath = path.join(filePath, 'index.html'); @@ -163,7 +168,7 @@ function serveStaticFile(req, res, filePath) { res.end('Error reading directory'); return; } - + const html = ` @@ -178,24 +183,24 @@ function serveStaticFile(req, res, filePath) { `; - + const headers = { 'Content-Type': 'text/html', 'Content-Length': Buffer.byteLength(html) }; - + if (!config.cache) { headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'; headers['Pragma'] = 'no-cache'; headers['Expires'] = '0'; } - + if (config.cors) { headers['Access-Control-Allow-Origin'] = '*'; headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'; headers['Access-Control-Allow-Headers'] = 'Origin, X-Requested-With, Content-Type, Accept, Authorization, Cache-Control'; } - + res.writeHead(200, headers); res.end(html); }); @@ -203,31 +208,31 @@ function serveStaticFile(req, res, filePath) { }); return; } - + // Serve file const mimeType = getMimeType(filePath); const headers = { 'Content-Type': mimeType, 'Content-Length': stats.size }; - + if (!config.cache) { headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'; headers['Pragma'] = 'no-cache'; headers['Expires'] = '0'; } - + if (config.cors) { headers['Access-Control-Allow-Origin'] = '*'; headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'; headers['Access-Control-Allow-Headers'] = 'Origin, X-Requested-With, Content-Type, Accept, Authorization, Cache-Control'; } - + res.writeHead(200, headers); - + const stream = fs.createReadStream(filePath); stream.pipe(res); - + stream.on('error', (err) => { res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end('Error reading file'); @@ -238,7 +243,7 @@ function serveStaticFile(req, res, filePath) { // Create HTTP server const server = http.createServer((req, res) => { const parsedUrl = url.parse(req.url, true); - + // Handle CORS preflight if (req.method === 'OPTIONS' && config.cors) { res.writeHead(200, { @@ -249,32 +254,32 @@ const server = http.createServer((req, res) => { res.end(); return; } - + // Check if this is a proxy request if (parsedUrl.pathname.startsWith('/proxy/accounts')) { // Extract the path after /proxy/accounts const targetPath = parsedUrl.pathname.replace('/proxy/accounts', ''); const originalUrl = req.url; - + // Modify the request URL for the proxy req.url = targetPath + (parsedUrl.search || ''); - + if (!config.silent) { - console.log(`[PROXY] ${req.method} ${originalUrl} -> https://account.phcode.dev${req.url}`); + console.log(`[PROXY] ${req.method} ${originalUrl} -> ${ACCOUNT_SERVER}${req.url}`); } - + // Proxy the request proxy.web(req, res, { - target: 'https://account.phcode.dev', + target: ACCOUNT_SERVER, changeOrigin: true, secure: true }); return; } - + // Serve static files let filePath = path.join(config.root, parsedUrl.pathname); - + // Security: prevent directory traversal const normalizedPath = path.normalize(filePath); if (!normalizedPath.startsWith(config.root)) { @@ -282,13 +287,24 @@ const server = http.createServer((req, res) => { res.end('Forbidden'); return; } - + if (!config.silent) { const clientIp = req.headers['x-forwarded-for'] || req.connection.remoteAddress; console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}${config.logIp ? ` (${clientIp})` : ''}`); } - - serveStaticFile(req, res, filePath); + + // Handle directory requests without trailing slash + fs.stat(filePath, (err, stats) => { + if (err) { + serveStaticFile(req, res, filePath); + } else if (stats.isDirectory() && !parsedUrl.pathname.endsWith('/')) { + // Redirect to URL with trailing slash for directories + res.writeHead(301, { 'Location': req.url + '/' }); + res.end(); + } else { + serveStaticFile(req, res, filePath); + } + }); }); // Parse arguments and start server @@ -300,7 +316,7 @@ server.listen(config.port, config.host, () => { console.log(`Available on:`); console.log(` http://${config.host === '0.0.0.0' ? 'localhost' : config.host}:${config.port}`); console.log(`Proxy routes:`); - console.log(` /proxy/accounts/* -> https://account.phcode.dev/*`); + console.log(` /proxy/accounts/* -> ${ACCOUNT_SERVER}/*`); console.log('Hit CTRL-C to stop the server'); } }); @@ -318,4 +334,4 @@ process.on('SIGTERM', () => { server.close(() => { process.exit(0); }); -}); \ No newline at end of file +}); diff --git a/src-node/index.js b/src-node/index.js index b120bfd127..c3087273f4 100644 --- a/src-node/index.js +++ b/src-node/index.js @@ -219,7 +219,7 @@ const nodeConnector = NodeConnector.createNodeConnector(AUTH_CONNECTOR_ID, { setVerificationCode }); -const ALLOWED_ORIGIN = 'https://account.phcode.io'; +const ALLOWED_ORIGIN = 'https://account.phcode.dev'; function autoAuth(req, res) { const origin = req.headers.origin; // localhost dev of loginService is not allowed here to not leak to production. So autoAuth is not available in diff --git a/src/brackets.js b/src/brackets.js index a21b33aae5..c3a0b9d261 100644 --- a/src/brackets.js +++ b/src/brackets.js @@ -139,7 +139,8 @@ define(function (require, exports, module) { require("widgets/InlineMenu"); require("thirdparty/tinycolor"); require("utils/LocalizationUtils"); - require("services/login"); + 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/config.json b/src/config.json index d6f3993d53..c5b3efc417 100644 --- a/src/config.json +++ b/src/config.json @@ -3,10 +3,10 @@ "app_title": "Phoenix Code", "app_name_about": "Phoenix Code", "about_icon": "styles/images/phoenix-icon.svg", - "account_url": "https://account.phcode.io/", + "account_url": "https://account.phcode.dev/", "how_to_use_url": "https://github.com/adobe/brackets/wiki/How-to-Use-Brackets", "docs_url": "https://docs.phcode.dev/", - "support_url": "https://account.phcode.io/?returnUrl=https%3A%2F%2Faccount.phcode.io%2F%23support", + "support_url": "https://account.phcode.dev/?returnUrl=https%3A%2F%2Faccount.phcode.dev%2F%23support", "suggest_feature_url": "https://github.com/phcode-dev/phoenix/discussions/categories/ideas", "report_issue_url": "https://github.com/phcode-dev/phoenix/issues/new/choose", "get_involved_url": "https://github.com/phcode-dev/phoenix/discussions/77", @@ -60,4 +60,4 @@ "url": "https://github.com/phcode-dev/phoenix/blob/master/LICENSE" } ] -} \ No newline at end of file +} diff --git a/src/nls/root/strings.js b/src/nls/root/strings.js index 2a1e50fac2..ed665e515b 100644 --- a/src/nls/root/strings.js +++ b/src/nls/root/strings.js @@ -1594,7 +1594,7 @@ define({ "SIGNED_IN_FAILED_TITLE": "Cannot Sign In", "SIGNED_IN_FAILED_MESSAGE": "Something went wrong while trying to sign in. Please try again.", "SIGNED_OUT_FAILED_TITLE": "Failed to Sign Out", - "SIGNED_OUT_FAILED_MESSAGE": "Something went wrong while trying to sign out. Please try again.", + "SIGNED_OUT_FAILED_MESSAGE": "Something went wrong while logging out. Press OK to open account.phcode.dev where you can logout manually.", "VALIDATION_CODE_TITLE": "Sign In Verification Code", "VALIDATION_CODE_MESSAGE": "Please use this Verification code to sign in to your {APP_NAME} account:", "COPY_VALIDATION_CODE": "Copy Code", @@ -1607,6 +1607,15 @@ define({ "ACCOUNT_DETAILS": "Account Details", "AI_QUOTA_USED": "AI quota used", "LOGIN_REFRESH": "Check Login Status", + "SIGN_IN_WAITING_TITLE": "Waiting for Sign In", + "SIGN_IN_WAITING_MESSAGE": "Please complete sign-in in the new tab, then return here.", + "WAITING_FOR_LOGIN": "Waiting for login\u2026", + "CHECK_NOW": "Check Now", + "CHECKING": "Checking\u2026", + "CHECKING_STATUS": "Checking login status\u2026", + "NOT_SIGNED_IN_YET": "Not signed in yet. Please complete sign-in in the other tab.", + "WELCOME_BACK": "Welcome back, {0}!", + "POPUP_BLOCKED": "Pop-up blocked. Please allow pop-ups and try again, or manually navigate to {0}", // Collapse Folders "COLLAPSE_ALL_FOLDERS": "Collapse All Folders", diff --git a/src/services/html/browser-login-waiting-dialog.html b/src/services/html/browser-login-waiting-dialog.html new file mode 100644 index 0000000000..733e99dcd1 --- /dev/null +++ b/src/services/html/browser-login-waiting-dialog.html @@ -0,0 +1,19 @@ + diff --git a/src/services/login-browser.js b/src/services/login-browser.js new file mode 100644 index 0000000000..dcfaf2e1d5 --- /dev/null +++ b/src/services/login-browser.js @@ -0,0 +1,397 @@ +/* + * 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 EventDispatcher = require("utils/EventDispatcher"), + 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 secureExports = {}; + // Only set loginService for browser apps to avoid conflict with desktop login + if (!Phoenix.isNativeApp) { + KernalModeTrust.loginService = secureExports; + } + + // 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"; + + EventDispatcher.makeEventDispatcher(exports); + EventDispatcher.makeEventDispatcher(secureExports); + + const _EVT_PAGE_FOCUSED = "page_focused"; + $(window).focus(function () { + exports.trigger(_EVT_PAGE_FOCUSED); + }); + + 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"; + + /** + * 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 { + const response = await fetch(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()); + } + + 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); + return; + } + + // User is not logged in or error occurred + if(resolveResponse.err === ERR_NOT_LOGGED_IN) { + console.log("No browser session found. Not logged in"); + // Only reset UI state if this is not a silent background check + if (!silentCheck) { + _resetBrowserLogin(); + } else { + // For silent checks, just update the internal state + isLoggedInUser = false; + userProfile = null; + } + return; + } + + // Other errors (network, retry later, etc.) + console.log("Browser login verification failed:", resolveResponse.err); + if (!silentCheck) { + _resetBrowserLogin(); + } else { + isLoggedInUser = false; + userProfile = null; + } + } + + let loginWaitingDialog = null; + let focusCheckInterval = 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(); + $template.find('#login-status') + .text(StringUtils.format(Strings.WELCOME_BACK, userProfile.firstName)) + .css('color', '#10b981'); + setTimeout(() => { + _cancelLoginWaiting(); + Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, "browserLogin", "browser"); + }, 1500); + } + } + + function _cancelLoginWaiting() { + if (loginWaitingDialog) { + loginWaitingDialog.close(); + loginWaitingDialog = null; + } + if (focusCheckInterval) { + clearInterval(focusCheckInterval); + focusCheckInterval = 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 accountURL = _getAccountWebURL(); + + // Open account URL in new tab + const newTab = window.open(accountURL, '_blank'); + + if (!newTab) { + Dialogs.showModalDialog( + DefaultDialogs.DIALOG_ID_ERROR, + Strings.SIGNED_IN_FAILED_TITLE, + StringUtils.format(Strings.POPUP_BLOCKED, accountURL) + ); + 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 { + const response = await fetch(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(); + + 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(); + 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() { + ProfileMenu.init(); + if(Phoenix.isNativeApp){ + console.log("Browser login service is not needed for native app"); + return; + } + + // Always verify login on browser app start (silent check to avoid closing popups) + _verifyBrowserLogin(true).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); + + // 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) { + init(); + // kernal exports + secureExports.isLoggedIn = isLoggedIn; + secureExports.signInToAccount = signInToBrowser; + secureExports.signOutAccount = signOutBrowser; + secureExports.getProfile = getProfile; + secureExports.verifyLoginStatus = () => _verifyBrowserLogin(false); + } + + // public exports + exports.isLoggedIn = isLoggedIn; + +}); diff --git a/src/services/login.js b/src/services/login-desktop.js similarity index 91% rename from src/services/login.js rename to src/services/login-desktop.js index c2821dde94..4dfef4ee5a 100644 --- a/src/services/login.js +++ b/src/services/login-desktop.js @@ -37,7 +37,10 @@ define(function (require, exports, module) { throw new Error("Login service should have access to KernalModeTrust. Cannot boot without trust ring"); } const secureExports = {}; - KernalModeTrust.loginService = secureExports; + // Only set loginService for native apps to avoid conflict with browser login + if (Phoenix.isNativeApp) { + KernalModeTrust.loginService = secureExports; + } // 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"} @@ -116,11 +119,13 @@ define(function (require, exports, module) { PreferencesManager.stateManager.set(PREF_USER_PROFILE_VERSION, crypto.randomUUID()); } - async function _verifyLogin() { + async function _verifyLogin(silentCheck = false) { const savedUserProfile = await KernalModeTrust.getCredential(KernalModeTrust.CRED_KEY_API); if(!savedUserProfile){ console.log("No savedUserProfile found. Not logged in"); - ProfileMenu.setNotLoggedIn(); + if (!silentCheck) { + ProfileMenu.setNotLoggedIn(); + } isLoggedInUser = false; return; } @@ -128,7 +133,9 @@ define(function (require, exports, module) { userProfile = JSON.parse(savedUserProfile); } catch (e) { console.error(e, "Failed to parse saved user profile credentials");// this should never happen - ProfileMenu.setNotLoggedIn(); + if (!silentCheck) { + ProfileMenu.setNotLoggedIn(); + } return; // not logged in if parse fails } isLoggedInUser = true; @@ -319,7 +326,7 @@ define(function (require, exports, module) { Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, isAutoSignedIn ? 'autoLogin' : 'manLogin' , Phoenix.platform); - Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, "login", + Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, "dsktpLogin", isAutoSignedIn ? 'auto' : 'man'); }); NativeApp.openURLInDefaultBrowser(appSignInURL); @@ -344,11 +351,14 @@ define(function (require, exports, module) { if (!result.isSuccess) { console.error('Error logging out', result); - Dialogs.showModalDialog( + const dialog = Dialogs.showModalDialog( DefaultDialogs.DIALOG_ID_ERROR, Strings.SIGNED_OUT_FAILED_TITLE, Strings.SIGNED_OUT_FAILED_MESSAGE ); + dialog.done(() => { + NativeApp.openURLInDefaultBrowser(Phoenix.config.account_url + "#advanced"); + }); Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, 'logoutFail', Phoenix.platform); return; } @@ -361,38 +371,44 @@ define(function (require, exports, module) { Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, 'logoutOK', Phoenix.platform); } catch (error) { console.error("Network error. Could not log out session.", error); - Dialogs.showModalDialog( + const dialog = Dialogs.showModalDialog( DefaultDialogs.DIALOG_ID_ERROR, Strings.SIGNED_OUT_FAILED_TITLE, Strings.SIGNED_OUT_FAILED_MESSAGE ); + dialog.done(() => { + NativeApp.openURLInDefaultBrowser(Phoenix.config.account_url + "#advanced"); + }); Metrics.countEvent(Metrics.EVENT_TYPE.AUTH, 'getAppAuth', Phoenix.platform); logger.reportError(error, "Failed to call logout calling" + resolveURL); } } function init() { - ProfileMenu.init(); if(!Phoenix.isNativeApp){ - console.warn("Login service is not supported in browser"); + console.log("Desktop login service not needed for browser"); return; } - _verifyLogin().catch(console.error);// todo raise metrics + ProfileMenu.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); } - init(); - // 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. - // kernal exports - secureExports.isLoggedIn = isLoggedIn; - secureExports.signInToAccount = signInToAccount; - secureExports.signOutAccount = signOutAccount; - secureExports.getProfile = getProfile; + // Only set exports for native apps to avoid conflict with browser login + if (Phoenix.isNativeApp) { + init(); + // kernal exports + secureExports.isLoggedIn = isLoggedIn; + secureExports.signInToAccount = signInToAccount; + secureExports.signOutAccount = signOutAccount; + secureExports.getProfile = getProfile; + secureExports.verifyLoginStatus = () => _verifyLogin(false); + } // public exports exports.isLoggedIn = isLoggedIn; diff --git a/src/services/profile-menu.js b/src/services/profile-menu.js index 7d8444ed49..aa10e15210 100644 --- a/src/services/profile-menu.js +++ b/src/services/profile-menu.js @@ -38,6 +38,9 @@ define(function (require, exports, module) { // 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; @@ -280,11 +283,44 @@ define(function (require, exports, module) { 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); + }); } function init() { @@ -299,24 +335,21 @@ define(function (require, exports, module) { .appendTo($("#main-toolbar .bottom-buttons")); // _updateProfileIcon("CA", "blue"); $icon.on('click', ()=>{ - if(!Phoenix.isNativeApp){ - // in browser app, we don't currently support login - Phoenix.app.openURLInDefaultBrowser("https://account.phcode.io"); - return; - } togglePopup(); }); } function setNotLoggedIn() { - if (isPopupVisible) { + // Only close popup if it's not a background refresh + if (isPopupVisible && !isBackgroundRefresh) { closePopup(); } _removeProfileIcon(); } function setLoggedIn(initial, color) { - if (isPopupVisible) { + // Only close popup if it's not a background refresh + if (isPopupVisible && !isBackgroundRefresh) { closePopup(); } _updateProfileIcon(initial, color);