From e3a0ff73ec35b8f9a262481384e8c61b6c685153 Mon Sep 17 00:00:00 2001 From: James Brunton Date: Wed, 17 Dec 2025 17:01:03 +0000 Subject: [PATCH] Initial SSO self hosted desktop --- .../software/SPDF/SPDFApplication.java | 42 ++- .../web/ReactRoutingController.java | 229 ++++++++++++---- frontend/package-lock.json | 67 ++--- frontend/package.json | 1 + .../SetupWizard/DesktopOAuthButtons.tsx | 49 +++- .../SetupWizard/SaaSLoginScreen.tsx | 6 +- .../SetupWizard/SelfHostedLoginScreen.tsx | 8 +- .../SetupWizard/ServerSelection.tsx | 20 +- .../desktop/components/SetupWizard/index.tsx | 22 ++ frontend/src/desktop/services/authService.ts | 248 +++++++++++++++++- .../desktop/services/connectionModeService.ts | 8 +- .../src/proprietary/routes/AuthCallback.tsx | 40 +++ 12 files changed, 622 insertions(+), 118 deletions(-) diff --git a/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java b/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java index 9cb4a786a..de8d69797 100644 --- a/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java +++ b/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java @@ -141,10 +141,10 @@ public class SPDFApplication { String backendUrl = appConfig.getBackendUrl(); String contextPath = appConfig.getContextPath(); String serverPort = appConfig.getServerPort(); - baseUrlStatic = backendUrl; + baseUrlStatic = normalizeBackendUrl(backendUrl, serverPort); contextPathStatic = contextPath; serverPortStatic = serverPort; - String url = backendUrl + ":" + getStaticPort() + contextPath; + String url = buildFullUrl(baseUrlStatic, getStaticPort(), contextPathStatic); // Log Tauri mode information if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) { @@ -210,7 +210,7 @@ public class SPDFApplication { private static void printStartupLogs() { log.info("Stirling-PDF Started."); - String url = baseUrlStatic + ":" + getStaticPort() + contextPathStatic; + String url = buildFullUrl(baseUrlStatic, getStaticPort(), contextPathStatic); log.info("Navigate to {}", url); } @@ -258,4 +258,40 @@ public class SPDFApplication { public static String getStaticContextPath() { return contextPathStatic; } + + private static String buildFullUrl(String backendUrl, String port, String contextPath) { + String normalizedBase = normalizeBackendUrl(backendUrl, port); + + String normalizedContextPath = + (contextPath == null || contextPath.isBlank() || "/".equals(contextPath)) + ? "/" + : (contextPath.startsWith("/") ? contextPath : "/" + contextPath); + + return normalizedBase + normalizedContextPath; + } + + private static String normalizeBackendUrl(String backendUrl, String port) { + String trimmedBase = + (backendUrl == null || backendUrl.isBlank()) + ? "http://localhost" + : backendUrl.trim().replaceAll("/+$", ""); + + try { + java.net.URI uri = new java.net.URI(trimmedBase); + boolean hasPort = uri.getPort() != -1; + boolean defaultHttp = "http".equalsIgnoreCase(uri.getScheme()) && "80".equals(port); + boolean defaultHttps = "https".equalsIgnoreCase(uri.getScheme()) && "443".equals(port); + + if (hasPort || defaultHttp || defaultHttps) { + return trimmedBase; + } + } catch (java.net.URISyntaxException e) { + // If parsing fails, fall back to a simple suffix check + if (trimmedBase.matches(".+:\\d+$")) { + return trimmedBase; + } + } + + return trimmedBase + ":" + port; + } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java index eddff0e1e..20f004eea 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/ReactRoutingController.java @@ -19,20 +19,22 @@ import org.springframework.web.bind.annotation.GetMapping; import jakarta.annotation.PostConstruct; import jakarta.servlet.http.HttpServletRequest; -import lombok.extern.slf4j.Slf4j; - import stirling.software.common.configuration.InstallationPathConfig; -@Slf4j @Controller public class ReactRoutingController { + private static final org.slf4j.Logger log = + org.slf4j.LoggerFactory.getLogger(ReactRoutingController.class); + @Value("${server.servlet.context-path:/}") private String contextPath; private String cachedIndexHtml; + private String cachedCallbackHtml; private boolean indexHtmlExists = false; private boolean useExternalIndexHtml = false; + private boolean loggedMissingIndex = false; @PostConstruct public void init() { @@ -43,56 +45,72 @@ public class ReactRoutingController { log.debug("Checking for custom index.html at: {}", externalIndexPath); if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) { log.info("Using custom index.html from: {}", externalIndexPath); - try { - this.cachedIndexHtml = processIndexHtml(); - this.indexHtmlExists = true; - this.useExternalIndexHtml = true; - return; - } catch (IOException e) { - log.warn("Failed to load custom index.html, falling back to classpath", e); - } + this.cachedIndexHtml = processIndexHtml(); + this.indexHtmlExists = true; + this.useExternalIndexHtml = true; + return; } // Fall back to classpath index.html ClassPathResource resource = new ClassPathResource("static/index.html"); if (resource.exists()) { - try { - this.cachedIndexHtml = processIndexHtml(); - this.indexHtmlExists = true; - this.useExternalIndexHtml = false; - } catch (IOException e) { - // Failed to cache, will process on each request - log.warn("Failed to cache index.html", e); - this.indexHtmlExists = false; + this.cachedIndexHtml = processIndexHtml(); + this.indexHtmlExists = true; + this.useExternalIndexHtml = false; + return; + } + + // Neither external nor classpath index.html exists - cache fallback once + this.cachedIndexHtml = buildFallbackHtml(); + this.cachedCallbackHtml = buildCallbackHtml(); + this.indexHtmlExists = true; + this.useExternalIndexHtml = false; + this.loggedMissingIndex = true; + log.warn( + "index.html not found in classpath or custom path; using lightweight fallback page"); + } + + private String processIndexHtml() { + try { + Resource resource = getIndexHtmlResource(); + + if (!resource.exists()) { + if (!loggedMissingIndex) { + log.warn("index.html not found, using lightweight fallback page"); + loggedMissingIndex = true; + } + return buildFallbackHtml(); } + + try (InputStream inputStream = resource.getInputStream()) { + String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + + // Replace %BASE_URL% with the actual context path for base href + String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/"; + html = html.replace("%BASE_URL%", baseUrl); + // Also rewrite any existing tag (Vite may have baked one in) + html = + html.replaceFirst( + "", + ""); + + // Inject context path as a global variable for API calls + String contextPathScript = + ""; + html = html.replace("", contextPathScript + ""); + + return html; + } + } catch (Exception ex) { + if (!loggedMissingIndex) { + log.warn("index.html not found, using lightweight fallback page", ex); + loggedMissingIndex = true; + } + return buildFallbackHtml(); } } - private String processIndexHtml() throws IOException { - Resource resource = getIndexHtmlResource(); - - try (InputStream inputStream = resource.getInputStream()) { - String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); - - // Replace %BASE_URL% with the actual context path for base href - String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/"; - html = html.replace("%BASE_URL%", baseUrl); - // Also rewrite any existing tag (Vite may have baked one in) - html = - html.replaceFirst( - "", - ""); - - // Inject context path as a global variable for API calls - String contextPathScript = - ""; - html = html.replace("", contextPathScript + ""); - - return html; - } - } - - private Resource getIndexHtmlResource() throws IOException { + private Resource getIndexHtmlResource() { // Check external location first Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html"); if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) { @@ -106,12 +124,25 @@ public class ReactRoutingController { @GetMapping( value = {"/", "/index.html"}, produces = MediaType.TEXT_HTML_VALUE) - public ResponseEntity serveIndexHtml(HttpServletRequest request) throws IOException { - if (indexHtmlExists && cachedIndexHtml != null) { - return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedIndexHtml); + public ResponseEntity serveIndexHtml(HttpServletRequest request) { + try { + if (indexHtmlExists && cachedIndexHtml != null) { + return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedIndexHtml); + } + // Fallback: process on each request (dev mode or cache failed) + return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(processIndexHtml()); + } catch (Exception ex) { + log.error("Failed to serve index.html, returning fallback", ex); + return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(buildFallbackHtml()); } - // Fallback: process on each request (dev mode or cache failed) - return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(processIndexHtml()); + } + + @GetMapping(value = "/auth/callback", produces = MediaType.TEXT_HTML_VALUE) + public ResponseEntity serveAuthCallback() { + if (cachedCallbackHtml == null) { + cachedCallbackHtml = buildCallbackHtml(); + } + return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedCallbackHtml); } @GetMapping( @@ -126,4 +157,104 @@ public class ReactRoutingController { throws IOException { return serveIndexHtml(request); } + + private String buildFallbackHtml() { + String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/"; + String serverUrl = "(window.location.origin + '" + baseUrl + "')"; + return """ + + + + + + Stirling PDF + + + +

Stirling PDF is running.

+ + + """ + .formatted(baseUrl, baseUrl, serverUrl); + } + + private String buildCallbackHtml() { + String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/"; + String serverUrl = "(window.location.origin + '" + baseUrl + "')"; + return """ + + + + + + Authentication Complete + + + +

Authentication complete. You can close this window.

+ + + """ + .formatted(baseUrl, serverUrl); + } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f788474bb..9ba5c8b72 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -49,6 +49,7 @@ "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-fs": "^2.4.0", "@tauri-apps/plugin-http": "^2.5.4", + "@tauri-apps/plugin-shell": "^2.3.3", "autoprefixer": "^10.4.21", "axios": "^1.12.2", "globals": "^16.4.0", @@ -457,7 +458,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -501,7 +501,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -582,7 +581,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.5.0.tgz", "integrity": "sha512-Yrh9XoVaT8cUgzgqpJ7hx5wg6BqQrCFirqqlSwVb+Ly9oNn4fZbR9GycIWmzJOU5XBnaOJjXfQSaDyoNP0woNA==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/engines": "1.5.0", "@embedpdf/models": "1.5.0" @@ -682,7 +680,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.5.0.tgz", "integrity": "sha512-p7PTNNaIr4gH3jLwX+eLJe1DeUXgi21kVGN6SRx/pocH8esg4jqoOeD/YiRRZoZnPOiy0jBXVhkPkwSmY7a2hQ==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.5.0" }, @@ -699,7 +696,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.5.0.tgz", "integrity": "sha512-ckHgTfvkW6c5Ta7Mc+Dl9C2foVnvEpqEJ84wyBnqrU0OWbe/jsiPhyKBVeartMGqNI/kVfaQTXupyrKhekAVmg==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.5.0" }, @@ -717,7 +713,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.5.0.tgz", "integrity": "sha512-P4YpIZfaW69etYIjphyaL4cGl2pB14h3OdTE0tRQ2pZYZHFLTvlt4q9B3PVSdhlSrHK5nob7jfLGon2U7xCslg==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.5.0" }, @@ -771,7 +766,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.5.0.tgz", "integrity": "sha512-ywwSj0ByrlkvrJIHKRzqxARkOZriki8VJUC+T4MV8fGyF4CzvCRJyKlPktahFz+VxhoodqTh7lBCib68dH+GvA==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.5.0" }, @@ -806,7 +800,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.5.0.tgz", "integrity": "sha512-RNmTZCZ8X1mA8cw9M7TMDuhO9GtkOalGha2bBL3En3D1IlDRS7PzNNMSMV7eqT7OQICSTltlpJ8p8Qi5esvL/Q==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.5.0" }, @@ -843,7 +836,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.5.0.tgz", "integrity": "sha512-zrxLBAZQoPswDuf9q9DrYaQc6B0Ysc2U1hueTjNH/4+ydfl0BFXZkKR63C2e3YmWtXvKjkoIj0GyPzsiBORLUw==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.5.0" }, @@ -919,7 +911,6 @@ "resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.5.0.tgz", "integrity": "sha512-G8GDyYRhfehw72+r4qKkydnA5+AU8qH67g01Y12b0DzI0VIzymh/05Z4dK8DsY3jyWPXJfw2hlg5+KDHaMBHgQ==", "license": "MIT", - "peer": true, "dependencies": { "@embedpdf/models": "1.5.0" }, @@ -1075,7 +1066,6 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -1119,7 +1109,6 @@ "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -2150,7 +2139,6 @@ "resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.6.tgz", "integrity": "sha512-paTl+0x+O/QtgMtqVJaG8maD8sfiOdgPmLOyG485FmeGZ1L3KMdEkhxZtmdGlDFsLXhmMGQ57ducT90bvhXX5A==", "license": "MIT", - "peer": true, "dependencies": { "@floating-ui/react": "^0.27.16", "clsx": "^2.1.1", @@ -2201,7 +2189,6 @@ "resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.6.tgz", "integrity": "sha512-liHfaWXHAkLjJy+Bkr29UsCwAoDQ/a64WrM67lksx8F0qqyjR5RQH8zVlhuOjdpQnwtlUkE/YiTvbJiPcoI0bw==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "^18.x || ^19.x" } @@ -2269,7 +2256,6 @@ "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz", "integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4", "@mui/core-downloads-tracker": "^7.3.5", @@ -3202,7 +3188,6 @@ "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz", "integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=12.16" } @@ -3321,6 +3306,7 @@ "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz", "integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==", "license": "MIT", + "peer": true, "peerDependencies": { "acorn": "^8.9.0" } @@ -4091,13 +4077,21 @@ "@tauri-apps/api": "^2.8.0" } }, + "node_modules/@tauri-apps/plugin-shell": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.3.tgz", + "integrity": "sha512-Xod+pRcFxmOWFWEnqH5yZcA7qwAMuaaDkMR1Sply+F8VfBj++CGnj2xf5UoialmjZ2Cvd8qrvSCbU+7GgNVsKQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4426,7 +4420,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4437,7 +4430,6 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4507,7 +4499,6 @@ "integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.3", "@typescript-eslint/types": "8.46.3", @@ -5221,6 +5212,7 @@ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz", "integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==", "license": "MIT", + "peer": true, "dependencies": { "@vue/shared": "3.5.24" } @@ -5230,6 +5222,7 @@ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz", "integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==", "license": "MIT", + "peer": true, "dependencies": { "@vue/reactivity": "3.5.24", "@vue/shared": "3.5.24" @@ -5240,6 +5233,7 @@ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz", "integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==", "license": "MIT", + "peer": true, "dependencies": { "@vue/reactivity": "3.5.24", "@vue/runtime-core": "3.5.24", @@ -5252,6 +5246,7 @@ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz", "integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==", "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-ssr": "3.5.24", "@vue/shared": "3.5.24" @@ -5278,7 +5273,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5686,6 +5680,7 @@ "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">= 0.4" } @@ -5962,7 +5957,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -7010,8 +7004,7 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz", "integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/dezalgo": { "version": "1.0.4", @@ -7406,7 +7399,6 @@ "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7577,7 +7569,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7744,7 +7735,8 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/espree": { "version": "10.4.0", @@ -7809,6 +7801,7 @@ "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.2.tgz", "integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } @@ -8899,7 +8892,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -9376,6 +9368,7 @@ "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.6" } @@ -9696,7 +9689,6 @@ "integrity": "sha512-Pcfm3eZ+eO4JdZCXthW9tCDT3nF4K+9dmeZ+5X39n+Kqz0DDIABRP5CAEOHRFZk8RGuC2efksTJxrjp8EXCunQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@acemir/cssom": "^0.9.19", "@asamuzakjp/dom-selector": "^6.7.3", @@ -10283,7 +10275,8 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/locate-path": { "version": "6.0.0", @@ -11442,7 +11435,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -11722,7 +11714,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz", "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -12105,7 +12096,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -12115,7 +12105,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -13627,6 +13616,7 @@ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">= 0.4" } @@ -13835,7 +13825,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -14137,7 +14126,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14219,7 +14207,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -14424,7 +14411,6 @@ "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -14595,7 +14581,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -14609,7 +14594,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -15221,7 +15205,8 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/zod": { "version": "3.25.76", diff --git a/frontend/package.json b/frontend/package.json index 914b7bb1f..e94494198 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -45,6 +45,7 @@ "@tauri-apps/api": "^2.5.0", "@tauri-apps/plugin-fs": "^2.4.0", "@tauri-apps/plugin-http": "^2.5.4", + "@tauri-apps/plugin-shell": "^2.3.3", "autoprefixer": "^10.4.21", "axios": "^1.12.2", "globals": "^16.4.0", diff --git a/frontend/src/desktop/components/SetupWizard/DesktopOAuthButtons.tsx b/frontend/src/desktop/components/SetupWizard/DesktopOAuthButtons.tsx index 49b55a386..97761783b 100644 --- a/frontend/src/desktop/components/SetupWizard/DesktopOAuthButtons.tsx +++ b/frontend/src/desktop/components/SetupWizard/DesktopOAuthButtons.tsx @@ -3,16 +3,24 @@ import { useTranslation } from 'react-i18next'; import { authService, UserInfo } from '@app/services/authService'; import { buildOAuthCallbackHtml } from '@app/utils/oauthCallbackHtml'; import { BASE_PATH } from '@app/constants/app'; +import { STIRLING_SAAS_URL } from '@desktop/constants/connection'; import '@app/routes/authShared/auth.css'; -export type OAuthProvider = 'google' | 'github' | 'keycloak' | 'azure' | 'apple' | 'oidc'; +export type OAuthProviderId = 'google' | 'github' | 'keycloak' | 'azure' | 'apple' | 'oidc' | string; + +export interface DesktopSSOProvider { + id: OAuthProviderId; + path?: string; + label?: string; +} interface DesktopOAuthButtonsProps { onOAuthSuccess: (userInfo: UserInfo) => Promise; onError: (error: string) => void; isDisabled: boolean; serverUrl: string; - providers: OAuthProvider[]; + providers: DesktopSSOProvider[]; + mode?: 'saas' | 'selfHosted'; } export const DesktopOAuthButtons: React.FC = ({ @@ -21,11 +29,12 @@ export const DesktopOAuthButtons: React.FC = ({ isDisabled, serverUrl, providers, + mode = 'saas', }) => { const { t } = useTranslation(); const [oauthLoading, setOauthLoading] = useState(false); - const handleOAuthLogin = async (provider: OAuthProvider) => { + const handleOAuthLogin = async (provider: DesktopSSOProvider) => { // Prevent concurrent OAuth attempts if (oauthLoading || isDisabled) { return; @@ -48,7 +57,12 @@ export const DesktopOAuthButtons: React.FC = ({ errorPlaceholder: true, // {error} will be replaced by Rust }); - const userInfo = await authService.loginWithOAuth(provider, serverUrl, successHtml, errorHtml); + const normalizedServer = serverUrl.replace(/\/+$/, ''); + const usingSupabaseFlow = + mode === 'saas' || normalizedServer === STIRLING_SAAS_URL.replace(/\/+$/, ''); + const userInfo = usingSupabaseFlow + ? await authService.loginWithOAuth(provider.id, serverUrl, successHtml, errorHtml) + : await authService.loginWithSelfHostedOAuth(provider.path || provider.id, serverUrl); // Call the onOAuthSuccess callback to complete setup await onOAuthSuccess(userInfo); @@ -64,7 +78,7 @@ export const DesktopOAuthButtons: React.FC = ({ } }; - const providerConfig: Record = { + const providerConfig: Record = { google: { label: 'Google', file: 'google.svg' }, github: { label: 'GitHub', file: 'github.svg' }, keycloak: { label: 'Keycloak', file: 'keycloak.svg' }, @@ -72,6 +86,7 @@ export const DesktopOAuthButtons: React.FC = ({ apple: { label: 'Apple', file: 'apple.svg' }, oidc: { label: 'OpenID', file: 'oidc.svg' }, }; + const GENERIC_PROVIDER_ICON = 'oidc.svg'; if (providers.length === 0) { return null; @@ -80,23 +95,29 @@ export const DesktopOAuthButtons: React.FC = ({ return (
{providers - .filter((providerId) => providerId in providerConfig) - .map((providerId) => { - const provider = providerConfig[providerId]; + .filter((providerConfigEntry) => providerConfigEntry && providerConfigEntry.id) + .map((providerEntry) => { + const iconConfig = providerConfig[providerEntry.id]; + const label = + providerEntry.label || + iconConfig?.label || + (providerEntry.id + ? providerEntry.id.charAt(0).toUpperCase() + providerEntry.id.slice(1) + : t('setup.login.sso', 'Single Sign-On')); return ( ); })} diff --git a/frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx b/frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx index 6acd28858..644e5fa98 100644 --- a/frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx +++ b/frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx @@ -66,7 +66,11 @@ export const SaaSLoginScreen: React.FC = ({ onError={handleOAuthError} isDisabled={loading} serverUrl={serverUrl} - providers={['google', 'github']} + mode="saas" + providers={[ + { id: 'google' }, + { id: 'github' }, + ]} /> Promise; onOAuthSuccess: (userInfo: UserInfo) => Promise; loading: boolean; @@ -74,7 +75,8 @@ export const SelfHostedLoginScreen: React.FC = ({ onError={handleOAuthError} isDisabled={loading} serverUrl={serverUrl} - providers={enabledOAuthProviders as OAuthProvider[]} + mode="selfHosted" + providers={enabledOAuthProviders} /> = ({ onSelect, load } // Fetch OAuth providers and check if login is enabled - let enabledProviders: string[] = []; + const enabledProviders: SSOProviderConfig[] = []; try { const response = await fetch(`${url}/api/v1/proprietary/ui-data/login`); @@ -76,9 +76,19 @@ export const ServerSelection: React.FC = ({ onSelect, load // Extract provider IDs from authorization URLs // Example: "/oauth2/authorization/google" → "google" - enabledProviders = Object.keys(data.providerList || {}) - .map(key => key.split('/').pop()) - .filter((id): id is string => id !== undefined); + const providerEntries = Object.entries(data.providerList || {}); + providerEntries.forEach(([path, label]) => { + const id = path.split('/').pop(); + if (!id) { + return; + } + + enabledProviders.push({ + id, + path, + label: typeof label === 'string' ? label : undefined, + }); + }); console.log('[ServerSelection] Detected OAuth providers:', enabledProviders); } catch (err) { diff --git a/frontend/src/desktop/components/SetupWizard/index.tsx b/frontend/src/desktop/components/SetupWizard/index.tsx index 9fbd0e6cc..b6e18a6c7 100644 --- a/frontend/src/desktop/components/SetupWizard/index.tsx +++ b/frontend/src/desktop/components/SetupWizard/index.tsx @@ -155,6 +155,28 @@ export const SetupWizard: React.FC = ({ onComplete }) => { const params = new URLSearchParams(hash); const accessToken = params.get('access_token'); const type = params.get('type') || parsed.searchParams.get('type'); + const accessTokenFromHash = params.get('access_token'); + const accessTokenFromQuery = parsed.searchParams.get('access_token'); + const serverFromQuery = parsed.searchParams.get('server'); + + // Handle self-hosted SSO deep link + if (type === 'sso' || type === 'sso-selfhosted') { + const token = accessTokenFromHash || accessTokenFromQuery; + const serverUrl = serverFromQuery || serverConfig?.url || STIRLING_SAAS_URL; + if (!token || !serverUrl) { + console.error('[SetupWizard] Deep link missing token or server for SSO completion'); + return; + } + + setLoading(true); + setError(null); + + await authService.completeSelfHostedSession(serverUrl, token); + await connectionModeService.switchToSelfHosted({ url: serverUrl }); + await tauriBackendService.initializeExternalBackend(); + onComplete(); + return; + } if (!type || (type !== 'signup' && type !== 'recovery' && type !== 'magiclink')) { return; diff --git a/frontend/src/desktop/services/authService.ts b/frontend/src/desktop/services/authService.ts index 710518f36..11d408e61 100644 --- a/frontend/src/desktop/services/authService.ts +++ b/frontend/src/desktop/services/authService.ts @@ -1,4 +1,6 @@ -import { invoke } from '@tauri-apps/api/core'; +import { invoke, isTauri } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; +import { open as shellOpen } from '@tauri-apps/plugin-shell'; import axios from 'axios'; import { DESKTOP_DEEP_LINK_CALLBACK, STIRLING_SAAS_URL, SUPABASE_KEY } from '@app/constants/connection'; @@ -436,6 +438,250 @@ export class AuthService { } } + /** + * Self-hosted SSO/OAuth2 flow for the desktop app. + * Opens a popup to the server's auth endpoint and listens for the AuthCallback page + * to postMessage the JWT back to the main window. + */ + async loginWithSelfHostedOAuth(providerPath: string, serverUrl: string): Promise { + const trimmedServer = serverUrl.replace(/\/+$/, ''); + const fullUrl = providerPath.startsWith('http') + ? providerPath + : `${trimmedServer}${providerPath.startsWith('/') ? providerPath : `/${providerPath}`}`; + + // Ensure backend redirects back to /auth/callback + try { + document.cookie = `stirling_redirect_path=${encodeURIComponent('/auth/callback')}; path=/; max-age=300; SameSite=Lax`; + } catch { + // ignore cookie errors + } + + // Force a real popup so the main webview stays on the app + const authWindow = window.open(fullUrl, '_blank', 'width=900,height=900'); + + // Fallback: use Tauri shell.open and wait for deep link back + if (!authWindow) { + if (await this.openInSystemBrowser(fullUrl)) { + return this.waitForDeepLinkCompletion(trimmedServer); + } + throw new Error('Unable to open browser window for SSO. Please allow pop-ups and try again.'); + } + + const expectedOrigin = new URL(fullUrl).origin; + + // Always also listen for deep link completion in case the opener messaging path fails + const deepLinkPromise = this.waitForDeepLinkCompletion(trimmedServer).catch(() => null); + + return new Promise((resolve, reject) => { + let completed = false; + + const cleanup = () => { + window.removeEventListener('message', handleMessage); + clearInterval(windowCheck); + clearInterval(localTokenCheck); + clearTimeout(timeoutId); + }; + + const handleMessage = async (event: MessageEvent) => { + if (event.origin !== expectedOrigin) { + return; + } + + const data = event.data as { type?: string; token?: string; access_token?: string }; + if (!data || data.type !== 'stirling-desktop-sso') { + return; + } + + const token = data.token || data.access_token; + if (!token) { + cleanup(); + reject(new Error('No token returned from SSO')); + return; + } + + completed = true; + cleanup(); + + try { + const userInfo = await this.completeSelfHostedSession(trimmedServer, token); + try { + authWindow.close(); + } catch (closeError) { + console.warn('Could not close auth window:', closeError); + } + resolve(userInfo); + } catch (err) { + reject(err instanceof Error ? err : new Error('Failed to complete login')); + } + }; + + // If deep link finishes first, resolve + deepLinkPromise.then(async (dlResult) => { + if (completed || !dlResult) return; + completed = true; + cleanup(); + resolve(dlResult); + }).catch(() => { + // ignore deep link errors here + }); + + window.addEventListener('message', handleMessage); + + const windowCheck = window.setInterval(() => { + if (authWindow.closed && !completed) { + cleanup(); + reject(new Error('Authentication window was closed before completion')); + } + }, 500); + + const localTokenCheck = window.setInterval(async () => { + if (completed) { + clearInterval(localTokenCheck); + return; + } + const token = localStorage.getItem('stirling_jwt'); + if (token) { + completed = true; + cleanup(); + try { + const userInfo = await this.completeSelfHostedSession(trimmedServer, token); + try { + authWindow.close(); + } catch (_) { + // ignore close errors + } + resolve(userInfo); + } catch (err) { + reject(err instanceof Error ? err : new Error('Failed to complete login')); + } + } + }, 1000); + + const timeoutId = window.setTimeout(() => { + if (!completed) { + cleanup(); + try { + authWindow.close(); + } catch { + // ignore close errors + } + reject(new Error('SSO login timed out. Please try again.')); + } + }, 120_000); + }); + } + + /** + * Wait for a deep-link event to complete self-hosted SSO (used when popup cannot open) + */ + private async waitForDeepLinkCompletion(serverUrl: string): Promise { + if (!isTauri()) { + throw new Error('Unable to open browser window for SSO. Please allow pop-ups and try again.'); + } + + return new Promise((resolve, reject) => { + let completed = false; + let unlisten: (() => void) | null = null; + + const timeoutId = window.setTimeout(() => { + if (!completed) { + if (unlisten) unlisten(); + reject(new Error('SSO login timed out. Please try again.')); + } + }, 120_000); + + listen('deep-link', async (event) => { + const url = event.payload; + if (!url || completed) return; + try { + const parsed = new URL(url); + const hash = parsed.hash.replace(/^#/, ''); + const params = new URLSearchParams(hash); + const type = params.get('type') || parsed.searchParams.get('type'); + if (type !== 'sso' && type !== 'sso-selfhosted') { + return; + } + const token = params.get('access_token') || parsed.searchParams.get('access_token'); + if (!token) { + return; + } + + completed = true; + if (unlisten) unlisten(); + clearTimeout(timeoutId); + + const userInfo = await this.completeSelfHostedSession(serverUrl, token); + resolve(userInfo); + } catch (err) { + completed = true; + if (unlisten) unlisten(); + clearTimeout(timeoutId); + reject(err instanceof Error ? err : new Error('Failed to complete SSO')); + } + }).then((fn) => { + unlisten = fn; + }); + }); + } + + private async openInSystemBrowser(url: string): Promise { + if (!isTauri()) { + return false; + } + try { + // Prefer plugin-shell (2.x) if available + await shellOpen(url); + return true; + } catch (err) { + console.error('Failed to open system browser for SSO:', err); + return false; + } + } + + /** + * Save JWT + user info for self-hosted SSO logins + */ + async completeSelfHostedSession(serverUrl: string, token: string): Promise { + await this.saveTokenEverywhere(token); + + const userInfo = await this.fetchSelfHostedUserInfo(serverUrl, token); + + await invoke('save_user_info', { + username: userInfo.username, + email: userInfo.email || null, + }); + + this.setAuthStatus('authenticated', userInfo); + return userInfo; + } + + private async fetchSelfHostedUserInfo(serverUrl: string, token: string): Promise { + try { + const response = await axios.get( + `${serverUrl.replace(/\/+$/, '')}/api/v1/auth/me`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + + const data = response.data; + const user = data.user || data; + + return { + username: user.username || user.email || 'User', + email: user.email || undefined, + }; + } catch (error) { + console.error('[Desktop AuthService] Failed to fetch user info after SSO:', error); + return { + username: 'User', + email: undefined, + }; + } + } + /** * Fetch user info from Supabase using access token */ diff --git a/frontend/src/desktop/services/connectionModeService.ts b/frontend/src/desktop/services/connectionModeService.ts index cf454a50b..c4db4d641 100644 --- a/frontend/src/desktop/services/connectionModeService.ts +++ b/frontend/src/desktop/services/connectionModeService.ts @@ -3,9 +3,15 @@ import { fetch } from '@tauri-apps/plugin-http'; export type ConnectionMode = 'saas' | 'selfhosted'; +export interface SSOProviderConfig { + id: string; + path: string; + label?: string; +} + export interface ServerConfig { url: string; - enabledOAuthProviders?: string[]; + enabledOAuthProviders?: SSOProviderConfig[]; } export interface ConnectionConfig { diff --git a/frontend/src/proprietary/routes/AuthCallback.tsx b/frontend/src/proprietary/routes/AuthCallback.tsx index 488a54146..676c4f0e4 100644 --- a/frontend/src/proprietary/routes/AuthCallback.tsx +++ b/frontend/src/proprietary/routes/AuthCallback.tsx @@ -1,6 +1,8 @@ import { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { springAuth } from '@app/auth/springAuthClient'; +import { connectionModeService } from '@app/services/connectionModeService'; +import { tauriBackendService } from '@app/services/tauriBackendService'; /** * OAuth Callback Handler @@ -52,6 +54,44 @@ export default function AuthCallback() { return; } + // Notify desktop popup listeners (self-hosted SSO flow) + const isDesktopPopup = typeof window !== 'undefined' && window.opener && window.name === 'stirling-desktop-sso'; + if (isDesktopPopup) { + try { + window.opener.postMessage( + { type: 'stirling-desktop-sso', token }, + '*' + ); + } catch (postError) { + console.error('[AuthCallback] Failed to notify desktop window:', postError); + } + + // Give the message a moment to flush before attempting to close + setTimeout(() => { + try { + window.close(); + } catch (_) { + // ignore close errors + } + }, 150); + } + + // Desktop fallback flow (when popup was blocked and we navigated directly) + try { + const pending = localStorage.getItem('desktop_self_hosted_sso_pending'); + const hasTauri = typeof window !== 'undefined' && (window as any).__TAURI_INTERNALS__; + if (pending && hasTauri) { + const parsed = JSON.parse(pending) as { serverUrl?: string } | null; + if (parsed?.serverUrl) { + await connectionModeService.switchToSelfHosted({ url: parsed.serverUrl }); + await tauriBackendService.initializeExternalBackend(); + } + localStorage.removeItem('desktop_self_hosted_sso_pending'); + } + } catch (desktopError) { + console.error('[AuthCallback] Desktop fallback completion failed:', desktopError); + } + console.log('[AuthCallback] Token validated, redirecting to home'); // Clear the hash from URL and redirect to home page