Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78df38b600 | ||
|
|
4ec75d4d8c | ||
|
|
63550e0c7d | ||
|
|
a2091b7851 | ||
|
|
93ed05b054 | ||
|
|
195b1472e4 | ||
|
|
340006ceea | ||
|
|
43137cde83 | ||
|
|
9ea2c94912 | ||
|
|
ad33679c2b | ||
|
|
c390f21114 | ||
|
|
b03ecef121 | ||
|
|
3d9d2621fa | ||
|
|
9c84e1eed4 | ||
|
|
e424af1a31 | ||
|
|
71d416ce90 | ||
|
|
91cdd20b3f | ||
|
|
235e68fa2b | ||
|
|
3279b28a76 | ||
|
|
d95abcea95 | ||
|
|
a73636a597 |
@@ -37,10 +37,6 @@ public class AppConfig {
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Getter
|
||||
@Value("${baseUrl:http://localhost}")
|
||||
private String baseUrl;
|
||||
|
||||
@Getter
|
||||
@Value("${server.servlet.context-path:/}")
|
||||
private String contextPath;
|
||||
@@ -49,6 +45,17 @@ public class AppConfig {
|
||||
@Value("${server.port:8080}")
|
||||
private String serverPort;
|
||||
|
||||
/**
|
||||
* Get the backend URL from system configuration. Falls back to http://localhost if not
|
||||
* configured.
|
||||
*
|
||||
* @return The backend base URL for SAML/OAuth/API callbacks
|
||||
*/
|
||||
public String getBackendUrl() {
|
||||
String backendUrl = applicationProperties.getSystem().getBackendUrl();
|
||||
return (backendUrl != null && !backendUrl.isBlank()) ? backendUrl : "http://localhost";
|
||||
}
|
||||
|
||||
@Value("${v2}")
|
||||
public boolean v2Enabled;
|
||||
|
||||
|
||||
@@ -138,13 +138,13 @@ public class SPDFApplication {
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
String baseUrl = appConfig.getBaseUrl();
|
||||
String backendUrl = appConfig.getBackendUrl();
|
||||
String contextPath = appConfig.getContextPath();
|
||||
String serverPort = appConfig.getServerPort();
|
||||
baseUrlStatic = baseUrl;
|
||||
baseUrlStatic = backendUrl;
|
||||
contextPathStatic = contextPath;
|
||||
serverPortStatic = serverPort;
|
||||
String url = baseUrl + ":" + getStaticPort() + contextPath;
|
||||
String url = backendUrl + ":" + getStaticPort() + contextPath;
|
||||
|
||||
// Log Tauri mode information
|
||||
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
|
||||
|
||||
@@ -33,14 +33,35 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
// Cache hashed assets (JS/CSS with content hashes) for 1 year
|
||||
// These files have names like index-ChAS4tCC.js that change when content changes
|
||||
// Check customFiles/static first, then fall back to classpath
|
||||
registry.addResourceHandler("/assets/**")
|
||||
.addResourceLocations("classpath:/static/assets/")
|
||||
.addResourceLocations(
|
||||
"file:"
|
||||
+ stirling.software.common.configuration.InstallationPathConfig
|
||||
.getStaticPath()
|
||||
+ "assets/",
|
||||
"classpath:/static/assets/")
|
||||
.setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic());
|
||||
|
||||
// Don't cache index.html - it needs to be fresh to reference latest hashed assets
|
||||
// Note: index.html is handled by ReactRoutingController for dynamic processing
|
||||
registry.addResourceHandler("/index.html")
|
||||
.addResourceLocations("classpath:/static/")
|
||||
.addResourceLocations(
|
||||
"file:"
|
||||
+ stirling.software.common.configuration.InstallationPathConfig
|
||||
.getStaticPath(),
|
||||
"classpath:/static/")
|
||||
.setCacheControl(CacheControl.noCache().mustRevalidate());
|
||||
|
||||
// Handle all other static resources (js, css, images, fonts, etc.)
|
||||
// Check customFiles/static first for user overrides
|
||||
registry.addResourceHandler("/**")
|
||||
.addResourceLocations(
|
||||
"file:"
|
||||
+ stirling.software.common.configuration.InstallationPathConfig
|
||||
.getStaticPath(),
|
||||
"classpath:/static/")
|
||||
.setCacheControl(CacheControl.maxAge(1, TimeUnit.HOURS));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+2
-1
@@ -66,7 +66,8 @@ public class ConfigController {
|
||||
AppConfig appConfig = applicationContext.getBean(AppConfig.class);
|
||||
|
||||
// Extract key configuration values from AppConfig
|
||||
configData.put("baseUrl", appConfig.getBaseUrl());
|
||||
// Note: Frontend expects "baseUrl" field name for compatibility
|
||||
configData.put("baseUrl", appConfig.getBackendUrl());
|
||||
configData.put("contextPath", appConfig.getContextPath());
|
||||
configData.put("serverPort", appConfig.getServerPort());
|
||||
|
||||
|
||||
+43
-2
@@ -3,9 +3,14 @@ package stirling.software.SPDF.controller.web;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
@@ -14,6 +19,11 @@ 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 {
|
||||
|
||||
@@ -22,24 +32,44 @@ public class ReactRoutingController {
|
||||
|
||||
private String cachedIndexHtml;
|
||||
private boolean indexHtmlExists = false;
|
||||
private boolean useExternalIndexHtml = false;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
// Only cache if index.html exists (production builds)
|
||||
log.info("Static files custom path: {}", InstallationPathConfig.getStaticPath());
|
||||
|
||||
// Check for external index.html first (customFiles/static/)
|
||||
Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String processIndexHtml() throws IOException {
|
||||
ClassPathResource resource = new ClassPathResource("static/index.html");
|
||||
Resource resource = getIndexHtmlResource();
|
||||
|
||||
try (InputStream inputStream = resource.getInputStream()) {
|
||||
String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
@@ -62,6 +92,17 @@ public class ReactRoutingController {
|
||||
}
|
||||
}
|
||||
|
||||
private Resource getIndexHtmlResource() throws IOException {
|
||||
// Check external location first
|
||||
Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html");
|
||||
if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) {
|
||||
return new FileSystemResource(externalIndexPath.toFile());
|
||||
}
|
||||
|
||||
// Fall back to classpath
|
||||
return new ClassPathResource("static/index.html");
|
||||
}
|
||||
|
||||
@GetMapping(
|
||||
value = {"/", "/index.html"},
|
||||
produces = MediaType.TEXT_HTML_VALUE)
|
||||
|
||||
+1
-1
@@ -198,7 +198,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
|
||||
private SamlClient getSamlClient(
|
||||
String registrationId, SAML2 samlConf, List<X509Certificate> certificates)
|
||||
throws SamlException {
|
||||
String serverUrl = appConfig.getBaseUrl() + ":" + appConfig.getServerPort();
|
||||
String serverUrl = appConfig.getBackendUrl() + ":" + appConfig.getServerPort();
|
||||
|
||||
String relyingPartyIdentifier =
|
||||
serverUrl + "/saml2/service-provider-metadata/" + registrationId;
|
||||
|
||||
+2
-1
@@ -344,7 +344,8 @@ public class SecurityConfiguration {
|
||||
log.error("Error configuring SAML 2 login", e);
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
});
|
||||
})
|
||||
.saml2Metadata(metadata -> {});
|
||||
}
|
||||
} else {
|
||||
log.debug("Login is not enabled.");
|
||||
|
||||
+20
-5
@@ -102,16 +102,31 @@ public class Saml2Configuration {
|
||||
log.error("Failed to load SAML2 SP credentials: {}", e.getMessage(), e);
|
||||
throw new IllegalStateException("Failed to load SAML2 SP credentials", e);
|
||||
}
|
||||
|
||||
// Get backend URL from configuration (for SAML endpoints)
|
||||
String backendUrl = applicationProperties.getSystem().getBackendUrl();
|
||||
if (backendUrl == null || backendUrl.isBlank()) {
|
||||
backendUrl = "{baseUrl}"; // Fallback to Spring's auto-resolution
|
||||
log.warn(
|
||||
"system.backendUrl not configured - SAML metadata will use request-based URLs. Set system.backendUrl for production use.");
|
||||
} else {
|
||||
log.info("Using configured backend URL for SAML: {}", backendUrl);
|
||||
}
|
||||
|
||||
String entityId =
|
||||
backendUrl + "/saml2/service-provider-metadata/" + samlConf.getRegistrationId();
|
||||
String acsLocation = backendUrl + "/login/saml2/sso/{registrationId}";
|
||||
String sloResponseLocation = backendUrl + "/login";
|
||||
|
||||
RelyingPartyRegistration rp =
|
||||
RelyingPartyRegistration.withRegistrationId(samlConf.getRegistrationId())
|
||||
.signingX509Credentials(c -> c.add(signingCredential))
|
||||
.entityId(samlConf.getIdpIssuer())
|
||||
.entityId(entityId)
|
||||
.singleLogoutServiceBinding(Saml2MessageBinding.POST)
|
||||
.singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl())
|
||||
.singleLogoutServiceResponseLocation("{baseUrl}/login")
|
||||
.singleLogoutServiceResponseLocation(sloResponseLocation)
|
||||
.assertionConsumerServiceBinding(Saml2MessageBinding.POST)
|
||||
.assertionConsumerServiceLocation(
|
||||
"{baseUrl}/login/saml2/sso/{registrationId}")
|
||||
.assertionConsumerServiceLocation(acsLocation)
|
||||
.authnRequestsSigned(true)
|
||||
.assertingPartyMetadata(
|
||||
metadata ->
|
||||
@@ -127,7 +142,7 @@ public class Saml2Configuration {
|
||||
.singleLogoutServiceLocation(
|
||||
samlConf.getIdpSingleLogoutUrl())
|
||||
.singleLogoutServiceResponseLocation(
|
||||
"{baseUrl}/login")
|
||||
sloResponseLocation)
|
||||
.wantAuthnRequestsSigned(true))
|
||||
.build();
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Stirling PDF - Authentication</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 2rem; color: #111; }
|
||||
.status { padding: 1rem; border-radius: 8px; border: 1px solid #e5e7eb; background: #f9fafb; }
|
||||
.error { border-color: #fca5a5; background: #fef2f2; color: #b91c1c; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="message" class="status">Completing authentication...</div>
|
||||
<script>
|
||||
(function () {
|
||||
const msgEl = document.getElementById('message');
|
||||
|
||||
function setStatus(text, isError) {
|
||||
msgEl.textContent = text;
|
||||
if (isError) {
|
||||
msgEl.classList.add('error');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const hash = window.location.hash.startsWith('#') ? window.location.hash.substring(1) : window.location.hash;
|
||||
const params = new URLSearchParams(hash);
|
||||
const token = params.get('access_token');
|
||||
const error = params.get('error_description') || params.get('error');
|
||||
|
||||
// Handle license errors that come via query instead of hash
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const licenseError = urlParams.get('errorOAuth') || urlParams.get('oAuth2RequiresLicense');
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
localStorage.setItem('stirling_jwt', token);
|
||||
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||
} catch (e) {
|
||||
console.warn('Failed to persist token in localStorage', e);
|
||||
}
|
||||
|
||||
if (window.opener) {
|
||||
window.opener.postMessage({ type: 'stirling-sso-success', token }, '*');
|
||||
}
|
||||
|
||||
setStatus('Authentication complete. Returning to app...', false);
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 300);
|
||||
return;
|
||||
}
|
||||
|
||||
if (error || licenseError) {
|
||||
const message = error || 'OAuth/SSO requires a paid license. Please contact your administrator.';
|
||||
if (window.opener) {
|
||||
window.opener.postMessage({ type: 'stirling-sso-error', error: message }, '*');
|
||||
}
|
||||
setStatus(message, true);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('No authentication token received.', true);
|
||||
} catch (e) {
|
||||
console.error('Auth callback handling error:', e);
|
||||
setStatus('Authentication failed. Please try again.', true);
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+93
@@ -589,6 +589,26 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
|
||||
dependencies = [
|
||||
"const-random-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-random-macro"
|
||||
version = "0.1.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
|
||||
dependencies = [
|
||||
"getrandom 0.2.16",
|
||||
"once_cell",
|
||||
"tiny-keccak",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
@@ -716,6 +736,12 @@ version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.6"
|
||||
@@ -908,6 +934,15 @@ dependencies = [
|
||||
"syn 2.0.108",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dlv-list"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
|
||||
dependencies = [
|
||||
"const-random",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
@@ -1626,6 +1661,12 @@ dependencies = [
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.0"
|
||||
@@ -2820,6 +2861,16 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-multimap"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
|
||||
dependencies = [
|
||||
"dlv-list",
|
||||
"hashbrown 0.14.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
@@ -3651,6 +3702,16 @@ dependencies = [
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust-ini"
|
||||
version = "0.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"ordered-multimap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust_decimal"
|
||||
version = "1.39.0"
|
||||
@@ -4256,6 +4317,7 @@ dependencies = [
|
||||
"sha2",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-log",
|
||||
@@ -4615,6 +4677,27 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-deep-link"
|
||||
version = "2.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e82759f7c7d51de3cbde51c04b3f2332de52436ed84541182cd8944b04e9e73"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"plist",
|
||||
"rust-ini",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.17",
|
||||
"tracing",
|
||||
"url",
|
||||
"windows-registry",
|
||||
"windows-result 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.4.4"
|
||||
@@ -4735,6 +4818,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin-deep-link",
|
||||
"thiserror 2.0.17",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
@@ -4954,6 +5038,15 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-keccak"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
|
||||
dependencies = [
|
||||
"crunchy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny_http"
|
||||
version = "0.12.0"
|
||||
|
||||
@@ -29,9 +29,10 @@ tauri-plugin-log = "2.0.0-rc"
|
||||
tauri-plugin-shell = "2.1.0"
|
||||
tauri-plugin-fs = "2.4.4"
|
||||
tauri-plugin-http = "2.4.4"
|
||||
tauri-plugin-single-instance = "2.0.1"
|
||||
tauri-plugin-single-instance = { version = "2.3.6", features = ["deep-link"] }
|
||||
tauri-plugin-store = "2.1.0"
|
||||
tauri-plugin-opener = "2.0.0"
|
||||
tauri-plugin-deep-link = "2.4.5"
|
||||
keyring = { version = "3.6.1", features = ["apple-native", "windows-native"] }
|
||||
tokio = { version = "1.0", features = ["time", "sync"] }
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
{
|
||||
"identifier": "fs:allow-read-file",
|
||||
"allow": [{ "path": "**" }]
|
||||
}
|
||||
},
|
||||
"opener:default",
|
||||
"shell:allow-open"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"identifier": "main",
|
||||
"description": "Main window capability with remote access for self-hosted SSO.",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"http:default",
|
||||
"http:allow-fetch",
|
||||
"http:allow-fetch-send",
|
||||
"http:allow-fetch-read-body",
|
||||
"opener:default",
|
||||
"opener:allow-open-url",
|
||||
"core:webview:allow-internal-toggle-devtools",
|
||||
"core:webview:allow-create-webview-window"
|
||||
],
|
||||
"remote": {
|
||||
"urls": [
|
||||
"http://localhost/*",
|
||||
"http://localhost:*/*",
|
||||
"http://127.0.0.1/*",
|
||||
"http://127.0.0.1:*/*",
|
||||
"http://*/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use tauri::{Manager, RunEvent, WindowEvent, Emitter};
|
||||
use tauri::{AppHandle, Emitter, Manager, RunEvent, WindowEvent};
|
||||
|
||||
mod utils;
|
||||
mod commands;
|
||||
@@ -28,6 +28,17 @@ use commands::{
|
||||
};
|
||||
use state::connection_state::AppConnectionState;
|
||||
use utils::{add_log, get_tauri_logs};
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
|
||||
fn dispatch_deep_link(app: &AppHandle, url: &str) {
|
||||
add_log(format!("🔗 Dispatching deep link: {}", url));
|
||||
let _ = app.emit("deep-link", url.to_string());
|
||||
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_focus();
|
||||
let _ = window.unminimize();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
@@ -42,6 +53,7 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.manage(AppConnectionState::default())
|
||||
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
|
||||
// This callback runs when a second instance tries to start
|
||||
@@ -78,6 +90,29 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let app_handle = app.handle();
|
||||
// On macOS the plugin registers schemes via bundle metadata, so runtime registration is required only on Windows/Linux
|
||||
#[cfg(any(target_os = "linux", target_os = "windows"))]
|
||||
if let Err(err) = app_handle.deep_link().register_all() {
|
||||
add_log(format!("⚠️ Failed to register deep link handler: {}", err));
|
||||
}
|
||||
|
||||
if let Ok(Some(urls)) = app_handle.deep_link().get_current() {
|
||||
let initial_handle = app_handle.clone();
|
||||
for url in urls {
|
||||
dispatch_deep_link(&initial_handle, url.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
let event_app_handle = app_handle.clone();
|
||||
app_handle.deep_link().on_open_url(move |event| {
|
||||
for url in event.urls() {
|
||||
dispatch_deep_link(&event_app_handle, url.as_str());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Start backend immediately, non-blocking
|
||||
let app_handle = app.handle().clone();
|
||||
|
||||
|
||||
@@ -18,10 +18,7 @@
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"capabilities": ["main"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
@@ -80,6 +77,13 @@
|
||||
},
|
||||
"fs": {
|
||||
"requireLiteralLeadingDot": false
|
||||
},
|
||||
"deep-link": {
|
||||
"desktop": {
|
||||
"schemes": [
|
||||
"stirlingpdf"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import AppsIcon from '@mui/icons-material/AppsRounded';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext';
|
||||
@@ -11,13 +10,11 @@ import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccess
|
||||
interface AllToolsNavButtonProps {
|
||||
activeButton: string;
|
||||
setActiveButton: (id: string) => void;
|
||||
tooltipPosition?: 'left' | 'right' | 'top' | 'bottom';
|
||||
}
|
||||
|
||||
const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
|
||||
activeButton,
|
||||
setActiveButton,
|
||||
tooltipPosition = 'right'
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow();
|
||||
@@ -55,26 +52,18 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={t("quickAccess.allTools", "Tools")}
|
||||
position={tooltipPosition}
|
||||
arrow
|
||||
containerStyle={{ marginTop: "-1rem" }}
|
||||
maxWidth={200}
|
||||
>
|
||||
<div className="mt-4 mb-2">
|
||||
<QuickAccessButton
|
||||
icon={<AppsIcon sx={{ fontSize: isActive ? '1.875rem' : '1.5rem' }} />}
|
||||
label={t("quickAccess.allTools", "Tools")}
|
||||
isActive={isActive}
|
||||
onClick={handleNavClick}
|
||||
href={navProps.href}
|
||||
ariaLabel={t("quickAccess.allTools", "Tools")}
|
||||
textClassName="all-tools-text"
|
||||
component="a"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div className="mt-4 mb-2">
|
||||
<QuickAccessButton
|
||||
icon={<AppsIcon sx={{ fontSize: isActive ? '1.875rem' : '1.5rem' }} />}
|
||||
label={t("quickAccess.allTools", "Tools")}
|
||||
isActive={isActive}
|
||||
onClick={handleNavClick}
|
||||
href={navProps.href}
|
||||
ariaLabel={t("quickAccess.allTools", "Tools")}
|
||||
textClassName="all-tools-text"
|
||||
component="a"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { ActionIcon, Popover } from '@mantine/core';
|
||||
import React from 'react';
|
||||
import { ActionIcon } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import { ViewerContext } from '@app/contexts/ViewerContext';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
import { ColorSwatchButton, ColorPicker } from '@app/components/annotation/shared/ColorPicker';
|
||||
import { useFileState, useFileContext } from '@app/contexts/FileContext';
|
||||
import { generateThumbnailWithMetadata } from '@app/utils/thumbnailUtils';
|
||||
import { createProcessedFile } from '@app/contexts/file/fileActions';
|
||||
import { createStirlingFile, createNewStirlingFileStub } from '@app/types/fileContext';
|
||||
import { useNavigationState } from '@app/contexts/NavigationContext';
|
||||
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
||||
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
|
||||
@@ -23,32 +17,14 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
||||
const { t } = useTranslation();
|
||||
const { sidebarRefs } = useSidebarContext();
|
||||
const { position: tooltipPosition, offset: tooltipOffset } = useRightRailTooltipSide(sidebarRefs);
|
||||
const [selectedColor, setSelectedColor] = useState('#000000');
|
||||
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
|
||||
const [isHoverColorPickerOpen, setIsHoverColorPickerOpen] = useState(false);
|
||||
|
||||
// Viewer context for PDF controls - safely handle when not available
|
||||
const viewerContext = React.useContext(ViewerContext);
|
||||
|
||||
// Signature context for accessing drawing API
|
||||
const { signatureApiRef, isPlacementMode } = useSignature();
|
||||
|
||||
// File state for save functionality
|
||||
const { state, selectors } = useFileState();
|
||||
const { actions: fileActions } = useFileContext();
|
||||
const activeFiles = selectors.getFiles();
|
||||
|
||||
// Check if we're in sign mode
|
||||
const { selectedTool } = useNavigationState();
|
||||
const isSignMode = selectedTool === 'sign';
|
||||
|
||||
// Turn off annotation mode when switching away from viewer
|
||||
useEffect(() => {
|
||||
if (currentView !== 'viewer' && viewerContext?.isAnnotationMode) {
|
||||
viewerContext.setAnnotationMode(false);
|
||||
}
|
||||
}, [currentView, viewerContext]);
|
||||
|
||||
// Don't show any annotation controls in sign mode
|
||||
if (isSignMode) {
|
||||
return null;
|
||||
@@ -65,7 +41,7 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
||||
onClick={() => {
|
||||
viewerContext?.toggleAnnotationsVisibility();
|
||||
}}
|
||||
disabled={disabled || currentView !== 'viewer' || viewerContext?.isAnnotationMode || isPlacementMode}
|
||||
disabled={disabled || currentView !== 'viewer'}
|
||||
>
|
||||
<LocalIcon
|
||||
icon={viewerContext?.isAnnotationsVisible ? "visibility" : "visibility-off-rounded"}
|
||||
@@ -74,164 +50,6 @@ export default function ViewerAnnotationControls({ currentView, disabled = false
|
||||
/>
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Annotation Mode Toggle with Drawing Controls */}
|
||||
{viewerContext?.isAnnotationMode ? (
|
||||
// When active: Show color picker on hover
|
||||
<div
|
||||
onMouseEnter={() => setIsHoverColorPickerOpen(true)}
|
||||
onMouseLeave={() => setIsHoverColorPickerOpen(false)}
|
||||
style={{ display: 'inline-flex' }}
|
||||
>
|
||||
<Popover
|
||||
opened={isHoverColorPickerOpen}
|
||||
onClose={() => setIsHoverColorPickerOpen(false)}
|
||||
position="left"
|
||||
withArrow
|
||||
shadow="md"
|
||||
offset={8}
|
||||
>
|
||||
<Popover.Target>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="blue"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.toggleAnnotationMode();
|
||||
setIsHoverColorPickerOpen(false); // Close hover color picker when toggling off
|
||||
// Deactivate drawing tool when exiting annotation mode
|
||||
if (signatureApiRef?.current) {
|
||||
try {
|
||||
signatureApiRef.current.deactivateTools();
|
||||
} catch (error) {
|
||||
console.log('Signature API not ready:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label="Drawing mode active"
|
||||
>
|
||||
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<div style={{ minWidth: '8rem' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '0.5rem', padding: '0.5rem' }}>
|
||||
<div style={{ fontSize: '0.8rem', fontWeight: 500 }}>Drawing Color</div>
|
||||
<ColorSwatchButton
|
||||
color={selectedColor}
|
||||
size={32}
|
||||
onClick={() => {
|
||||
setIsHoverColorPickerOpen(false); // Close hover picker
|
||||
setIsColorPickerOpen(true); // Open main color picker modal
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</div>
|
||||
) : (
|
||||
// When inactive: Show "Draw" tooltip
|
||||
<Tooltip content={t('rightRail.draw', 'Draw')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.toggleAnnotationMode();
|
||||
// Activate ink drawing tool when entering annotation mode
|
||||
if (signatureApiRef?.current && currentView === 'viewer') {
|
||||
try {
|
||||
signatureApiRef.current.activateDrawMode();
|
||||
signatureApiRef.current.updateDrawSettings(selectedColor, 2);
|
||||
} catch (error) {
|
||||
console.log('Signature API not ready:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
aria-label={typeof t === 'function' ? t('rightRail.draw', 'Draw') : 'Draw'}
|
||||
>
|
||||
<LocalIcon icon="edit" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Save PDF with Annotations */}
|
||||
<Tooltip content={t('rightRail.save', 'Save')} position={tooltipPosition} offset={tooltipOffset} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={async () => {
|
||||
if (viewerContext?.exportActions?.saveAsCopy && currentView === 'viewer') {
|
||||
try {
|
||||
const pdfArrayBuffer = await viewerContext.exportActions.saveAsCopy();
|
||||
if (pdfArrayBuffer) {
|
||||
// Create new File object with flattened annotations
|
||||
const blob = new Blob([pdfArrayBuffer], { type: 'application/pdf' });
|
||||
|
||||
// Get the original file name or use a default
|
||||
const originalFileName = activeFiles.length > 0 ? activeFiles[0].name : 'document.pdf';
|
||||
const newFile = new File([blob], originalFileName, { type: 'application/pdf' });
|
||||
|
||||
// Replace the current file in context with the saved version (exact same logic as Sign tool)
|
||||
if (activeFiles.length > 0) {
|
||||
// Generate thumbnail and metadata for the saved file
|
||||
const thumbnailResult = await generateThumbnailWithMetadata(newFile);
|
||||
const processedFileMetadata = createProcessedFile(thumbnailResult.pageCount, thumbnailResult.thumbnail);
|
||||
|
||||
// Get current file info
|
||||
const currentFileIds = state.files.ids;
|
||||
if (currentFileIds.length > 0) {
|
||||
const currentFileId = currentFileIds[0];
|
||||
const currentRecord = selectors.getStirlingFileStub(currentFileId);
|
||||
|
||||
if (!currentRecord) {
|
||||
console.error('No file record found for:', currentFileId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create output stub and file (exact same as Sign tool)
|
||||
const outputStub = createNewStirlingFileStub(newFile, undefined, thumbnailResult.thumbnail, processedFileMetadata);
|
||||
const outputStirlingFile = createStirlingFile(newFile, outputStub.id);
|
||||
|
||||
// Replace the original file with the saved version
|
||||
await fileActions.consumeFiles([currentFileId], [outputStirlingFile], [outputStub]);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving PDF:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LocalIcon icon="save" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Color Picker Modal */}
|
||||
<ColorPicker
|
||||
isOpen={isColorPickerOpen}
|
||||
onClose={() => setIsColorPickerOpen(false)}
|
||||
selectedColor={selectedColor}
|
||||
onColorChange={(color) => {
|
||||
setSelectedColor(color);
|
||||
// Update drawing tool color if annotation mode is active
|
||||
if (viewerContext?.isAnnotationMode && signatureApiRef?.current && currentView === 'viewer') {
|
||||
try {
|
||||
signatureApiRef.current.updateDrawSettings(color, 2);
|
||||
} catch (error) {
|
||||
console.log('Unable to update drawing settings:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
title="Choose Drawing Color"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ const EmbedPdfViewerContent = ({
|
||||
getScrollState,
|
||||
getRotationState,
|
||||
isAnnotationMode,
|
||||
setAnnotationMode,
|
||||
isAnnotationsVisible,
|
||||
exportActions,
|
||||
} = useViewer();
|
||||
@@ -82,15 +83,18 @@ const EmbedPdfViewerContent = ({
|
||||
// Navigation guard for unsaved changes
|
||||
const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = useNavigationGuard();
|
||||
|
||||
// Check if we're in signature mode OR viewer annotation mode
|
||||
// Check if we're in an annotation tool
|
||||
const { selectedTool } = useNavigationState();
|
||||
// Tools that use the stamp/signature placement system with hover preview
|
||||
const isSignatureMode = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage';
|
||||
// Tools that require the annotation layer (Sign, Add Text, Add Image)
|
||||
const isInAnnotationTool = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage';
|
||||
|
||||
// Sync isAnnotationMode in ViewerContext with current tool
|
||||
useEffect(() => {
|
||||
setAnnotationMode(isInAnnotationTool);
|
||||
}, [isInAnnotationTool, setAnnotationMode]);
|
||||
|
||||
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
|
||||
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
|
||||
const isPlacementOverlayActive = Boolean(
|
||||
isSignatureMode && shouldEnableAnnotations && isPlacementMode && signatureConfig
|
||||
isInAnnotationTool && isPlacementMode && signatureConfig
|
||||
);
|
||||
|
||||
// Track which file tab is active
|
||||
@@ -333,7 +337,8 @@ const EmbedPdfViewerContent = ({
|
||||
key={currentFile && isStirlingFile(currentFile) ? currentFile.fileId : (effectiveFile.file instanceof File ? effectiveFile.file.name : effectiveFile.url)}
|
||||
file={effectiveFile.file}
|
||||
url={effectiveFile.url}
|
||||
enableAnnotations={shouldEnableAnnotations}
|
||||
enableAnnotations={isAnnotationMode}
|
||||
showBakedAnnotations={isAnnotationsVisible}
|
||||
signatureApiRef={signatureApiRef as React.RefObject<any>}
|
||||
historyApiRef={historyApiRef as React.RefObject<any>}
|
||||
onSignatureAdded={() => {
|
||||
|
||||
@@ -52,12 +52,13 @@ interface LocalEmbedPDFProps {
|
||||
file?: File | Blob;
|
||||
url?: string | null;
|
||||
enableAnnotations?: boolean;
|
||||
showBakedAnnotations?: boolean;
|
||||
onSignatureAdded?: (annotation: any) => void;
|
||||
signatureApiRef?: React.RefObject<SignatureAPI>;
|
||||
historyApiRef?: React.RefObject<HistoryAPI>;
|
||||
}
|
||||
|
||||
export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) {
|
||||
export function LocalEmbedPDF({ file, url, enableAnnotations = false, showBakedAnnotations = true, onSignatureAdded, signatureApiRef, historyApiRef }: LocalEmbedPDFProps) {
|
||||
const { t } = useTranslation();
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const [, setAnnotations] = useState<Array<{id: string, pageIndex: number, rect: any}>>([]);
|
||||
@@ -100,7 +101,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
|
||||
}),
|
||||
createPluginRegistration(RenderPluginPackage, {
|
||||
withForms: true,
|
||||
withAnnotations: true,
|
||||
withAnnotations: showBakedAnnotations && !enableAnnotations, // Show baked annotations only when: visibility is ON and annotation layer is OFF
|
||||
}),
|
||||
|
||||
// Register interaction manager (required for zoom and selection features)
|
||||
@@ -166,7 +167,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
|
||||
// Register print plugin for printing PDFs
|
||||
createPluginRegistration(PrintPluginPackage),
|
||||
];
|
||||
}, [pdfUrl]);
|
||||
}, [pdfUrl, enableAnnotations, showBakedAnnotations]);
|
||||
|
||||
// Initialize the engine with the React hook - use local WASM for offline support
|
||||
const { engine, isLoading, error } = usePdfiumEngine({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { Button, Paper, Group, NumberInput } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import FirstPageIcon from '@mui/icons-material/FirstPage';
|
||||
import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos';
|
||||
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
|
||||
@@ -209,21 +210,27 @@ export function PdfViewerToolbar({
|
||||
</Button>
|
||||
|
||||
{/* Dual Page Toggle */}
|
||||
<Button
|
||||
variant={isDualPageActive ? "filled" : "light"}
|
||||
color="blue"
|
||||
size="md"
|
||||
radius="xl"
|
||||
onClick={handleDualPageToggle}
|
||||
style={{ minWidth: '2.5rem' }}
|
||||
title={
|
||||
<Tooltip
|
||||
content={
|
||||
isDualPageActive
|
||||
? t("viewer.singlePageView", "Single Page View")
|
||||
: t("viewer.dualPageView", "Dual Page View")
|
||||
}
|
||||
position="top"
|
||||
arrow
|
||||
>
|
||||
{isDualPageActive ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant={isDualPageActive ? "filled" : "light"}
|
||||
color="blue"
|
||||
size="md"
|
||||
radius="xl"
|
||||
onClick={handleDualPageToggle}
|
||||
disabled={scrollState.totalPages <= 1}
|
||||
style={{ minWidth: '2.5rem' }}
|
||||
>
|
||||
{isDualPageActive ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<Group gap={4} align="center" style={{ marginLeft: 16 }}>
|
||||
|
||||
@@ -95,7 +95,6 @@ interface ViewerContextType {
|
||||
// Annotation/drawing mode for viewer
|
||||
isAnnotationMode: boolean;
|
||||
setAnnotationMode: (enabled: boolean) => void;
|
||||
toggleAnnotationMode: () => void;
|
||||
|
||||
// Active file index for multi-file viewing
|
||||
activeFileIndex: number;
|
||||
@@ -230,10 +229,6 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
setIsAnnotationModeState(enabled);
|
||||
};
|
||||
|
||||
const toggleAnnotationMode = () => {
|
||||
setIsAnnotationModeState(prev => !prev);
|
||||
};
|
||||
|
||||
// State getters - read from bridge refs
|
||||
const getScrollState = (): ScrollState => {
|
||||
return bridgeRefs.current.scroll?.state || { currentPage: 1, totalPages: 0 };
|
||||
@@ -318,7 +313,6 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
toggleAnnotationsVisibility,
|
||||
isAnnotationMode,
|
||||
setAnnotationMode,
|
||||
toggleAnnotationMode,
|
||||
|
||||
// Active file index
|
||||
activeFileIndex,
|
||||
|
||||
@@ -7,24 +7,12 @@ import '@app/routes/authShared/auth.css';
|
||||
|
||||
export type OAuthProvider = 'google' | 'github' | 'keycloak' | 'azure' | 'apple' | 'oidc';
|
||||
|
||||
export interface DesktopOAuthProvider {
|
||||
id: string;
|
||||
label?: string;
|
||||
file?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface DesktopOAuthButtonsProps {
|
||||
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
|
||||
onError: (error: string) => void;
|
||||
isDisabled: boolean;
|
||||
serverUrl: string;
|
||||
providers: DesktopOAuthProvider[];
|
||||
/**
|
||||
* Optional override to handle provider click (used for self-hosted SSO flow).
|
||||
* When provided, the caller is responsible for invoking onOAuthSuccess/onError.
|
||||
*/
|
||||
onProviderClick?: (provider: DesktopOAuthProvider) => Promise<void>;
|
||||
providers: OAuthProvider[];
|
||||
}
|
||||
|
||||
export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
|
||||
@@ -33,7 +21,6 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
|
||||
isDisabled,
|
||||
serverUrl,
|
||||
providers,
|
||||
onProviderClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [oauthLoading, setOauthLoading] = useState(false);
|
||||
@@ -77,25 +64,7 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomClick = async (provider: DesktopOAuthProvider) => {
|
||||
if (!onProviderClick) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setOauthLoading(true);
|
||||
await onProviderClick(provider);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error
|
||||
? error.message
|
||||
: t('setup.login.error.oauthFailed', 'OAuth login failed. Please try again.');
|
||||
onError(errorMessage);
|
||||
} finally {
|
||||
setOauthLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const providerConfig: Record<string, { label: string; file: string }> = {
|
||||
const providerConfig: Record<OAuthProvider, { label: string; file: string }> = {
|
||||
google: { label: 'Google', file: 'google.svg' },
|
||||
github: { label: 'GitHub', file: 'github.svg' },
|
||||
keycloak: { label: 'Keycloak', file: 'keycloak.svg' },
|
||||
@@ -111,31 +80,19 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
|
||||
return (
|
||||
<div className="oauth-container-vertical">
|
||||
{providers
|
||||
.map((providerMeta) => {
|
||||
const provider =
|
||||
(providerMeta.id in providerConfig
|
||||
? providerConfig[providerMeta.id as OAuthProvider]
|
||||
: {
|
||||
label: providerMeta.label || providerMeta.id,
|
||||
file: providerMeta.file || 'oidc.svg',
|
||||
});
|
||||
|
||||
.filter((providerId) => providerId in providerConfig)
|
||||
.map((providerId) => {
|
||||
const provider = providerConfig[providerId];
|
||||
return (
|
||||
<button
|
||||
key={providerMeta.id}
|
||||
onClick={() => {
|
||||
if (onProviderClick) {
|
||||
void handleCustomClick(providerMeta);
|
||||
} else {
|
||||
void handleOAuthLogin(providerMeta.id as OAuthProvider);
|
||||
}
|
||||
}}
|
||||
key={providerId}
|
||||
onClick={() => handleOAuthLogin(providerId)}
|
||||
disabled={isDisabled || oauthLoading}
|
||||
className="oauth-button-vertical"
|
||||
title={provider.label}
|
||||
>
|
||||
<img
|
||||
src={`${BASE_PATH}/Login/${provider.file || 'oidc.svg'}`}
|
||||
src={`${BASE_PATH}/Login/${provider.file}`}
|
||||
alt={provider.label}
|
||||
className="oauth-icon-tiny"
|
||||
/>
|
||||
|
||||
@@ -14,6 +14,7 @@ interface SaaSLoginScreenProps {
|
||||
onLogin: (username: string, password: string) => Promise<void>;
|
||||
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
|
||||
onSelfHostedClick: () => void;
|
||||
onSwitchToSignup: () => void;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
@@ -23,6 +24,7 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
|
||||
onLogin,
|
||||
onOAuthSuccess,
|
||||
onSelfHostedClick,
|
||||
onSwitchToSignup,
|
||||
loading,
|
||||
error,
|
||||
}) => {
|
||||
@@ -64,10 +66,7 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
|
||||
onError={handleOAuthError}
|
||||
isDisabled={loading}
|
||||
serverUrl={serverUrl}
|
||||
providers={[
|
||||
{ id: 'google' },
|
||||
{ id: 'github' },
|
||||
]}
|
||||
providers={['google', 'github']}
|
||||
/>
|
||||
|
||||
<DividerWithText
|
||||
@@ -92,6 +91,20 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
|
||||
submitButtonText={t('setup.login.submit', 'Login')}
|
||||
/>
|
||||
|
||||
<div className="navigation-link-container" style={{ marginTop: '0.5rem', textAlign: 'right' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setValidationError(null);
|
||||
onSwitchToSignup();
|
||||
}}
|
||||
className="navigation-link-button"
|
||||
disabled={loading}
|
||||
>
|
||||
{t('signup.signUp', 'Sign Up')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SelfHostedLink onClick={onSelfHostedClick} disabled={loading} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LoginHeader from '@app/routes/login/LoginHeader';
|
||||
import ErrorMessage from '@app/routes/login/ErrorMessage';
|
||||
import SignupForm from '@app/routes/signup/SignupForm';
|
||||
import { useSignupFormValidation, SignupFieldErrors } from '@app/routes/signup/SignupFormValidation';
|
||||
import { authService } from '@app/services/authService';
|
||||
import '@app/routes/authShared/auth.css';
|
||||
|
||||
interface SaaSSignupScreenProps {
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onLogin: (username: string, password: string) => Promise<void>;
|
||||
onSwitchToLogin: () => void;
|
||||
}
|
||||
|
||||
export const SaaSSignupScreen: React.FC<SaaSSignupScreenProps> = ({
|
||||
loading,
|
||||
error,
|
||||
onLogin: _onLogin,
|
||||
onSwitchToLogin: _onSwitchToLogin,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [signupFieldErrors, setSignupFieldErrors] = useState<SignupFieldErrors>({});
|
||||
const [signupSuccessMessage, setSignupSuccessMessage] = useState<string | null>(null);
|
||||
const [isSignupSubmitting, setIsSignupSubmitting] = useState(false);
|
||||
const { validateSignupForm } = useSignupFormValidation();
|
||||
|
||||
const displayError = error || validationError;
|
||||
|
||||
const handleSignupSubmit = async () => {
|
||||
setValidationError(null);
|
||||
setSignupSuccessMessage(null);
|
||||
setSignupFieldErrors({});
|
||||
|
||||
const validation = validateSignupForm(email, password, confirmPassword);
|
||||
if (!validation.isValid) {
|
||||
setValidationError(validation.error);
|
||||
setSignupFieldErrors(validation.fieldErrors || {});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSignupSubmitting(true);
|
||||
await authService.signUpSaas(email.trim(), password);
|
||||
setSignupSuccessMessage(t('signup.checkEmailConfirmation', 'Check your email for a confirmation link to complete your registration.'));
|
||||
setSignupFieldErrors({});
|
||||
setValidationError(null);
|
||||
} catch (err) {
|
||||
setSignupSuccessMessage(null);
|
||||
const message = err instanceof Error ? err.message : t('signup.unexpectedError', { message: 'Unknown error' });
|
||||
setValidationError(message);
|
||||
} finally {
|
||||
setIsSignupSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoginHeader
|
||||
title={t('signup.title', 'Create an account')}
|
||||
subtitle={t('signup.subtitle', 'Join Stirling PDF')}
|
||||
/>
|
||||
|
||||
<ErrorMessage error={displayError} />
|
||||
{signupSuccessMessage && (
|
||||
<div className="success-message">
|
||||
<p className="success-message-text">{signupSuccessMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SignupForm
|
||||
email={email}
|
||||
password={password}
|
||||
confirmPassword={confirmPassword}
|
||||
setEmail={(value) => {
|
||||
setEmail(value);
|
||||
setValidationError(null);
|
||||
setSignupFieldErrors({});
|
||||
}}
|
||||
setPassword={(value) => {
|
||||
setPassword(value);
|
||||
setValidationError(null);
|
||||
setSignupFieldErrors({});
|
||||
}}
|
||||
setConfirmPassword={(value) => {
|
||||
setConfirmPassword(value);
|
||||
setValidationError(null);
|
||||
setSignupFieldErrors({});
|
||||
}}
|
||||
onSubmit={handleSignupSubmit}
|
||||
isSubmitting={loading || isSignupSubmitting}
|
||||
fieldErrors={signupFieldErrors}
|
||||
showName={false}
|
||||
showTerms={false}
|
||||
/>
|
||||
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,17 +1,17 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Text } from '@mantine/core';
|
||||
import LoginHeader from '@app/routes/login/LoginHeader';
|
||||
import ErrorMessage from '@app/routes/login/ErrorMessage';
|
||||
import EmailPasswordForm from '@app/routes/login/EmailPasswordForm';
|
||||
import DividerWithText from '@app/components/shared/DividerWithText';
|
||||
import { DesktopOAuthButtons, DesktopOAuthProvider } from '@app/components/SetupWizard/DesktopOAuthButtons';
|
||||
import { authService, UserInfo } from '@app/services/authService';
|
||||
import { DesktopOAuthButtons, OAuthProvider } from '@app/components/SetupWizard/DesktopOAuthButtons';
|
||||
import { UserInfo } from '@app/services/authService';
|
||||
import '@app/routes/authShared/auth.css';
|
||||
|
||||
interface SelfHostedLoginScreenProps {
|
||||
serverUrl: string;
|
||||
enabledOAuthProviders?: DesktopOAuthProvider[];
|
||||
enabledOAuthProviders?: string[];
|
||||
onLogin: (username: string, password: string) => Promise<void>;
|
||||
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
|
||||
loading: boolean;
|
||||
@@ -51,124 +51,6 @@ export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
|
||||
setValidationError(errorMessage);
|
||||
};
|
||||
|
||||
const pollForSession = (timeoutMs = 60000): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const start = Date.now();
|
||||
const interval = window.setInterval(async () => {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
window.clearInterval(interval);
|
||||
reject(new Error('Timed out waiting for authentication'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const hasToken = localStorage.getItem('stirling_jwt');
|
||||
if (hasToken) {
|
||||
window.clearInterval(interval);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const isAuthed = await authService.isAuthenticated();
|
||||
if (isAuthed) {
|
||||
window.clearInterval(interval);
|
||||
resolve();
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore transient errors and keep polling
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
};
|
||||
|
||||
const waitForSsoCompletion = (popup: Window, expectedOrigin?: string): Promise<string> => {
|
||||
// Accept messages from the backend origin (e.g., http://localhost:8080) since the wizard runs under the Tauri origin.
|
||||
const allowedOrigin = (() => {
|
||||
try {
|
||||
return expectedOrigin ? new URL(expectedOrigin).origin : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const messageHandler = (event: MessageEvent) => {
|
||||
if (typeof event.data !== 'object' || event.data === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we can compute the backend origin, ensure the message comes from there.
|
||||
if (allowedOrigin && event.origin !== allowedOrigin) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, token, error } = event.data as { type?: string; token?: string; error?: string };
|
||||
if (type === 'stirling-sso-success' && token) {
|
||||
cleanup();
|
||||
resolve(token);
|
||||
} else if (type === 'stirling-sso-error') {
|
||||
cleanup();
|
||||
reject(new Error(error || 'SSO login failed'));
|
||||
}
|
||||
};
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
if (popup.closed) {
|
||||
cleanup();
|
||||
reject(new Error('Login window was closed before authentication completed'));
|
||||
}
|
||||
}, 500);
|
||||
|
||||
const cleanup = () => {
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener('message', messageHandler);
|
||||
};
|
||||
|
||||
window.addEventListener('message', messageHandler);
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelfHostedOAuthLogin = async (provider: DesktopOAuthProvider) => {
|
||||
setValidationError(null);
|
||||
|
||||
if (!provider.url) {
|
||||
handleOAuthError(t('setup.login.error.configFetch', 'Failed to fetch server configuration. Please check the URL and try again.'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark SSO flow so the callback can short-circuit verification
|
||||
localStorage.setItem('desktop_sso_in_progress', JSON.stringify({ mode: 'selfhosted' }));
|
||||
|
||||
console.debug('[Desktop SSO] Launching provider (popup)', provider);
|
||||
const popup = window.open(provider.url, '_blank', 'width=520,height=720');
|
||||
|
||||
if (!popup) {
|
||||
localStorage.removeItem('desktop_sso_in_progress');
|
||||
console.error('[Desktop SSO] Failed to open popup window for provider', provider);
|
||||
handleOAuthError(t('setup.login.error.oauthFailed', 'OAuth login failed. Please try again.'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await waitForSsoCompletion(popup, serverUrl);
|
||||
localStorage.removeItem('desktop_sso_in_progress');
|
||||
|
||||
await authService.applyExternalToken(token);
|
||||
await onOAuthSuccess({ username: provider.label || provider.id });
|
||||
} catch (err) {
|
||||
console.error('[Desktop SSO] OAuth flow failed for provider', provider, err);
|
||||
localStorage.removeItem('desktop_sso_in_progress');
|
||||
const message = err instanceof Error ? err.message : t('setup.login.error.oauthFailed', 'OAuth login failed. Please try again.');
|
||||
handleOAuthError(message);
|
||||
} finally {
|
||||
try {
|
||||
popup.close();
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const displayError = error || validationError;
|
||||
|
||||
return (
|
||||
@@ -192,8 +74,7 @@ export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
|
||||
onError={handleOAuthError}
|
||||
isDisabled={loading}
|
||||
serverUrl={serverUrl}
|
||||
providers={enabledOAuthProviders}
|
||||
onProviderClick={handleSelfHostedOAuthLogin}
|
||||
providers={enabledOAuthProviders as OAuthProvider[]}
|
||||
/>
|
||||
|
||||
<DividerWithText
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next';
|
||||
import { ServerConfig } from '@app/services/connectionModeService';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { DesktopOAuthProvider } from '@app/components/SetupWizard/DesktopOAuthButtons';
|
||||
|
||||
interface ServerSelectionProps {
|
||||
onSelect: (config: ServerConfig) => void;
|
||||
@@ -44,7 +43,7 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
||||
}
|
||||
|
||||
// Fetch OAuth providers and check if login is enabled
|
||||
let enabledProviders: DesktopOAuthProvider[] = [];
|
||||
let enabledProviders: string[] = [];
|
||||
try {
|
||||
const response = await fetch(`${url}/api/v1/proprietary/ui-data/login`);
|
||||
|
||||
@@ -75,19 +74,11 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract provider metadata from authorization URLs
|
||||
// Example: "/oauth2/authorization/google" → { id: "google", url: "https://server/oauth2/authorization/google" }
|
||||
enabledProviders = Object.entries(data.providerList || {})
|
||||
.map(([path, label]) => {
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
const id = normalizedPath.split('/').pop() || normalizedPath;
|
||||
const trimmedUrl = url.replace(/\/$/, '');
|
||||
return {
|
||||
id,
|
||||
label: typeof label === 'string' ? label : undefined,
|
||||
url: `${trimmedUrl}${normalizedPath}`,
|
||||
} satisfies DesktopOAuthProvider;
|
||||
});
|
||||
// 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);
|
||||
|
||||
console.log('[ServerSelection] Detected OAuth providers:', enabledProviders);
|
||||
} catch (err) {
|
||||
|
||||
@@ -2,16 +2,20 @@ import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DesktopAuthLayout } from '@app/components/SetupWizard/DesktopAuthLayout';
|
||||
import { SaaSLoginScreen } from '@app/components/SetupWizard/SaaSLoginScreen';
|
||||
import { SaaSSignupScreen } from '@app/components/SetupWizard/SaaSSignupScreen';
|
||||
import { ServerSelectionScreen } from '@app/components/SetupWizard/ServerSelectionScreen';
|
||||
import { SelfHostedLoginScreen } from '@app/components/SetupWizard/SelfHostedLoginScreen';
|
||||
import { ServerConfig, connectionModeService } from '@app/services/connectionModeService';
|
||||
import { authService, UserInfo } from '@app/services/authService';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { STIRLING_SAAS_URL } from '@desktop/constants/connection';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { useEffect } from 'react';
|
||||
import '@app/routes/authShared/auth.css';
|
||||
|
||||
enum SetupStep {
|
||||
SaaSLogin,
|
||||
SaaSSignup,
|
||||
ServerSelection,
|
||||
SelfHostedLogin,
|
||||
}
|
||||
@@ -80,6 +84,16 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
setActiveStep(SetupStep.ServerSelection);
|
||||
};
|
||||
|
||||
const handleSwitchToSignup = () => {
|
||||
setError(null);
|
||||
setActiveStep(SetupStep.SaaSSignup);
|
||||
};
|
||||
|
||||
const handleSwitchToLogin = () => {
|
||||
setError(null);
|
||||
setActiveStep(SetupStep.SaaSLogin);
|
||||
};
|
||||
|
||||
const handleServerSelection = (config: ServerConfig) => {
|
||||
setServerConfig(config);
|
||||
setError(null);
|
||||
@@ -128,6 +142,48 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribePromise = listen<string>('deep-link', async (event) => {
|
||||
const url = event.payload;
|
||||
if (!url) return;
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
|
||||
// Supabase sends tokens in the URL hash
|
||||
const hash = parsed.hash.replace(/^#/, '');
|
||||
const params = new URLSearchParams(hash);
|
||||
const accessToken = params.get('access_token');
|
||||
const type = params.get('type') || parsed.searchParams.get('type');
|
||||
|
||||
if (!type || (type !== 'signup' && type !== 'recovery' && type !== 'magiclink')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
console.error('[SetupWizard] Deep link missing access_token');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
await authService.completeSupabaseSession(accessToken, serverConfig?.url || STIRLING_SAAS_URL);
|
||||
await connectionModeService.switchToSaaS(serverConfig?.url || STIRLING_SAAS_URL);
|
||||
tauriBackendService.startBackend().catch(console.error);
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.error('[SetupWizard] Failed to handle deep link', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to complete signup');
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
void unsubscribePromise.then((unsub) => unsub());
|
||||
};
|
||||
}, [onComplete, serverConfig?.url]);
|
||||
|
||||
const handleBack = () => {
|
||||
setError(null);
|
||||
if (activeStep === SetupStep.SelfHostedLogin) {
|
||||
@@ -135,6 +191,8 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
} else if (activeStep === SetupStep.ServerSelection) {
|
||||
setActiveStep(SetupStep.SaaSLogin);
|
||||
setServerConfig({ url: STIRLING_SAAS_URL });
|
||||
} else if (activeStep === SetupStep.SaaSSignup) {
|
||||
setActiveStep(SetupStep.SaaSLogin);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -147,11 +205,21 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||
onLogin={handleSaaSLogin}
|
||||
onOAuthSuccess={handleSaaSLoginOAuth}
|
||||
onSelfHostedClick={handleSelfHostedClick}
|
||||
onSwitchToSignup={handleSwitchToSignup}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeStep === SetupStep.SaaSSignup && (
|
||||
<SaaSSignupScreen
|
||||
loading={loading}
|
||||
error={error}
|
||||
onLogin={handleSaaSLogin}
|
||||
onSwitchToLogin={handleSwitchToLogin}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeStep === SetupStep.ServerSelection && (
|
||||
<ServerSelectionScreen
|
||||
onSelect={handleServerSelection}
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
// The SaaS authentication server
|
||||
export const STIRLING_SAAS_URL: string = import.meta.env.VITE_SAAS_SERVER_URL || '';
|
||||
|
||||
// SaaS signup URL for creating new cloud accounts
|
||||
export const STIRLING_SAAS_SIGNUP_URL: string = import.meta.env.VITE_SAAS_SIGNUP_URL || '';
|
||||
|
||||
// Supabase publishable key from environment variable
|
||||
// Used for SaaS authentication
|
||||
export const SUPABASE_KEY: string = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || 'sb_publishable_UHz2SVRF5mvdrPHWkRteyA_yNlZTkYb';
|
||||
|
||||
// Desktop deep link callback for Supabase email confirmations
|
||||
export const DESKTOP_DEEP_LINK_CALLBACK = 'stirlingpdf://auth/callback';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import axios from 'axios';
|
||||
import { STIRLING_SAAS_URL, SUPABASE_KEY } from '@app/constants/connection';
|
||||
import { DESKTOP_DEEP_LINK_CALLBACK, STIRLING_SAAS_URL, SUPABASE_KEY } from '@app/constants/connection';
|
||||
|
||||
export interface UserInfo {
|
||||
username: string;
|
||||
@@ -69,11 +69,6 @@ export class AuthService {
|
||||
// Notify other parts of the system
|
||||
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||
console.log('[Desktop AuthService] Dispatched jwt-available event');
|
||||
|
||||
// Keep auth status in sync if we already have user info
|
||||
if (this.userInfo) {
|
||||
this.setAuthStatus('authenticated', this.userInfo);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,19 +100,6 @@ export class AuthService {
|
||||
return localStorageToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a JWT obtained from an external flow (e.g., browser SSO).
|
||||
*/
|
||||
async applyExternalToken(token: string, userInfo?: UserInfo | null): Promise<void> {
|
||||
await this.saveTokenEverywhere(token);
|
||||
|
||||
if (userInfo) {
|
||||
this.userInfo = userInfo;
|
||||
}
|
||||
|
||||
this.setAuthStatus('authenticated', userInfo ?? this.userInfo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear token from all storage locations
|
||||
*/
|
||||
@@ -149,6 +131,67 @@ export class AuthService {
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
async completeSupabaseSession(accessToken: string, serverUrl: string): Promise<UserInfo> {
|
||||
if (!accessToken || !accessToken.trim()) {
|
||||
throw new Error('Invalid access token');
|
||||
}
|
||||
if (!SUPABASE_KEY) {
|
||||
throw new Error('VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured');
|
||||
}
|
||||
|
||||
await this.saveTokenEverywhere(accessToken);
|
||||
|
||||
const userInfo = await this.fetchSupabaseUserInfo(serverUrl, accessToken);
|
||||
|
||||
await invoke('save_user_info', {
|
||||
username: userInfo.username,
|
||||
email: userInfo.email || null,
|
||||
});
|
||||
|
||||
this.setAuthStatus('authenticated', userInfo);
|
||||
return userInfo;
|
||||
}
|
||||
|
||||
async signUpSaas(email: string, password: string): Promise<void> {
|
||||
if (!STIRLING_SAAS_URL) {
|
||||
throw new Error('VITE_SAAS_SERVER_URL is not configured');
|
||||
}
|
||||
if (!SUPABASE_KEY) {
|
||||
throw new Error('VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured');
|
||||
}
|
||||
|
||||
const redirectParam = encodeURIComponent(DESKTOP_DEEP_LINK_CALLBACK);
|
||||
const signupUrl = `${STIRLING_SAAS_URL.replace(/\/$/, '')}/auth/v1/signup?redirect_to=${redirectParam}`;
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
signupUrl,
|
||||
{ email, password, email_redirect_to: DESKTOP_DEEP_LINK_CALLBACK },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json;charset=UTF-8',
|
||||
apikey: SUPABASE_KEY,
|
||||
Authorization: `Bearer ${SUPABASE_KEY}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status >= 400) {
|
||||
throw new Error('Sign up failed');
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const message =
|
||||
error.response?.data?.error_description ||
|
||||
error.response?.data?.msg ||
|
||||
error.response?.data?.message ||
|
||||
error.message;
|
||||
throw new Error(message || 'Sign up failed');
|
||||
}
|
||||
throw error instanceof Error ? error : new Error('Sign up failed');
|
||||
}
|
||||
}
|
||||
|
||||
async login(serverUrl: string, username: string, password: string): Promise<UserInfo> {
|
||||
try {
|
||||
console.log('Logging in to:', serverUrl);
|
||||
|
||||
@@ -5,11 +5,7 @@ export type ConnectionMode = 'saas' | 'selfhosted';
|
||||
|
||||
export interface ServerConfig {
|
||||
url: string;
|
||||
enabledOAuthProviders?: Array<{
|
||||
id: string;
|
||||
label?: string;
|
||||
url?: string;
|
||||
}>;
|
||||
enabledOAuthProviders?: string[];
|
||||
}
|
||||
|
||||
export interface ConnectionConfig {
|
||||
@@ -108,22 +104,13 @@ export class ConnectionModeService {
|
||||
try {
|
||||
// Test connection by hitting the health/status endpoint
|
||||
const healthUrl = `${url.replace(/\/$/, '')}/api/v1/info/status`;
|
||||
|
||||
// Prefer the browser fetch to avoid Tauri HTTP permission blockers
|
||||
if (typeof window !== 'undefined' && window.fetch) {
|
||||
const response = await window.fetch(healthUrl, { method: 'GET' });
|
||||
const isOk = response.ok;
|
||||
console.log(`[ConnectionModeService] Server connection test result (browser fetch): ${isOk}`);
|
||||
return isOk;
|
||||
}
|
||||
|
||||
// Fallback to Tauri HTTP plugin
|
||||
const response = await fetch(healthUrl, {
|
||||
method: 'GET',
|
||||
connectTimeout: 10000,
|
||||
});
|
||||
|
||||
const isOk = response.ok;
|
||||
console.log(`[ConnectionModeService] Server connection test result (tauri fetch): ${isOk}`);
|
||||
console.log(`[ConnectionModeService] Server connection test result: ${isOk}`);
|
||||
return isOk;
|
||||
} catch (error) {
|
||||
console.warn('[ConnectionModeService] Server connection test failed:', error);
|
||||
|
||||
@@ -21,20 +21,9 @@ export default function AuthCallback() {
|
||||
const hash = window.location.hash.substring(1); // Remove '#'
|
||||
const params = new URLSearchParams(hash);
|
||||
const token = params.get('access_token');
|
||||
const desktopSsoState = localStorage.getItem('desktop_sso_in_progress');
|
||||
const isDesktopSso = Boolean(desktopSsoState);
|
||||
|
||||
if (!token) {
|
||||
console.error('[AuthCallback] No access_token in URL fragment');
|
||||
if (isDesktopSso && window.opener) {
|
||||
localStorage.removeItem('desktop_sso_in_progress');
|
||||
window.opener.postMessage(
|
||||
{ type: 'stirling-sso-error', error: 'OAuth login failed - no token received.' },
|
||||
window.location.origin
|
||||
);
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
navigate('/login', {
|
||||
replace: true,
|
||||
state: { error: 'OAuth login failed - no token received.' }
|
||||
@@ -49,53 +38,26 @@ export default function AuthCallback() {
|
||||
// Dispatch custom event for other components to react to JWT availability
|
||||
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||
|
||||
// Desktop SSO flow relies on the opener to finalize setup, so skip server validation here
|
||||
if (!isDesktopSso) {
|
||||
// Validate the token and load user info
|
||||
// This calls /api/v1/auth/me with the JWT to get user details
|
||||
const { data, error } = await springAuth.getSession();
|
||||
// Validate the token and load user info
|
||||
// This calls /api/v1/auth/me with the JWT to get user details
|
||||
const { data, error } = await springAuth.getSession();
|
||||
|
||||
if (error || !data.session) {
|
||||
console.error('[AuthCallback] Failed to validate token:', error);
|
||||
localStorage.removeItem('stirling_jwt');
|
||||
navigate('/login', {
|
||||
replace: true,
|
||||
state: { error: 'OAuth login failed - invalid token.' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup flag for desktop flow
|
||||
if (isDesktopSso) {
|
||||
localStorage.removeItem('desktop_sso_in_progress');
|
||||
if (error || !data.session) {
|
||||
console.error('[AuthCallback] Failed to validate token:', error);
|
||||
localStorage.removeItem('stirling_jwt');
|
||||
navigate('/login', {
|
||||
replace: true,
|
||||
state: { error: 'OAuth login failed - invalid token.' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[AuthCallback] Token validated, redirecting to home');
|
||||
|
||||
if (isDesktopSso && window.opener) {
|
||||
window.opener.postMessage(
|
||||
{ type: 'stirling-sso-success', token },
|
||||
window.location.origin
|
||||
);
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear the hash from URL and redirect to home page
|
||||
navigate('/', { replace: true });
|
||||
} catch (error) {
|
||||
console.error('[AuthCallback] Error:', error);
|
||||
const desktopSsoState = localStorage.getItem('desktop_sso_in_progress');
|
||||
if (desktopSsoState && window.opener) {
|
||||
localStorage.removeItem('desktop_sso_in_progress');
|
||||
window.opener.postMessage(
|
||||
{ type: 'stirling-sso-error', error: 'OAuth login failed. Please try again.' },
|
||||
window.location.origin
|
||||
);
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
navigate('/login', {
|
||||
replace: true,
|
||||
state: { error: 'OAuth login failed. Please try again.' }
|
||||
|
||||
Reference in New Issue
Block a user