mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Desktop to match normal login screens (#5122)1
Also fixed issue with csrf Also fixed issue with rust keychain --------- Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
co-authored by
James Brunton
parent
7459463a3c
commit
c6b4a2b141
@@ -112,7 +112,6 @@ public class ApplicationProperties {
|
|||||||
@Data
|
@Data
|
||||||
public static class Security {
|
public static class Security {
|
||||||
private Boolean enableLogin;
|
private Boolean enableLogin;
|
||||||
private Boolean csrfDisabled;
|
|
||||||
private InitialLogin initialLogin = new InitialLogin();
|
private InitialLogin initialLogin = new InitialLogin();
|
||||||
private OAUTH2 oauth2 = new OAUTH2();
|
private OAUTH2 oauth2 = new OAUTH2();
|
||||||
private SAML2 saml2 = new SAML2();
|
private SAML2 saml2 = new SAML2();
|
||||||
|
|||||||
@@ -254,10 +254,7 @@ public class PostHogService {
|
|||||||
properties,
|
properties,
|
||||||
"security_enableLogin",
|
"security_enableLogin",
|
||||||
applicationProperties.getSecurity().getEnableLogin());
|
applicationProperties.getSecurity().getEnableLogin());
|
||||||
addIfNotEmpty(
|
addIfNotEmpty(properties, "security_csrfDisabled", true);
|
||||||
properties,
|
|
||||||
"security_csrfDisabled",
|
|
||||||
applicationProperties.getSecurity().getCsrfDisabled());
|
|
||||||
addIfNotEmpty(
|
addIfNotEmpty(
|
||||||
properties,
|
properties,
|
||||||
"security_loginAttemptCount",
|
"security_loginAttemptCount",
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ public class InitialSetup {
|
|||||||
public void init() throws IOException {
|
public void init() throws IOException {
|
||||||
initUUIDKey();
|
initUUIDKey();
|
||||||
initSecretKey();
|
initSecretKey();
|
||||||
initEnableCSRFSecurity();
|
|
||||||
initLegalUrls();
|
initLegalUrls();
|
||||||
initSetAppVersion();
|
initSetAppVersion();
|
||||||
GeneralUtils.extractPipeline();
|
GeneralUtils.extractPipeline();
|
||||||
@@ -59,19 +58,6 @@ public class InitialSetup {
|
|||||||
applicationProperties.getAutomaticallyGenerated().setKey(secretKey);
|
applicationProperties.getAutomaticallyGenerated().setKey(secretKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void initEnableCSRFSecurity() throws IOException {
|
|
||||||
if (GeneralUtils.isVersionHigher(
|
|
||||||
"0.46.0", applicationProperties.getAutomaticallyGenerated().getAppVersion())) {
|
|
||||||
Boolean csrf = applicationProperties.getSecurity().getCsrfDisabled();
|
|
||||||
if (!csrf) {
|
|
||||||
GeneralUtils.saveKeyToSettings("security.csrfDisabled", false);
|
|
||||||
GeneralUtils.saveKeyToSettings("system.enableAnalytics", true);
|
|
||||||
applicationProperties.getSecurity().setCsrfDisabled(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void initLegalUrls() throws IOException {
|
public void initLegalUrls() throws IOException {
|
||||||
// Initialize Terms and Conditions
|
// Initialize Terms and Conditions
|
||||||
String termsUrl = applicationProperties.getLegal().getTermsAndConditions();
|
String termsUrl = applicationProperties.getLegal().getTermsAndConditions();
|
||||||
@@ -95,7 +81,7 @@ public class InitialSetup {
|
|||||||
isNewServer =
|
isNewServer =
|
||||||
existingVersion == null
|
existingVersion == null
|
||||||
|| existingVersion.isEmpty()
|
|| existingVersion.isEmpty()
|
||||||
|| existingVersion.equals("0.0.0");
|
|| "0.0.0".equals(existingVersion);
|
||||||
|
|
||||||
String appVersion = "0.0.0";
|
String appVersion = "0.0.0";
|
||||||
Resource resource = new ClassPathResource("version.properties");
|
Resource resource = new ClassPathResource("version.properties");
|
||||||
|
|||||||
@@ -124,7 +124,6 @@ public class SettingsController {
|
|||||||
ApplicationProperties.Security security = applicationProperties.getSecurity();
|
ApplicationProperties.Security security = applicationProperties.getSecurity();
|
||||||
|
|
||||||
settings.put("enableLogin", security.getEnableLogin());
|
settings.put("enableLogin", security.getEnableLogin());
|
||||||
settings.put("csrfDisabled", security.getCsrfDisabled());
|
|
||||||
settings.put("loginMethod", security.getLoginMethod());
|
settings.put("loginMethod", security.getLoginMethod());
|
||||||
settings.put("loginAttemptCount", security.getLoginAttemptCount());
|
settings.put("loginAttemptCount", security.getLoginAttemptCount());
|
||||||
settings.put("loginResetTimeMinutes", security.getLoginResetTimeMinutes());
|
settings.put("loginResetTimeMinutes", security.getLoginResetTimeMinutes());
|
||||||
@@ -159,12 +158,6 @@ public class SettingsController {
|
|||||||
.getSecurity()
|
.getSecurity()
|
||||||
.setEnableLogin((Boolean) settings.get("enableLogin"));
|
.setEnableLogin((Boolean) settings.get("enableLogin"));
|
||||||
}
|
}
|
||||||
if (settings.containsKey("csrfDisabled")) {
|
|
||||||
GeneralUtils.saveKeyToSettings("security.csrfDisabled", settings.get("csrfDisabled"));
|
|
||||||
applicationProperties
|
|
||||||
.getSecurity()
|
|
||||||
.setCsrfDisabled((Boolean) settings.get("csrfDisabled"));
|
|
||||||
}
|
|
||||||
if (settings.containsKey("loginMethod")) {
|
if (settings.containsKey("loginMethod")) {
|
||||||
GeneralUtils.saveKeyToSettings("security.loginMethod", settings.get("loginMethod"));
|
GeneralUtils.saveKeyToSettings("security.loginMethod", settings.get("loginMethod"));
|
||||||
applicationProperties
|
applicationProperties
|
||||||
|
|||||||
+5
-5
@@ -4,8 +4,6 @@ import java.io.IOException;
|
|||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct;
|
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.core.io.ClassPathResource;
|
import org.springframework.core.io.ClassPathResource;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
@@ -13,6 +11,7 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
@@ -63,9 +62,10 @@ public class ReactRoutingController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping(value = {"/", "/index.html"}, produces = MediaType.TEXT_HTML_VALUE)
|
@GetMapping(
|
||||||
public ResponseEntity<String> serveIndexHtml(HttpServletRequest request)
|
value = {"/", "/index.html"},
|
||||||
throws IOException {
|
produces = MediaType.TEXT_HTML_VALUE)
|
||||||
|
public ResponseEntity<String> serveIndexHtml(HttpServletRequest request) throws IOException {
|
||||||
if (indexHtmlExists && cachedIndexHtml != null) {
|
if (indexHtmlExists && cachedIndexHtml != null) {
|
||||||
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedIndexHtml);
|
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedIndexHtml);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
|
|
||||||
security:
|
security:
|
||||||
enableLogin: true # set to 'true' to enable login
|
enableLogin: true # set to 'true' to enable login
|
||||||
csrfDisabled: false # set to 'true' to disable CSRF protection (not recommended for production)
|
|
||||||
loginAttemptCount: 5 # lock user account after 5 tries; when using e.g. Fail2Ban you can deactivate the function with -1
|
loginAttemptCount: 5 # lock user account after 5 tries; when using e.g. Fail2Ban you can deactivate the function with -1
|
||||||
loginResetTimeMinutes: 120 # lock account for 2 hours after x attempts
|
loginResetTimeMinutes: 120 # lock account for 2 hours after x attempts
|
||||||
loginMethod: all # Accepts values like 'all' and 'normal'(only Login with Username/Password), 'oauth2'(only Login with OAuth2) or 'saml2'(only Login with SAML2)
|
loginMethod: all # Accepts values like 'all' and 'normal'(only Login with Username/Password), 'oauth2'(only Login with OAuth2) or 'saml2'(only Login with SAML2)
|
||||||
|
|||||||
-48
@@ -1,7 +1,6 @@
|
|||||||
package stirling.software.proprietary.security.configuration;
|
package stirling.software.proprietary.security.configuration;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
@@ -25,8 +24,6 @@ import org.springframework.security.saml2.provider.service.web.authentication.Op
|
|||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
|
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
|
||||||
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
|
|
||||||
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
|
|
||||||
import org.springframework.security.web.savedrequest.NullRequestCache;
|
import org.springframework.security.web.savedrequest.NullRequestCache;
|
||||||
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
|
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
|
||||||
import org.springframework.web.cors.CorsConfiguration;
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
@@ -47,7 +44,6 @@ import stirling.software.proprietary.security.database.repository.PersistentLogi
|
|||||||
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
|
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
|
||||||
import stirling.software.proprietary.security.filter.JwtAuthenticationFilter;
|
import stirling.software.proprietary.security.filter.JwtAuthenticationFilter;
|
||||||
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
|
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
|
||||||
import stirling.software.proprietary.security.model.User;
|
|
||||||
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationFailureHandler;
|
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationFailureHandler;
|
||||||
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationSuccessHandler;
|
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationSuccessHandler;
|
||||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationFailureHandler;
|
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationFailureHandler;
|
||||||
@@ -198,9 +194,7 @@ public class SecurityConfiguration {
|
|||||||
http.cors(cors -> cors.disable());
|
http.cors(cors -> cors.disable());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (securityProperties.getCsrfDisabled() || !loginEnabledValue) {
|
|
||||||
http.csrf(CsrfConfigurer::disable);
|
http.csrf(CsrfConfigurer::disable);
|
||||||
}
|
|
||||||
|
|
||||||
if (loginEnabledValue) {
|
if (loginEnabledValue) {
|
||||||
boolean v2Enabled = appConfig.v2Enabled();
|
boolean v2Enabled = appConfig.v2Enabled();
|
||||||
@@ -210,48 +204,6 @@ public class SecurityConfiguration {
|
|||||||
.addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class)
|
.addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class)
|
||||||
.addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class);
|
.addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class);
|
||||||
|
|
||||||
if (!securityProperties.getCsrfDisabled()) {
|
|
||||||
CookieCsrfTokenRepository cookieRepo =
|
|
||||||
CookieCsrfTokenRepository.withHttpOnlyFalse();
|
|
||||||
CsrfTokenRequestAttributeHandler requestHandler =
|
|
||||||
new CsrfTokenRequestAttributeHandler();
|
|
||||||
requestHandler.setCsrfRequestAttributeName(null);
|
|
||||||
http.csrf(
|
|
||||||
csrf ->
|
|
||||||
csrf.ignoringRequestMatchers(
|
|
||||||
request -> {
|
|
||||||
String uri = request.getRequestURI();
|
|
||||||
|
|
||||||
// Ignore CSRF for auth endpoints
|
|
||||||
if (uri.startsWith("/api/v1/auth/")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
String apiKey = request.getHeader("X-API-KEY");
|
|
||||||
// If there's no API key, don't ignore CSRF
|
|
||||||
// (return false)
|
|
||||||
if (apiKey == null || apiKey.trim().isEmpty()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Validate API key using existing UserService
|
|
||||||
try {
|
|
||||||
Optional<User> user =
|
|
||||||
userService.getUserByApiKey(apiKey);
|
|
||||||
// If API key is valid, ignore CSRF (return
|
|
||||||
// true)
|
|
||||||
// If API key is invalid, don't ignore CSRF
|
|
||||||
// (return false)
|
|
||||||
return user.isPresent();
|
|
||||||
} catch (Exception e) {
|
|
||||||
// If there's any error validating the API
|
|
||||||
// key, don't ignore CSRF
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.csrfTokenRepository(cookieRepo)
|
|
||||||
.csrfTokenRequestHandler(requestHandler));
|
|
||||||
}
|
|
||||||
|
|
||||||
http.sessionManagement(
|
http.sessionManagement(
|
||||||
sessionManagement -> {
|
sessionManagement -> {
|
||||||
if (v2Enabled) {
|
if (v2Enabled) {
|
||||||
|
|||||||
@@ -5901,6 +5901,7 @@ subtitle = "Sign in with your Stirling account"
|
|||||||
[setup.selfhosted]
|
[setup.selfhosted]
|
||||||
title = "Sign in to Server"
|
title = "Sign in to Server"
|
||||||
subtitle = "Enter your server credentials"
|
subtitle = "Enter your server credentials"
|
||||||
|
link = "or connect to a self-hosted account"
|
||||||
|
|
||||||
[setup.server]
|
[setup.server]
|
||||||
title = "Connect to Server"
|
title = "Connect to Server"
|
||||||
@@ -5919,6 +5920,14 @@ description = "Enter the full URL of your self-hosted Stirling PDF server"
|
|||||||
emptyUrl = "Please enter a server URL"
|
emptyUrl = "Please enter a server URL"
|
||||||
unreachable = "Could not connect to server"
|
unreachable = "Could not connect to server"
|
||||||
testFailed = "Connection test failed"
|
testFailed = "Connection test failed"
|
||||||
|
configFetch = "Failed to fetch server configuration. Please check the URL and try again."
|
||||||
|
|
||||||
|
[setup.server.error.securityDisabled]
|
||||||
|
title = "Login Not Enabled"
|
||||||
|
body = "This server does not have login enabled. To connect to this server, you must enable authentication:"
|
||||||
|
step1 = "Set DOCKER_ENABLE_SECURITY=true in your environment"
|
||||||
|
step2 = "Or set security.enableLogin=true in settings.yml"
|
||||||
|
step3 = "Restart the server"
|
||||||
|
|
||||||
[setup.login]
|
[setup.login]
|
||||||
title = "Sign In"
|
title = "Sign In"
|
||||||
|
|||||||
Generated
+18
-1
@@ -2152,7 +2152,11 @@ version = "3.6.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
|
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
"log",
|
"log",
|
||||||
|
"security-framework 2.11.1",
|
||||||
|
"security-framework 3.5.1",
|
||||||
|
"windows-sys 0.60.2",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2378,7 +2382,7 @@ dependencies = [
|
|||||||
"openssl-probe",
|
"openssl-probe",
|
||||||
"openssl-sys",
|
"openssl-sys",
|
||||||
"schannel",
|
"schannel",
|
||||||
"security-framework",
|
"security-framework 2.11.1",
|
||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
]
|
]
|
||||||
@@ -3841,6 +3845,19 @@ dependencies = [
|
|||||||
"security-framework-sys",
|
"security-framework-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework"
|
||||||
|
version = "3.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"core-foundation 0.10.1",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
"security-framework-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework-sys"
|
name = "security-framework-sys"
|
||||||
version = "2.15.0"
|
version = "2.15.0"
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ tauri-plugin-http = "2.4.4"
|
|||||||
tauri-plugin-single-instance = "2.0.1"
|
tauri-plugin-single-instance = "2.0.1"
|
||||||
tauri-plugin-store = "2.1.0"
|
tauri-plugin-store = "2.1.0"
|
||||||
tauri-plugin-opener = "2.0.0"
|
tauri-plugin-opener = "2.0.0"
|
||||||
keyring = "3.6.1"
|
keyring = { version = "3.6.1", features = ["apple-native", "windows-native"] }
|
||||||
tokio = { version = "1.0", features = ["time", "sync"] }
|
tokio = { version = "1.0", features = ["time", "sync"] }
|
||||||
reqwest = { version = "0.11", features = ["json"] }
|
reqwest = { version = "0.11", features = ["json"] }
|
||||||
tiny_http = "0.12"
|
tiny_http = "0.12"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use keyring::Entry;
|
use keyring::{Entry};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use tauri::AppHandle;
|
use tauri::AppHandle;
|
||||||
@@ -21,53 +21,70 @@ pub struct UserInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn get_keyring_entry() -> Result<Entry, String> {
|
fn get_keyring_entry() -> Result<Entry, String> {
|
||||||
Entry::new(KEYRING_SERVICE, KEYRING_TOKEN_KEY)
|
log::debug!("Creating keyring entry with service='{}' username='{}'", KEYRING_SERVICE, KEYRING_TOKEN_KEY);
|
||||||
.map_err(|e| format!("Failed to access keyring: {}", e))
|
let entry = Entry::new(KEYRING_SERVICE, KEYRING_TOKEN_KEY)
|
||||||
|
.map_err(|e| {
|
||||||
|
log::error!("Failed to create keyring entry: {}", e);
|
||||||
|
format!("Failed to access keyring: {}", e)
|
||||||
|
})?;
|
||||||
|
log::debug!("Keyring entry created successfully");
|
||||||
|
Ok(entry)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn save_auth_token(_app_handle: AppHandle, token: String) -> Result<(), String> {
|
pub async fn save_auth_token(_app_handle: AppHandle, token: String) -> Result<(), String> {
|
||||||
log::info!("Saving auth token to keyring");
|
if token.is_empty() {
|
||||||
|
log::warn!("Attempted to save empty auth token");
|
||||||
|
return Err("Token cannot be empty".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
let entry = get_keyring_entry()?;
|
let entry = get_keyring_entry()?;
|
||||||
|
|
||||||
entry
|
entry
|
||||||
.set_password(&token)
|
.set_password(&token)
|
||||||
.map_err(|e| format!("Failed to save token to keyring: {}", e))?;
|
.map_err(|e| {
|
||||||
|
log::error!("Failed to set password in keyring: {}", e);
|
||||||
|
format!("Failed to save token to keyring: {}", e)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Verify the save worked
|
||||||
|
match entry.get_password() {
|
||||||
|
Ok(retrieved_token) => {
|
||||||
|
if retrieved_token != token {
|
||||||
|
log::error!("Token verification failed: Retrieved token doesn't match");
|
||||||
|
return Err("Token verification failed after save".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Token verification failed: {}", e);
|
||||||
|
return Err(format!("Token verification failed: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log::info!("Auth token saved successfully");
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_auth_token(_app_handle: AppHandle) -> Result<Option<String>, String> {
|
pub async fn get_auth_token(_app_handle: AppHandle) -> Result<Option<String>, String> {
|
||||||
log::debug!("Retrieving auth token from keyring");
|
|
||||||
|
|
||||||
let entry = get_keyring_entry()?;
|
let entry = get_keyring_entry()?;
|
||||||
|
|
||||||
match entry.get_password() {
|
match entry.get_password() {
|
||||||
Ok(token) => Ok(Some(token)),
|
Ok(token) => Ok(Some(token)),
|
||||||
Err(keyring::Error::NoEntry) => Ok(None),
|
Err(keyring::Error::NoEntry) => Ok(None),
|
||||||
Err(e) => Err(format!("Failed to retrieve token: {}", e)),
|
Err(e) => {
|
||||||
|
log::error!("Failed to retrieve token from keyring: {}", e);
|
||||||
|
Err(format!("Failed to retrieve token: {}", e))
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn clear_auth_token(_app_handle: AppHandle) -> Result<(), String> {
|
pub async fn clear_auth_token(_app_handle: AppHandle) -> Result<(), String> {
|
||||||
log::info!("Clearing auth token from keyring");
|
|
||||||
|
|
||||||
let entry = get_keyring_entry()?;
|
let entry = get_keyring_entry()?;
|
||||||
|
|
||||||
// Delete the token - ignore error if it doesn't exist
|
// Delete the token - ignore error if it doesn't exist
|
||||||
match entry.delete_credential() {
|
match entry.delete_credential() {
|
||||||
Ok(_) => {
|
Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
|
||||||
log::info!("Auth token cleared successfully");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(keyring::Error::NoEntry) => {
|
|
||||||
log::info!("Auth token was already cleared");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
Err(e) => Err(format!("Failed to clear token: {}", e)),
|
Err(e) => Err(format!("Failed to clear token: {}", e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,8 +95,6 @@ pub async fn save_user_info(
|
|||||||
username: String,
|
username: String,
|
||||||
email: Option<String>,
|
email: Option<String>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
log::info!("Saving user info for: {}", username);
|
|
||||||
|
|
||||||
let user_info = UserInfo { username, email };
|
let user_info = UserInfo { username, email };
|
||||||
|
|
||||||
let store = app_handle
|
let store = app_handle
|
||||||
@@ -96,7 +111,6 @@ pub async fn save_user_info(
|
|||||||
.save()
|
.save()
|
||||||
.map_err(|e| format!("Failed to save store: {}", e))?;
|
.map_err(|e| format!("Failed to save store: {}", e))?;
|
||||||
|
|
||||||
log::info!("User info saved successfully");
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,8 +131,6 @@ pub async fn get_user_info(app_handle: AppHandle) -> Result<Option<UserInfo>, St
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn clear_user_info(app_handle: AppHandle) -> Result<(), String> {
|
pub async fn clear_user_info(app_handle: AppHandle) -> Result<(), String> {
|
||||||
log::info!("Clearing user info");
|
|
||||||
|
|
||||||
let store = app_handle
|
let store = app_handle
|
||||||
.store(STORE_FILE)
|
.store(STORE_FILE)
|
||||||
.map_err(|e| format!("Failed to access store: {}", e))?;
|
.map_err(|e| format!("Failed to access store: {}", e))?;
|
||||||
@@ -129,7 +141,6 @@ pub async fn clear_user_info(app_handle: AppHandle) -> Result<(), String> {
|
|||||||
.save()
|
.save()
|
||||||
.map_err(|e| format!("Failed to save store: {}", e))?;
|
.map_err(|e| format!("Failed to save store: {}", e))?;
|
||||||
|
|
||||||
log::info!("User info cleared successfully");
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,12 +197,8 @@ pub async fn login(
|
|||||||
supabase_key: String,
|
supabase_key: String,
|
||||||
saas_server_url: String,
|
saas_server_url: String,
|
||||||
) -> Result<LoginResponse, String> {
|
) -> Result<LoginResponse, String> {
|
||||||
log::info!("Login attempt for user: {} to server: {}", username, server_url);
|
|
||||||
|
|
||||||
// Detect if this is Supabase (SaaS) or Spring Boot (self-hosted)
|
// Detect if this is Supabase (SaaS) or Spring Boot (self-hosted)
|
||||||
// Compare against the configured SaaS server URL
|
|
||||||
let is_supabase = server_url.trim_end_matches('/') == saas_server_url.trim_end_matches('/');
|
let is_supabase = server_url.trim_end_matches('/') == saas_server_url.trim_end_matches('/');
|
||||||
log::info!("Authentication type: {}", if is_supabase { "Supabase (SaaS)" } else { "Spring Boot (Self-hosted)" });
|
|
||||||
|
|
||||||
// Create HTTP client
|
// Create HTTP client
|
||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
@@ -248,8 +255,6 @@ pub async fn login(
|
|||||||
.or_else(|| email.clone())
|
.or_else(|| email.clone())
|
||||||
.unwrap_or_else(|| username);
|
.unwrap_or_else(|| username);
|
||||||
|
|
||||||
log::info!("Supabase login successful for user: {}", username);
|
|
||||||
|
|
||||||
Ok(LoginResponse {
|
Ok(LoginResponse {
|
||||||
token: login_response.access_token,
|
token: login_response.access_token,
|
||||||
username,
|
username,
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import LoginRightCarousel from '@app/components/shared/LoginRightCarousel';
|
||||||
|
import buildLoginSlides from '@app/components/shared/loginSlides';
|
||||||
|
import styles from '@app/routes/authShared/AuthLayout.module.css';
|
||||||
|
import { useLogoVariant } from '@app/hooks/useLogoVariant';
|
||||||
|
|
||||||
|
interface DesktopAuthLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DesktopAuthLayout: React.FC<DesktopAuthLayoutProps> = ({ children }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [hideRightPanel, setHideRightPanel] = useState(false);
|
||||||
|
const logoVariant = useLogoVariant();
|
||||||
|
const imageSlides = useMemo(() => buildLoginSlides(logoVariant, t), [logoVariant, t]);
|
||||||
|
|
||||||
|
// Force light mode on auth pages
|
||||||
|
useEffect(() => {
|
||||||
|
const htmlElement = document.documentElement;
|
||||||
|
const previousColorScheme = htmlElement.getAttribute('data-mantine-color-scheme');
|
||||||
|
|
||||||
|
// Set light mode
|
||||||
|
htmlElement.setAttribute('data-mantine-color-scheme', 'light');
|
||||||
|
|
||||||
|
// Cleanup: restore previous theme when leaving auth pages
|
||||||
|
return () => {
|
||||||
|
if (previousColorScheme) {
|
||||||
|
htmlElement.setAttribute('data-mantine-color-scheme', previousColorScheme);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const update = () => {
|
||||||
|
// Use viewport to avoid hysteresis when the card is already in single-column mode
|
||||||
|
const viewportWidth = window.innerWidth;
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const cardWidthIfTwoCols = Math.min(1180, viewportWidth * 0.96); // matches min(73.75rem, 96vw)
|
||||||
|
const columnWidth = cardWidthIfTwoCols / 2;
|
||||||
|
const tooNarrow = columnWidth < 470;
|
||||||
|
const tooShort = viewportHeight < 740;
|
||||||
|
setHideRightPanel(tooNarrow || tooShort);
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
window.addEventListener('resize', update);
|
||||||
|
window.addEventListener('orientationchange', update);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', update);
|
||||||
|
window.removeEventListener('orientationchange', update);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.authContainer}>
|
||||||
|
<div
|
||||||
|
ref={cardRef}
|
||||||
|
className={`${styles.authCard} ${!hideRightPanel ? styles.authCardTwoColumns : ''}`}
|
||||||
|
>
|
||||||
|
<div className={styles.authLeftPanel}>
|
||||||
|
<div className={styles.authContent}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!hideRightPanel && (
|
||||||
|
<LoginRightCarousel imageSlides={imageSlides} initialSeconds={5} slideSeconds={8} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
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 '@app/routes/authShared/auth.css';
|
||||||
|
|
||||||
|
export type OAuthProvider = 'google' | 'github' | 'keycloak' | 'azure' | 'apple' | 'oidc';
|
||||||
|
|
||||||
|
interface DesktopOAuthButtonsProps {
|
||||||
|
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
|
||||||
|
onError: (error: string) => void;
|
||||||
|
isDisabled: boolean;
|
||||||
|
serverUrl: string;
|
||||||
|
providers: OAuthProvider[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
|
||||||
|
onOAuthSuccess,
|
||||||
|
onError,
|
||||||
|
isDisabled,
|
||||||
|
serverUrl,
|
||||||
|
providers,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [oauthLoading, setOauthLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleOAuthLogin = async (provider: OAuthProvider) => {
|
||||||
|
// Prevent concurrent OAuth attempts
|
||||||
|
if (oauthLoading || isDisabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setOauthLoading(true);
|
||||||
|
|
||||||
|
// Build callback page HTML with translations and dark mode support
|
||||||
|
const successHtml = buildOAuthCallbackHtml({
|
||||||
|
title: t('oauth.success.title', 'Authentication Successful'),
|
||||||
|
message: t('oauth.success.message', 'You can close this window and return to Stirling PDF.'),
|
||||||
|
isError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorHtml = buildOAuthCallbackHtml({
|
||||||
|
title: t('oauth.error.title', 'Authentication Failed'),
|
||||||
|
message: t('oauth.error.message', 'Authentication was not successful. You can close this window and try again.'),
|
||||||
|
isError: true,
|
||||||
|
errorPlaceholder: true, // {error} will be replaced by Rust
|
||||||
|
});
|
||||||
|
|
||||||
|
const userInfo = await authService.loginWithOAuth(provider, serverUrl, successHtml, errorHtml);
|
||||||
|
|
||||||
|
// Call the onOAuthSuccess callback to complete setup
|
||||||
|
await onOAuthSuccess(userInfo);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('OAuth login failed:', error);
|
||||||
|
|
||||||
|
const errorMessage = error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: t('setup.login.error.oauthFailed', 'OAuth login failed. Please try again.');
|
||||||
|
|
||||||
|
onError(errorMessage);
|
||||||
|
setOauthLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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' },
|
||||||
|
azure: { label: 'Microsoft', file: 'microsoft.svg' },
|
||||||
|
apple: { label: 'Apple', file: 'apple.svg' },
|
||||||
|
oidc: { label: 'OpenID', file: 'oidc.svg' },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (providers.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="oauth-container-vertical">
|
||||||
|
{providers
|
||||||
|
.filter((providerId) => providerId in providerConfig)
|
||||||
|
.map((providerId) => {
|
||||||
|
const provider = providerConfig[providerId];
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={providerId}
|
||||||
|
onClick={() => handleOAuthLogin(providerId)}
|
||||||
|
disabled={isDisabled || oauthLoading}
|
||||||
|
className="oauth-button-vertical"
|
||||||
|
title={provider.label}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`${BASE_PATH}/Login/${provider.file}`}
|
||||||
|
alt={provider.label}
|
||||||
|
className="oauth-icon-tiny"
|
||||||
|
/>
|
||||||
|
{provider.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{oauthLoading && (
|
||||||
|
<p style={{ margin: '0.5rem 0', fontSize: '0.875rem', color: '#6b7280', textAlign: 'center' }}>
|
||||||
|
{t('setup.login.oauthPending', 'Opening browser for authentication...')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { Stack, TextInput, PasswordInput, Button, Text, Divider, Group, Collapse, Anchor, Box } from '@mantine/core';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { authService } from '@app/services/authService';
|
|
||||||
import { STIRLING_SAAS_URL } from '@app/constants/connection';
|
|
||||||
import { buildOAuthCallbackHtml } from '@app/utils/oauthCallbackHtml';
|
|
||||||
import { BASE_PATH } from '@app/constants/app';
|
|
||||||
|
|
||||||
interface LoginFormProps {
|
|
||||||
serverUrl: string;
|
|
||||||
isSaaS?: boolean;
|
|
||||||
onLogin: (username: string, password: string) => Promise<void>;
|
|
||||||
loading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, isSaaS = false, onLogin, loading }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [username, setUsername] = useState('');
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [validationError, setValidationError] = useState<string | null>(null);
|
|
||||||
const [oauthLoading, setOauthLoading] = useState(false);
|
|
||||||
const [showInstructions, setShowInstructions] = useState(false);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// Validation
|
|
||||||
if (!username.trim()) {
|
|
||||||
setValidationError(isSaaS
|
|
||||||
? t('setup.login.error.emptyEmail', 'Please enter your email')
|
|
||||||
: t('setup.login.error.emptyUsername', 'Please enter your username'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!password) {
|
|
||||||
setValidationError(t('setup.login.error.emptyPassword', 'Please enter your password'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setValidationError(null);
|
|
||||||
await onLogin(username.trim(), password);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOAuthLogin = async (provider: 'google' | 'github') => {
|
|
||||||
// Prevent concurrent OAuth attempts
|
|
||||||
if (oauthLoading || loading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setOauthLoading(true);
|
|
||||||
setValidationError(null);
|
|
||||||
|
|
||||||
// For SaaS, use configured SaaS URL; for self-hosted, derive from serverUrl
|
|
||||||
const authServerUrl = isSaaS
|
|
||||||
? STIRLING_SAAS_URL
|
|
||||||
: serverUrl; // Self-hosted might have its own auth
|
|
||||||
|
|
||||||
// Build callback page HTML with translations and dark mode support
|
|
||||||
const successHtml = buildOAuthCallbackHtml({
|
|
||||||
title: t('oauth.success.title', 'Authentication Successful'),
|
|
||||||
message: t('oauth.success.message', 'You can close this window and return to Stirling PDF.'),
|
|
||||||
isError: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const errorHtml = buildOAuthCallbackHtml({
|
|
||||||
title: t('oauth.error.title', 'Authentication Failed'),
|
|
||||||
message: t('oauth.error.message', 'Authentication was not successful. You can close this window and try again.'),
|
|
||||||
isError: true,
|
|
||||||
errorPlaceholder: true, // {error} will be replaced by Rust
|
|
||||||
});
|
|
||||||
|
|
||||||
const userInfo = await authService.loginWithOAuth(provider, authServerUrl, successHtml, errorHtml);
|
|
||||||
|
|
||||||
// Call the onLogin callback to complete setup (username/password not needed for OAuth)
|
|
||||||
await onLogin(userInfo.username, '');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('OAuth login failed:', error);
|
|
||||||
|
|
||||||
const errorMessage = error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: t('setup.login.error.oauthFailed', 'OAuth login failed. Please try again.');
|
|
||||||
|
|
||||||
setValidationError(errorMessage);
|
|
||||||
setOauthLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<Stack gap="md">
|
|
||||||
<Text size="sm" c="dimmed">
|
|
||||||
{t('setup.login.connectingTo', 'Connecting to:')} <strong>{isSaaS ? 'stirling.com' : serverUrl}</strong>
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Login requirement note for self-hosted servers */}
|
|
||||||
{!isSaaS && (
|
|
||||||
<Box>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t('setup.login.serverRequirement', 'Note: The server must have login enabled.')}{' '}
|
|
||||||
<Anchor
|
|
||||||
size="xs"
|
|
||||||
onClick={() => setShowInstructions(!showInstructions)}
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
>
|
|
||||||
{showInstructions
|
|
||||||
? t('setup.login.hideInstructions', 'Hide instructions')
|
|
||||||
: t('setup.login.showInstructions', 'How to enable?')}
|
|
||||||
</Anchor>
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Collapse in={showInstructions}>
|
|
||||||
<Box mt="xs" p="sm" style={{ backgroundColor: 'var(--mantine-color-gray-0)', borderRadius: '4px' }}>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{t('setup.login.instructions', 'To enable login on your Stirling PDF server:')}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" mt="xs" c="dimmed">
|
|
||||||
{t('setup.login.instructionsEnvVar', 'Set the environment variable:')}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" mt="4px" ff="monospace" c="dark">
|
|
||||||
SECURITY_ENABLELOGIN=true
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" mt="xs" c="dimmed">
|
|
||||||
{t('setup.login.instructionsOrYml', 'Or in settings.yml:')}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" mt="4px" ff="monospace" c="dark">
|
|
||||||
security.enableLogin: true
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" mt="xs" c="dimmed">
|
|
||||||
{t('setup.login.instructionsRestart', 'Then restart your server for the changes to take effect.')}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
</Collapse>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* OAuth Login Buttons - Only show for SaaS */}
|
|
||||||
{isSaaS && (
|
|
||||||
<>
|
|
||||||
<Stack gap="xs">
|
|
||||||
<Group grow>
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
leftSection={<img src={`${BASE_PATH}/Login/google.svg`} alt="Google" width={18} height={18} />}
|
|
||||||
onClick={() => handleOAuthLogin('google')}
|
|
||||||
disabled={loading || oauthLoading}
|
|
||||||
styles={{
|
|
||||||
root: { height: '42px' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('setup.login.signInWith', 'Sign in with')} Google
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="default"
|
|
||||||
leftSection={<img src={`${BASE_PATH}/Login/github.svg`} alt="GitHub" width={18} height={18} />}
|
|
||||||
onClick={() => handleOAuthLogin('github')}
|
|
||||||
disabled={loading || oauthLoading}
|
|
||||||
styles={{
|
|
||||||
root: { height: '42px' },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('setup.login.signInWith', 'Sign in with')} GitHub
|
|
||||||
</Button>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{oauthLoading && (
|
|
||||||
<Text size="sm" c="dimmed" ta="center">
|
|
||||||
{t('setup.login.oauthPending', 'Opening browser for authentication...')}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Divider label={t('setup.login.orContinueWith', 'Or continue with email')} labelPosition="center" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TextInput
|
|
||||||
label={isSaaS
|
|
||||||
? t('setup.login.email.label', 'Email')
|
|
||||||
: t('setup.login.username.label', 'Username')}
|
|
||||||
placeholder={isSaaS
|
|
||||||
? t('setup.login.email.placeholder', 'Enter your email')
|
|
||||||
: t('setup.login.username.placeholder', 'Enter your username')}
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => {
|
|
||||||
setUsername(e.target.value);
|
|
||||||
setValidationError(null);
|
|
||||||
}}
|
|
||||||
disabled={loading}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PasswordInput
|
|
||||||
label={t('setup.login.password.label', 'Password')}
|
|
||||||
placeholder={t('setup.login.password.placeholder', 'Enter your password')}
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => {
|
|
||||||
setPassword(e.target.value);
|
|
||||||
setValidationError(null);
|
|
||||||
}}
|
|
||||||
disabled={loading}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
{validationError && (
|
|
||||||
<Text c="red" size="sm">
|
|
||||||
{validationError}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
loading={loading}
|
|
||||||
disabled={loading}
|
|
||||||
mt="md"
|
|
||||||
fullWidth
|
|
||||||
color="#AF3434"
|
|
||||||
>
|
|
||||||
{t('setup.login.submit', 'Login')}
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Stack, Button, Text } from '@mantine/core';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import CloudIcon from '@mui/icons-material/Cloud';
|
|
||||||
import ComputerIcon from '@mui/icons-material/Computer';
|
|
||||||
|
|
||||||
interface ModeSelectionProps {
|
|
||||||
onSelect: (mode: 'saas' | 'selfhosted') => void;
|
|
||||||
loading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ModeSelection: React.FC<ModeSelectionProps> = ({ onSelect, loading }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack gap="md">
|
|
||||||
<Button
|
|
||||||
size="xl"
|
|
||||||
variant="default"
|
|
||||||
onClick={() => onSelect('saas')}
|
|
||||||
disabled={loading}
|
|
||||||
leftSection={<CloudIcon />}
|
|
||||||
styles={{
|
|
||||||
root: {
|
|
||||||
height: 'auto',
|
|
||||||
padding: '1.25rem',
|
|
||||||
},
|
|
||||||
inner: {
|
|
||||||
justifyContent: 'flex-start',
|
|
||||||
},
|
|
||||||
section: {
|
|
||||||
marginRight: '1rem',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ textAlign: 'left', flex: 1 }}>
|
|
||||||
<Text fw={600} size="md">{t('setup.mode.saas.title', 'Use SaaS')}</Text>
|
|
||||||
<Text size="sm" c="dimmed" fw={400}>
|
|
||||||
{t('setup.mode.saas.description', 'Sign in to Stirling PDF cloud service')}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
size="xl"
|
|
||||||
variant="default"
|
|
||||||
onClick={() => onSelect('selfhosted')}
|
|
||||||
disabled={loading}
|
|
||||||
leftSection={<ComputerIcon />}
|
|
||||||
styles={{
|
|
||||||
root: {
|
|
||||||
height: 'auto',
|
|
||||||
padding: '1.25rem',
|
|
||||||
},
|
|
||||||
inner: {
|
|
||||||
justifyContent: 'flex-start',
|
|
||||||
},
|
|
||||||
section: {
|
|
||||||
marginRight: '1rem',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ textAlign: 'left', flex: 1 }}>
|
|
||||||
<Text fw={600} size="md">{t('setup.mode.selfhosted.title', 'Self-Hosted Server')}</Text>
|
|
||||||
<Text size="sm" c="dimmed" fw={400}>
|
|
||||||
{t('setup.mode.selfhosted.description', 'Connect to your own Stirling PDF server with your personal account')}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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 EmailPasswordForm from '@app/routes/login/EmailPasswordForm';
|
||||||
|
import DividerWithText from '@app/components/shared/DividerWithText';
|
||||||
|
import { DesktopOAuthButtons } from '@app/components/SetupWizard/DesktopOAuthButtons';
|
||||||
|
import { SelfHostedLink } from '@app/components/SetupWizard/SelfHostedLink';
|
||||||
|
import { UserInfo } from '@app/services/authService';
|
||||||
|
import '@app/routes/authShared/auth.css';
|
||||||
|
|
||||||
|
interface SaaSLoginScreenProps {
|
||||||
|
serverUrl: string;
|
||||||
|
onLogin: (username: string, password: string) => Promise<void>;
|
||||||
|
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
|
||||||
|
onSelfHostedClick: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
|
||||||
|
serverUrl,
|
||||||
|
onLogin,
|
||||||
|
onOAuthSuccess,
|
||||||
|
onSelfHostedClick,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleEmailPasswordSubmit = async () => {
|
||||||
|
// Validation
|
||||||
|
if (!email.trim()) {
|
||||||
|
setValidationError(t('setup.login.error.emptyEmail', 'Please enter your email'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
setValidationError(t('setup.login.error.emptyPassword', 'Please enter your password'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValidationError(null);
|
||||||
|
await onLogin(email.trim(), password);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOAuthError = (errorMessage: string) => {
|
||||||
|
setValidationError(errorMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayError = error || validationError;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LoginHeader title={t('setup.saas.title', 'Sign in to Stirling Cloud')} />
|
||||||
|
|
||||||
|
<ErrorMessage error={displayError} />
|
||||||
|
|
||||||
|
<DesktopOAuthButtons
|
||||||
|
onOAuthSuccess={onOAuthSuccess}
|
||||||
|
onError={handleOAuthError}
|
||||||
|
isDisabled={loading}
|
||||||
|
serverUrl={serverUrl}
|
||||||
|
providers={['google', 'github']}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DividerWithText
|
||||||
|
text={t('setup.login.orContinueWith', 'Or continue with email')}
|
||||||
|
respondsToDarkMode={false}
|
||||||
|
opacity={0.4}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EmailPasswordForm
|
||||||
|
email={email}
|
||||||
|
password={password}
|
||||||
|
setEmail={(value) => {
|
||||||
|
setEmail(value);
|
||||||
|
setValidationError(null);
|
||||||
|
}}
|
||||||
|
setPassword={(value) => {
|
||||||
|
setPassword(value);
|
||||||
|
setValidationError(null);
|
||||||
|
}}
|
||||||
|
onSubmit={handleEmailPasswordSubmit}
|
||||||
|
isSubmitting={loading}
|
||||||
|
submitButtonText={t('setup.login.submit', 'Login')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SelfHostedLink onClick={onSelfHostedClick} disabled={loading} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import '@app/routes/authShared/auth.css';
|
||||||
|
|
||||||
|
interface SelfHostedLinkProps {
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SelfHostedLink: React.FC<SelfHostedLinkProps> = ({ onClick, disabled = false }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="navigation-link-container" style={{ marginTop: '1.5rem' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className="navigation-link-button"
|
||||||
|
>
|
||||||
|
{t('setup.selfhosted.link', 'or connect to a self hosted account')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
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, OAuthProvider } from '@app/components/SetupWizard/DesktopOAuthButtons';
|
||||||
|
import { UserInfo } from '@app/services/authService';
|
||||||
|
import '@app/routes/authShared/auth.css';
|
||||||
|
|
||||||
|
interface SelfHostedLoginScreenProps {
|
||||||
|
serverUrl: string;
|
||||||
|
enabledOAuthProviders?: string[];
|
||||||
|
onLogin: (username: string, password: string) => Promise<void>;
|
||||||
|
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
|
||||||
|
serverUrl,
|
||||||
|
enabledOAuthProviders,
|
||||||
|
onLogin,
|
||||||
|
onOAuthSuccess,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
// Validation
|
||||||
|
if (!username.trim()) {
|
||||||
|
setValidationError(t('setup.login.error.emptyUsername', 'Please enter your username'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password) {
|
||||||
|
setValidationError(t('setup.login.error.emptyPassword', 'Please enter your password'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValidationError(null);
|
||||||
|
await onLogin(username.trim(), password);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOAuthError = (errorMessage: string) => {
|
||||||
|
setValidationError(errorMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayError = error || validationError;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LoginHeader
|
||||||
|
title={t('setup.selfhosted.title', 'Sign in to Server')}
|
||||||
|
subtitle={t('setup.selfhosted.subtitle', 'Enter your server credentials')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ErrorMessage error={displayError} />
|
||||||
|
|
||||||
|
<Text size="sm" mb="md">
|
||||||
|
{t('setup.login.connectingTo', 'Connecting to:')} <Text span fw="500">{serverUrl}</Text>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Show OAuth buttons if providers are available */}
|
||||||
|
{enabledOAuthProviders && enabledOAuthProviders.length > 0 && (
|
||||||
|
<>
|
||||||
|
<DesktopOAuthButtons
|
||||||
|
onOAuthSuccess={onOAuthSuccess}
|
||||||
|
onError={handleOAuthError}
|
||||||
|
isDisabled={loading}
|
||||||
|
serverUrl={serverUrl}
|
||||||
|
providers={enabledOAuthProviders as OAuthProvider[]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DividerWithText
|
||||||
|
text={t('setup.login.orContinueWith', 'Or continue with email')}
|
||||||
|
respondsToDarkMode={false}
|
||||||
|
opacity={0.4}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EmailPasswordForm
|
||||||
|
email={username}
|
||||||
|
password={password}
|
||||||
|
setEmail={(value) => {
|
||||||
|
setUsername(value);
|
||||||
|
setValidationError(null);
|
||||||
|
}}
|
||||||
|
setPassword={(value) => {
|
||||||
|
setPassword(value);
|
||||||
|
setValidationError(null);
|
||||||
|
}}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
isSubmitting={loading}
|
||||||
|
submitButtonText={t('setup.login.submit', 'Login')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Stack, Button, TextInput } from '@mantine/core';
|
import { Stack, Button, TextInput, Alert, Text } from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ServerConfig } from '@app/services/connectionModeService';
|
import { ServerConfig } from '@app/services/connectionModeService';
|
||||||
import { connectionModeService } from '@app/services/connectionModeService';
|
import { connectionModeService } from '@app/services/connectionModeService';
|
||||||
|
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||||
|
|
||||||
interface ServerSelectionProps {
|
interface ServerSelectionProps {
|
||||||
onSelect: (config: ServerConfig) => void;
|
onSelect: (config: ServerConfig) => void;
|
||||||
@@ -14,11 +15,13 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
|||||||
const [customUrl, setCustomUrl] = useState('');
|
const [customUrl, setCustomUrl] = useState('');
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [testError, setTestError] = useState<string | null>(null);
|
const [testError, setTestError] = useState<string | null>(null);
|
||||||
|
const [securityDisabled, setSecurityDisabled] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const url = customUrl.trim();
|
// Normalize URL: trim and remove trailing slashes
|
||||||
|
const url = customUrl.trim().replace(/\/+$/, '');
|
||||||
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
setTestError(t('setup.server.error.emptyUrl', 'Please enter a server URL'));
|
setTestError(t('setup.server.error.emptyUrl', 'Please enter a server URL'));
|
||||||
@@ -28,6 +31,7 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
|||||||
// Test connection before proceeding
|
// Test connection before proceeding
|
||||||
setTesting(true);
|
setTesting(true);
|
||||||
setTestError(null);
|
setTestError(null);
|
||||||
|
setSecurityDisabled(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const isReachable = await connectionModeService.testConnection(url);
|
const isReachable = await connectionModeService.testConnection(url);
|
||||||
@@ -38,9 +42,67 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connection successful
|
// Fetch OAuth providers and check if login is enabled
|
||||||
|
let enabledProviders: string[] = [];
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${url}/api/v1/proprietary/ui-data/login`);
|
||||||
|
|
||||||
|
// Check if security is disabled (status 403 or error response)
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 403 || response.status === 401) {
|
||||||
|
setSecurityDisabled(true);
|
||||||
|
setTesting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Other error statuses - show generic error
|
||||||
|
setTestError(
|
||||||
|
t('setup.server.error.configFetch', 'Failed to fetch server configuration (status {{status}})', {
|
||||||
|
status: response.status
|
||||||
|
})
|
||||||
|
);
|
||||||
|
setTesting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('Login UI data:', data);
|
||||||
|
|
||||||
|
// Check if the response indicates security is disabled
|
||||||
|
if (data.enableLogin === false || data.securityEnabled === false) {
|
||||||
|
setSecurityDisabled(true);
|
||||||
|
setTesting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
console.error('[ServerSelection] Failed to fetch login configuration', err);
|
||||||
|
|
||||||
|
// Check if it's a security disabled error
|
||||||
|
if (err instanceof Error && (err.message.includes('403') || err.message.includes('401'))) {
|
||||||
|
setSecurityDisabled(true);
|
||||||
|
setTesting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For any other error (network, CORS, invalid JSON, etc.), show error and don't proceed
|
||||||
|
setTestError(
|
||||||
|
t('setup.server.error.configFetch', 'Failed to fetch server configuration. Please check the URL and try again.')
|
||||||
|
);
|
||||||
|
setTesting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection successful - pass URL and OAuth providers
|
||||||
onSelect({
|
onSelect({
|
||||||
url,
|
url,
|
||||||
|
enabledOAuthProviders: enabledProviders.length > 0 ? enabledProviders : undefined,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Connection test failed:', error);
|
console.error('Connection test failed:', error);
|
||||||
@@ -64,6 +126,7 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setCustomUrl(e.target.value);
|
setCustomUrl(e.target.value);
|
||||||
setTestError(null);
|
setTestError(null);
|
||||||
|
setSecurityDisabled(false);
|
||||||
}}
|
}}
|
||||||
disabled={loading || testing}
|
disabled={loading || testing}
|
||||||
error={testError}
|
error={testError}
|
||||||
@@ -73,6 +136,28 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{securityDisabled && (
|
||||||
|
<Alert
|
||||||
|
variant="light"
|
||||||
|
color="orange"
|
||||||
|
icon={<LocalIcon icon="warning-rounded" width="1.25rem" height="1.25rem" />}
|
||||||
|
title={t('setup.server.error.securityDisabled.title', 'Login Not Enabled')}
|
||||||
|
>
|
||||||
|
<Stack gap="sm">
|
||||||
|
<Text size="sm">
|
||||||
|
{t('setup.server.error.securityDisabled.body', 'This server does not have login enabled. To connect to this server, you must enable authentication:')}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" component="div">
|
||||||
|
<ol style={{ margin: 0, paddingLeft: '1.5rem' }}>
|
||||||
|
<li>{t('setup.server.error.securityDisabled.step1', 'Set DOCKER_ENABLE_SECURITY=true in your environment')}</li>
|
||||||
|
<li>{t('setup.server.error.securityDisabled.step2', 'Or set security.enableLogin=true in settings.yml')}</li>
|
||||||
|
<li>{t('setup.server.error.securityDisabled.step3', 'Restart the server')}</li>
|
||||||
|
</ol>
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
loading={testing || loading}
|
loading={testing || loading}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import LoginHeader from '@app/routes/login/LoginHeader';
|
||||||
|
import ErrorMessage from '@app/routes/login/ErrorMessage';
|
||||||
|
import { ServerSelection } from '@app/components/SetupWizard/ServerSelection';
|
||||||
|
import { ServerConfig } from '@app/services/connectionModeService';
|
||||||
|
import '@app/routes/authShared/auth.css';
|
||||||
|
|
||||||
|
interface ServerSelectionScreenProps {
|
||||||
|
onSelect: (config: ServerConfig) => void;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServerSelectionScreen: React.FC<ServerSelectionScreenProps> = ({
|
||||||
|
onSelect,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<LoginHeader
|
||||||
|
title={t('setup.server.title', 'Connect to Server')}
|
||||||
|
subtitle={t('setup.server.subtitle', 'Enter your self-hosted server URL')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ErrorMessage error={error} />
|
||||||
|
|
||||||
|
<ServerSelection onSelect={onSelect} loading={loading} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
.setup-container {
|
|
||||||
position: relative;
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background: linear-gradient(135deg,
|
|
||||||
light-dark(#f5f5f5, #1a1a1a) 0%,
|
|
||||||
light-dark(#e8e8e8, #0d0d0d) 100%
|
|
||||||
);
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setup-wrapper {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 600px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.setup-card {
|
|
||||||
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
|
|
||||||
box-shadow: 0 20px 60px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4));
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,16 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Container, Paper, Stack, Title, Text, Button, Image } from '@mantine/core';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ModeSelection } from '@app/components/SetupWizard/ModeSelection';
|
import { DesktopAuthLayout } from '@app/components/SetupWizard/DesktopAuthLayout';
|
||||||
import { ServerSelection } from '@app/components/SetupWizard/ServerSelection';
|
import { SaaSLoginScreen } from '@app/components/SetupWizard/SaaSLoginScreen';
|
||||||
import { LoginForm } from '@app/components/SetupWizard/LoginForm';
|
import { ServerSelectionScreen } from '@app/components/SetupWizard/ServerSelectionScreen';
|
||||||
import { connectionModeService, ServerConfig } from '@app/services/connectionModeService';
|
import { SelfHostedLoginScreen } from '@app/components/SetupWizard/SelfHostedLoginScreen';
|
||||||
import { authService } from '@app/services/authService';
|
import { ServerConfig, connectionModeService } from '@app/services/connectionModeService';
|
||||||
|
import { authService, UserInfo } from '@app/services/authService';
|
||||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||||
import { useLogoPath } from '@app/hooks/useLogoPath';
|
|
||||||
import { STIRLING_SAAS_URL } from '@desktop/constants/connection';
|
import { STIRLING_SAAS_URL } from '@desktop/constants/connection';
|
||||||
import '@app/components/SetupWizard/SetupWizard.css';
|
import '@app/routes/authShared/auth.css';
|
||||||
|
|
||||||
enum SetupStep {
|
enum SetupStep {
|
||||||
ModeSelection,
|
|
||||||
SaaSLogin,
|
SaaSLogin,
|
||||||
ServerSelection,
|
ServerSelection,
|
||||||
SelfHostedLogin,
|
SelfHostedLogin,
|
||||||
@@ -24,25 +22,11 @@ interface SetupWizardProps {
|
|||||||
|
|
||||||
export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const logoPath = useLogoPath();
|
const [activeStep, setActiveStep] = useState<SetupStep>(SetupStep.SaaSLogin);
|
||||||
const [activeStep, setActiveStep] = useState<SetupStep>(SetupStep.ModeSelection);
|
const [serverConfig, setServerConfig] = useState<ServerConfig | null>({ url: STIRLING_SAAS_URL });
|
||||||
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleModeSelection = (mode: 'saas' | 'selfhosted') => {
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
if (mode === 'saas') {
|
|
||||||
// For SaaS, go directly to login screen with SaaS URL
|
|
||||||
setServerConfig({ url: STIRLING_SAAS_URL });
|
|
||||||
setActiveStep(SetupStep.SaaSLogin);
|
|
||||||
} else {
|
|
||||||
// For self-hosted, show server selection first
|
|
||||||
setActiveStep(SetupStep.ServerSelection);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaaSLogin = async (username: string, password: string) => {
|
const handleSaaSLogin = async (username: string, password: string) => {
|
||||||
if (!serverConfig) {
|
if (!serverConfig) {
|
||||||
setError('No SaaS server configured');
|
setError('No SaaS server configured');
|
||||||
@@ -70,6 +54,32 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaaSLoginOAuth = async (_userInfo: UserInfo) => {
|
||||||
|
if (!serverConfig) {
|
||||||
|
setError('No SaaS server configured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// OAuth already completed by authService.loginWithOAuth
|
||||||
|
await connectionModeService.switchToSaaS(serverConfig.url);
|
||||||
|
tauriBackendService.startBackend().catch(console.error);
|
||||||
|
onComplete();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('SaaS OAuth login completion failed:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to complete SaaS login');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelfHostedClick = () => {
|
||||||
|
setError(null);
|
||||||
|
setActiveStep(SetupStep.ServerSelection);
|
||||||
|
};
|
||||||
|
|
||||||
const handleServerSelection = (config: ServerConfig) => {
|
const handleServerSelection = (config: ServerConfig) => {
|
||||||
setServerConfig(config);
|
setServerConfig(config);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -97,120 +107,82 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSelfHostedOAuthSuccess = async (_userInfo: UserInfo) => {
|
||||||
|
if (!serverConfig) {
|
||||||
|
setError('No server configured');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// OAuth already completed by authService.loginWithOAuth
|
||||||
|
await connectionModeService.switchToSelfHosted(serverConfig);
|
||||||
|
await tauriBackendService.initializeExternalBackend();
|
||||||
|
onComplete();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Self-hosted OAuth login completion failed:', err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to complete login');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
if (activeStep === SetupStep.SaaSLogin) {
|
if (activeStep === SetupStep.SelfHostedLogin) {
|
||||||
setActiveStep(SetupStep.ModeSelection);
|
|
||||||
setServerConfig(null);
|
|
||||||
} else if (activeStep === SetupStep.SelfHostedLogin) {
|
|
||||||
setActiveStep(SetupStep.ServerSelection);
|
setActiveStep(SetupStep.ServerSelection);
|
||||||
} else if (activeStep === SetupStep.ServerSelection) {
|
} else if (activeStep === SetupStep.ServerSelection) {
|
||||||
setActiveStep(SetupStep.ModeSelection);
|
setActiveStep(SetupStep.SaaSLogin);
|
||||||
setServerConfig(null);
|
setServerConfig({ url: STIRLING_SAAS_URL });
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStepTitle = () => {
|
|
||||||
switch (activeStep) {
|
|
||||||
case SetupStep.ModeSelection:
|
|
||||||
return t('setup.welcome', 'Welcome to Stirling PDF');
|
|
||||||
case SetupStep.SaaSLogin:
|
|
||||||
return t('setup.saas.title', 'Sign in to Stirling Cloud');
|
|
||||||
case SetupStep.ServerSelection:
|
|
||||||
return t('setup.server.title', 'Connect to Server');
|
|
||||||
case SetupStep.SelfHostedLogin:
|
|
||||||
return t('setup.selfhosted.title', 'Sign in to Server');
|
|
||||||
default:
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getStepSubtitle = () => {
|
|
||||||
switch (activeStep) {
|
|
||||||
case SetupStep.ModeSelection:
|
|
||||||
return t('setup.description', 'Get started by choosing how you want to use Stirling PDF');
|
|
||||||
case SetupStep.SaaSLogin:
|
|
||||||
return t('setup.saas.subtitle', 'Sign in with your Stirling account');
|
|
||||||
case SetupStep.ServerSelection:
|
|
||||||
return t('setup.server.subtitle', 'Enter your self-hosted server URL');
|
|
||||||
case SetupStep.SelfHostedLogin:
|
|
||||||
return t('setup.selfhosted.subtitle', 'Enter your server credentials');
|
|
||||||
default:
|
|
||||||
return '';
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="setup-container">
|
<DesktopAuthLayout>
|
||||||
<Container size="sm" className="setup-wrapper">
|
|
||||||
<Paper shadow="xl" p="xl" radius="lg" className="setup-card">
|
|
||||||
<Stack gap="lg">
|
|
||||||
{/* Logo Header */}
|
|
||||||
<Stack gap="xs" align="center">
|
|
||||||
<Image
|
|
||||||
src={logoPath}
|
|
||||||
alt="Stirling PDF"
|
|
||||||
h={64}
|
|
||||||
fit="contain"
|
|
||||||
/>
|
|
||||||
<Title order={1} ta="center" style={{ fontSize: '2rem', fontWeight: 800 }}>
|
|
||||||
{getStepTitle()}
|
|
||||||
</Title>
|
|
||||||
<Text size="sm" c="dimmed" ta="center">
|
|
||||||
{getStepSubtitle()}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
{/* Error Message */}
|
|
||||||
{error && (
|
|
||||||
<Paper p="md" bg="red.0" style={{ border: '1px solid var(--mantine-color-red-3)' }}>
|
|
||||||
<Text size="sm" c="red.7" ta="center">
|
|
||||||
{error}
|
|
||||||
</Text>
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step Content */}
|
{/* Step Content */}
|
||||||
{activeStep === SetupStep.ModeSelection && (
|
|
||||||
<ModeSelection onSelect={handleModeSelection} loading={loading} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeStep === SetupStep.SaaSLogin && (
|
{activeStep === SetupStep.SaaSLogin && (
|
||||||
<LoginForm
|
<SaaSLoginScreen
|
||||||
serverUrl={serverConfig?.url || ''}
|
serverUrl={serverConfig?.url || STIRLING_SAAS_URL}
|
||||||
isSaaS={true}
|
|
||||||
onLogin={handleSaaSLogin}
|
onLogin={handleSaaSLogin}
|
||||||
|
onOAuthSuccess={handleSaaSLoginOAuth}
|
||||||
|
onSelfHostedClick={handleSelfHostedClick}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
error={error}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeStep === SetupStep.ServerSelection && (
|
{activeStep === SetupStep.ServerSelection && (
|
||||||
<ServerSelection onSelect={handleServerSelection} loading={loading} />
|
<ServerSelectionScreen
|
||||||
|
onSelect={handleServerSelection}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeStep === SetupStep.SelfHostedLogin && (
|
{activeStep === SetupStep.SelfHostedLogin && (
|
||||||
<LoginForm
|
<SelfHostedLoginScreen
|
||||||
serverUrl={serverConfig?.url || ''}
|
serverUrl={serverConfig?.url || ''}
|
||||||
isSaaS={false}
|
enabledOAuthProviders={serverConfig?.enabledOAuthProviders}
|
||||||
onLogin={handleSelfHostedLogin}
|
onLogin={handleSelfHostedLogin}
|
||||||
|
onOAuthSuccess={handleSelfHostedOAuthSuccess}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
error={error}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Back Button */}
|
{/* Back Button */}
|
||||||
{activeStep > SetupStep.ModeSelection && !loading && (
|
{activeStep > SetupStep.SaaSLogin && !loading && (
|
||||||
<Button
|
<div className="navigation-link-container" style={{ marginTop: '1.5rem' }}>
|
||||||
variant="subtle"
|
<button
|
||||||
|
type="button"
|
||||||
onClick={handleBack}
|
onClick={handleBack}
|
||||||
fullWidth
|
className="navigation-link-button"
|
||||||
mt="md"
|
|
||||||
>
|
>
|
||||||
{t('common.back', 'Back')}
|
{t('common.back', 'Back')}
|
||||||
</Button>
|
</button>
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
</Container>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</DesktopAuthLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { getApiBaseUrl } from '@app/services/apiClientConfig';
|
|||||||
const apiClient = create({
|
const apiClient = create({
|
||||||
baseURL: getApiBaseUrl(),
|
baseURL: getApiBaseUrl(),
|
||||||
responseType: 'json',
|
responseType: 'json',
|
||||||
withCredentials: true,
|
withCredentials: false, // Desktop doesn't need credentials
|
||||||
});
|
});
|
||||||
|
|
||||||
// Setup interceptors (desktop-specific auth and backend ready checks)
|
// Setup interceptors (desktop-specific auth and backend ready checks)
|
||||||
|
|||||||
@@ -48,13 +48,21 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
|||||||
// Debug logging
|
// Debug logging
|
||||||
console.debug(`[apiClientSetup] Request to: ${extendedConfig.url}`);
|
console.debug(`[apiClientSetup] Request to: ${extendedConfig.url}`);
|
||||||
|
|
||||||
// Add auth token for remote requests
|
// Add auth token for remote requests and enable credentials
|
||||||
const isRemote = await operationRouter.isSelfHostedMode();
|
const isRemote = await operationRouter.isSelfHostedMode();
|
||||||
if (isRemote) {
|
if (isRemote) {
|
||||||
|
// Self-hosted mode: enable credentials for session management
|
||||||
|
extendedConfig.withCredentials = true;
|
||||||
|
|
||||||
const token = await authService.getAuthToken();
|
const token = await authService.getAuthToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
extendedConfig.headers.Authorization = `Bearer ${token}`;
|
extendedConfig.headers.Authorization = `Bearer ${token}`;
|
||||||
|
} else {
|
||||||
|
console.warn('[apiClientSetup] Self-hosted mode but no auth token available');
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// SaaS mode: disable credentials (security disabled on local backend)
|
||||||
|
extendedConfig.withCredentials = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backend readiness check (for local backend)
|
// Backend readiness check (for local backend)
|
||||||
@@ -85,7 +93,9 @@ export function setupApiInterceptors(client: AxiosInstance): void {
|
|||||||
|
|
||||||
// Response interceptor: Handle auth errors
|
// Response interceptor: Handle auth errors
|
||||||
client.interceptors.response.use(
|
client.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => {
|
||||||
|
return response;
|
||||||
|
},
|
||||||
async (error) => {
|
async (error) => {
|
||||||
const originalRequest = error.config as ExtendedRequestConfig;
|
const originalRequest = error.config as ExtendedRequestConfig;
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export class AuthService {
|
|||||||
private static instance: AuthService;
|
private static instance: AuthService;
|
||||||
private authStatus: AuthStatus = 'unauthenticated';
|
private authStatus: AuthStatus = 'unauthenticated';
|
||||||
private userInfo: UserInfo | null = null;
|
private userInfo: UserInfo | null = null;
|
||||||
|
private cachedToken: string | null = null;
|
||||||
private authListeners = new Set<(status: AuthStatus, userInfo: UserInfo | null) => void>();
|
private authListeners = new Set<(status: AuthStatus, userInfo: UserInfo | null) => void>();
|
||||||
|
|
||||||
static getInstance(): AuthService {
|
static getInstance(): AuthService {
|
||||||
@@ -38,13 +39,32 @@ export class AuthService {
|
|||||||
* Save token to all storage locations and notify listeners
|
* Save token to all storage locations and notify listeners
|
||||||
*/
|
*/
|
||||||
private async saveTokenEverywhere(token: string): Promise<void> {
|
private async saveTokenEverywhere(token: string): Promise<void> {
|
||||||
|
// Validate token before caching
|
||||||
|
if (!token || token.trim().length === 0) {
|
||||||
|
console.warn('[Desktop AuthService] Attempted to save invalid/empty token');
|
||||||
|
throw new Error('Invalid token');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
// Save to Tauri store
|
// Save to Tauri store
|
||||||
await invoke('save_auth_token', { token });
|
await invoke('save_auth_token', { token });
|
||||||
console.log('[Desktop AuthService] Token saved to Tauri store');
|
console.log('[Desktop AuthService] ✅ Token saved to Tauri store');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Desktop AuthService] ❌ Failed to save token to Tauri store:', error);
|
||||||
|
// Don't throw - we can still use localStorage
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
// Sync to localStorage for web layer
|
// Sync to localStorage for web layer
|
||||||
localStorage.setItem('stirling_jwt', token);
|
localStorage.setItem('stirling_jwt', token);
|
||||||
console.log('[Desktop AuthService] Token saved to localStorage');
|
console.log('[Desktop AuthService] ✅ Token saved to localStorage');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Desktop AuthService] ❌ Failed to save token to localStorage:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the valid token in memory
|
||||||
|
this.cachedToken = token;
|
||||||
|
console.log('[Desktop AuthService] ✅ Token cached in memory');
|
||||||
|
|
||||||
// Notify other parts of the system
|
// Notify other parts of the system
|
||||||
window.dispatchEvent(new CustomEvent('jwt-available'));
|
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||||
@@ -56,20 +76,25 @@ export class AuthService {
|
|||||||
*/
|
*/
|
||||||
private async getTokenFromAnySource(): Promise<string | null> {
|
private async getTokenFromAnySource(): Promise<string | null> {
|
||||||
// Try Tauri store first
|
// Try Tauri store first
|
||||||
console.log('[Desktop AuthService] Retrieving token from Tauri store...');
|
try {
|
||||||
const token = await invoke<string | null>('get_auth_token');
|
const token = await invoke<string | null>('get_auth_token');
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
console.log(`[Desktop AuthService] Token found in Tauri store (length: ${token.length})`);
|
console.log(`[Desktop AuthService] ✅ Token found in Tauri store (length: ${token.length})`);
|
||||||
return token;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[Desktop AuthService] No token in Tauri store');
|
console.log('[Desktop AuthService] ℹ️ No token in Tauri store, checking localStorage...');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Desktop AuthService] ❌ Failed to read from Tauri store:', error);
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback to localStorage
|
// Fallback to localStorage
|
||||||
const localStorageToken = localStorage.getItem('stirling_jwt');
|
const localStorageToken = localStorage.getItem('stirling_jwt');
|
||||||
if (localStorageToken) {
|
if (localStorageToken) {
|
||||||
console.log('[Desktop AuthService] Token found in localStorage (length:', localStorageToken.length, ')');
|
console.log(`[Desktop AuthService] ✅ Token found in localStorage (length: ${localStorageToken.length})`);
|
||||||
|
} else {
|
||||||
|
console.log('[Desktop AuthService] ❌ No token found in any storage');
|
||||||
}
|
}
|
||||||
|
|
||||||
return localStorageToken;
|
return localStorageToken;
|
||||||
@@ -79,6 +104,10 @@ export class AuthService {
|
|||||||
* Clear token from all storage locations
|
* Clear token from all storage locations
|
||||||
*/
|
*/
|
||||||
private async clearTokenEverywhere(): Promise<void> {
|
private async clearTokenEverywhere(): Promise<void> {
|
||||||
|
// Invalidate cache
|
||||||
|
this.cachedToken = null;
|
||||||
|
console.log('[Desktop AuthService] Cache invalidated');
|
||||||
|
|
||||||
await invoke('clear_auth_token');
|
await invoke('clear_auth_token');
|
||||||
localStorage.removeItem('stirling_jwt');
|
localStorage.removeItem('stirling_jwt');
|
||||||
}
|
}
|
||||||
@@ -183,7 +212,22 @@ export class AuthService {
|
|||||||
|
|
||||||
async getAuthToken(): Promise<string | null> {
|
async getAuthToken(): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
return await this.getTokenFromAnySource();
|
// Return cached token if available
|
||||||
|
if (this.cachedToken) {
|
||||||
|
console.debug('[Desktop AuthService] ✅ Returning cached token');
|
||||||
|
return this.cachedToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug('[Desktop AuthService] Cache miss, fetching from storage...');
|
||||||
|
const token = await this.getTokenFromAnySource();
|
||||||
|
|
||||||
|
// Cache the token if valid
|
||||||
|
if (token && token.trim().length > 0) {
|
||||||
|
this.cachedToken = token;
|
||||||
|
console.log('[Desktop AuthService] ✅ Token cached in memory after retrieval');
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Desktop AuthService] Failed to get auth token:', error);
|
console.error('[Desktop AuthService] Failed to get auth token:', error);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type ConnectionMode = 'saas' | 'selfhosted';
|
|||||||
|
|
||||||
export interface ServerConfig {
|
export interface ServerConfig {
|
||||||
url: string;
|
url: string;
|
||||||
|
enabledOAuthProviders?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ConnectionConfig {
|
export interface ConnectionConfig {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class TauriHttpClient {
|
|||||||
headers: {},
|
headers: {},
|
||||||
timeout: 120000,
|
timeout: 120000,
|
||||||
responseType: 'json',
|
responseType: 'json',
|
||||||
withCredentials: true,
|
withCredentials: false, // Desktop doesn't need credentials (backend has allowCredentials=false)
|
||||||
};
|
};
|
||||||
|
|
||||||
public interceptors: Interceptors = {
|
public interceptors: Interceptors = {
|
||||||
@@ -173,14 +173,15 @@ class TauriHttpClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Debug logging
|
// Convert withCredentials to fetch API's credentials option
|
||||||
console.debug(`[tauriHttpClient] Fetch request:`, { url, method });
|
const credentials: RequestCredentials = finalConfig.withCredentials ? 'include' : 'omit';
|
||||||
|
|
||||||
// Make the request using Tauri's native HTTP client (standard Fetch API)
|
// Make the request using Tauri's native HTTP client (standard Fetch API)
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body,
|
body,
|
||||||
|
credentials,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Parse response based on responseType
|
// Parse response based on responseType
|
||||||
|
|||||||
-19
@@ -13,7 +13,6 @@ import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBann
|
|||||||
|
|
||||||
interface SecuritySettingsData {
|
interface SecuritySettingsData {
|
||||||
enableLogin?: boolean;
|
enableLogin?: boolean;
|
||||||
csrfDisabled?: boolean;
|
|
||||||
loginMethod?: string;
|
loginMethod?: string;
|
||||||
loginAttemptCount?: number;
|
loginAttemptCount?: number;
|
||||||
loginResetTimeMinutes?: number;
|
loginResetTimeMinutes?: number;
|
||||||
@@ -123,7 +122,6 @@ export default function AdminSecuritySection() {
|
|||||||
const deltaSettings: Record<string, any> = {
|
const deltaSettings: Record<string, any> = {
|
||||||
// Security settings
|
// Security settings
|
||||||
'security.enableLogin': securitySettings.enableLogin,
|
'security.enableLogin': securitySettings.enableLogin,
|
||||||
'security.csrfDisabled': securitySettings.csrfDisabled,
|
|
||||||
'security.loginMethod': securitySettings.loginMethod,
|
'security.loginMethod': securitySettings.loginMethod,
|
||||||
'security.loginAttemptCount': securitySettings.loginAttemptCount,
|
'security.loginAttemptCount': securitySettings.loginAttemptCount,
|
||||||
'security.loginResetTimeMinutes': securitySettings.loginResetTimeMinutes,
|
'security.loginResetTimeMinutes': securitySettings.loginResetTimeMinutes,
|
||||||
@@ -282,23 +280,6 @@ export default function AdminSecuritySection() {
|
|||||||
disabled={!loginEnabled}
|
disabled={!loginEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
||||||
<div>
|
|
||||||
<Text fw={500} size="sm">{t('admin.settings.security.csrfDisabled.label', 'Disable CSRF Protection')}</Text>
|
|
||||||
<Text size="xs" c="dimmed" mt={4}>
|
|
||||||
{t('admin.settings.security.csrfDisabled.description', 'Disable Cross-Site Request Forgery protection (not recommended)')}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
<Group gap="xs">
|
|
||||||
<Switch
|
|
||||||
checked={settings?.csrfDisabled || false}
|
|
||||||
onChange={(e) => setSettings({ ...settings, csrfDisabled: e.target.checked })}
|
|
||||||
disabled={!loginEnabled}
|
|
||||||
/>
|
|
||||||
<PendingBadge show={isFieldPending('csrfDisabled')} />
|
|
||||||
</Group>
|
|
||||||
</div>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user