From b39d70f4467fde8f17b7ea4099ca31ad3a741e39 Mon Sep 17 00:00:00 2001 From: Prateek Shourya Date: Mon, 9 Mar 2026 23:59:52 +0530 Subject: [PATCH] fix(desktop): fix auth sign-in opening in browser for cloud deployment (#6209) --- apps/desktop/src/managers/view-manager.ts | 140 ++++++++++++++++------ 1 file changed, 103 insertions(+), 37 deletions(-) diff --git a/apps/desktop/src/managers/view-manager.ts b/apps/desktop/src/managers/view-manager.ts index 78a175801f..54f3749eb9 100644 --- a/apps/desktop/src/managers/view-manager.ts +++ b/apps/desktop/src/managers/view-manager.ts @@ -22,6 +22,14 @@ import type { WindowLayoutMode } from "../stores/types"; const TAB_BAR_HEIGHT = 44; +/** + * Plane cloud deployment hosts. + * Self-hosted instances always serve app, api and assets from the same hostname, + * so cross-host handling is only needed for cloud hosts. + */ +const CLOUD_APP_HOST = "app.plane.so"; +const CLOUD_API_HOST = "api.plane.so"; + export class ViewManager { #views: Map = new Map(); #loadedTabs: Set = new Set(); @@ -341,57 +349,60 @@ export class ViewManager { } #setupViewHandlers(view: WebContentsView, id: string, instanceUrl: string): void { - const instanceHost = new URL(instanceUrl).hostname; - view.webContents.setWindowOpenHandler(({ url: targetUrl }) => { + // Non-HTTP(S) URLs (mailto:, plane://, etc.): let Electron handle normally if (!targetUrl.startsWith("http://") && !targetUrl.startsWith("https://")) { return { action: "allow" }; } - let targetHost: string; - try { - targetHost = new URL(targetUrl).hostname; - } catch { - void shell.openExternal(targetUrl); - return { action: "deny" }; - } + switch (classifyDesktopNavigation(instanceUrl, targetUrl)) { + case "same_host_asset": + case "cloud_cross_host_asset": + view.webContents.downloadURL(targetUrl); + return { action: "deny" }; - if (targetHost !== instanceHost) { - void shell.openExternal(targetUrl); - return { action: "deny" }; - } + case "cloud_cross_host_auth": + // Auth redirect via window.open: open in a new in-app tab. + // The full URL is passed so the cross-host auth page loads correctly; + // tab path is corrected via did-navigate once auth redirects back to the instance. + this.createTab(targetUrl); + return { action: "deny" }; - if (isAssetPath(targetUrl)) { - view.webContents.downloadURL(targetUrl); - return { action: "deny" }; - } + case "same_host_page": { + const { pathname, search, hash } = new URL(targetUrl); + this.createTab(pathname + search + hash || "/"); + return { action: "deny" }; + } - const parsedUrl = new URL(targetUrl); - const newPath = parsedUrl.pathname + parsedUrl.search + parsedUrl.hash; - this.createTab(newPath || "/"); - return { action: "deny" }; + case "external_cross_host": + case "invalid_url": + default: + void shell.openExternal(targetUrl); + return { action: "deny" }; + } }); view.webContents.on("will-navigate", (event, targetUrl) => { - let targetHost: string; - try { - targetHost = new URL(targetUrl).hostname; - } catch { - return; - } + switch (classifyDesktopNavigation(instanceUrl, targetUrl)) { + case "same_host_asset": + case "cloud_cross_host_asset": + event.preventDefault(); + view.webContents.downloadURL(targetUrl); + return; - if (targetHost !== instanceHost) { - event.preventDefault(); - void shell.openExternal(targetUrl); - return; - } + case "same_host_page": + case "cloud_cross_host_auth": + return; // allow navigation to proceed in-app - if (!isAssetPath(targetUrl)) { - return; - } + case "external_cross_host": + event.preventDefault(); + void shell.openExternal(targetUrl); + return; - event.preventDefault(); - view.webContents.downloadURL(targetUrl); + case "invalid_url": + default: + return; // passthrough for unparseable URLs + } }); view.webContents.on("page-title-updated", (_event, pageTitle) => { @@ -717,3 +728,58 @@ export class ViewManager { view.setBounds({ x: 0, y: tabBarHeight, width, height: height - tabBarHeight }); } } + +/** + * How the desktop app should handle a given navigation URL. + * + * - "same_host_page": Same hostname as instance, not an asset → navigate in-app. + * - "same_host_asset": Same hostname as instance, URL is a file/asset → trigger download. + * - "cloud_cross_host_auth": api.plane.so URL under /auth/ → allow in-app (auth flow). + * - "cloud_cross_host_asset": api.plane.so URL that is a file/asset → trigger download. + * - "external_cross_host": Unrecognised different hostname → open in system browser. + * - "invalid_url": Unparseable URL → passthrough. + */ +type DesktopNavigationKind = + | "same_host_page" + | "same_host_asset" + | "cloud_cross_host_auth" + | "cloud_cross_host_asset" + | "external_cross_host" + | "invalid_url"; + +/** + * Classifies a navigation URL so the desktop app can decide how to handle it. + * Single source of truth used by both setWindowOpenHandler and will-navigate. + * + * @param instanceUrl Full URL of the configured Plane instance (e.g. "https://app.plane.so"). + * @param targetUrl The URL being navigated to. + */ +function classifyDesktopNavigation(instanceUrl: string, targetUrl: string): DesktopNavigationKind { + try { + const instance = new URL(instanceUrl); + const target = new URL(targetUrl); + const isSameHost = target.hostname === instance.hostname; + const isCloudPair = instance.hostname === CLOUD_APP_HOST && target.hostname === CLOUD_API_HOST; + const isAsset = isAssetPath(targetUrl); + const isAuth = target.pathname.startsWith("/auth/"); + + if (isSameHost) { + return isAsset ? "same_host_asset" : "same_host_page"; + } + + if (isCloudPair) { + // Auth paths take priority: /auth/ navigation must stay in-app even if the path + // has a file-like suffix (unlikely, but explicit ordering makes intent clear). + if (isAuth) { + return "cloud_cross_host_auth"; + } + if (isAsset) { + return "cloud_cross_host_asset"; + } + } + + return "external_cross_host"; + } catch { + return "invalid_url"; + } +}