Desktop to match normal login screens (#5122)1

Also fixed issue with csrf
Also fixed issue with rust keychain

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
This commit is contained in:
ConnorYoh 2025-12-04 17:48:19 +00:00 committed by GitHub
parent 7459463a3c
commit c6b4a2b141
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 779 additions and 607 deletions

View File

@ -112,7 +112,6 @@ public class ApplicationProperties {
@Data
public static class Security {
private Boolean enableLogin;
private Boolean csrfDisabled;
private InitialLogin initialLogin = new InitialLogin();
private OAUTH2 oauth2 = new OAUTH2();
private SAML2 saml2 = new SAML2();

View File

@ -254,10 +254,7 @@ public class PostHogService {
properties,
"security_enableLogin",
applicationProperties.getSecurity().getEnableLogin());
addIfNotEmpty(
properties,
"security_csrfDisabled",
applicationProperties.getSecurity().getCsrfDisabled());
addIfNotEmpty(properties, "security_csrfDisabled", true);
addIfNotEmpty(
properties,
"security_loginAttemptCount",

View File

@ -34,7 +34,6 @@ public class InitialSetup {
public void init() throws IOException {
initUUIDKey();
initSecretKey();
initEnableCSRFSecurity();
initLegalUrls();
initSetAppVersion();
GeneralUtils.extractPipeline();
@ -59,19 +58,6 @@ public class InitialSetup {
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 {
// Initialize Terms and Conditions
String termsUrl = applicationProperties.getLegal().getTermsAndConditions();
@ -95,7 +81,7 @@ public class InitialSetup {
isNewServer =
existingVersion == null
|| existingVersion.isEmpty()
|| existingVersion.equals("0.0.0");
|| "0.0.0".equals(existingVersion);
String appVersion = "0.0.0";
Resource resource = new ClassPathResource("version.properties");

View File

@ -124,7 +124,6 @@ public class SettingsController {
ApplicationProperties.Security security = applicationProperties.getSecurity();
settings.put("enableLogin", security.getEnableLogin());
settings.put("csrfDisabled", security.getCsrfDisabled());
settings.put("loginMethod", security.getLoginMethod());
settings.put("loginAttemptCount", security.getLoginAttemptCount());
settings.put("loginResetTimeMinutes", security.getLoginResetTimeMinutes());
@ -159,12 +158,6 @@ public class SettingsController {
.getSecurity()
.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")) {
GeneralUtils.saveKeyToSettings("security.loginMethod", settings.get("loginMethod"));
applicationProperties

View File

@ -4,8 +4,6 @@ import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
@ -13,6 +11,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
@Controller
@ -63,9 +62,10 @@ public class ReactRoutingController {
}
}
@GetMapping(value = {"/", "/index.html"}, produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> serveIndexHtml(HttpServletRequest request)
throws IOException {
@GetMapping(
value = {"/", "/index.html"},
produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> serveIndexHtml(HttpServletRequest request) throws IOException {
if (indexHtmlExists && cachedIndexHtml != null) {
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedIndexHtml);
}

View File

@ -12,7 +12,6 @@
security:
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
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)

View File

@ -1,7 +1,6 @@
package stirling.software.proprietary.security.configuration;
import java.util.List;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
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.authentication.UsernamePasswordAuthenticationFilter;
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.servlet.util.matcher.PathPatternRequestMatcher;
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.JwtAuthenticationFilter;
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.CustomOAuth2AuthenticationSuccessHandler;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationFailureHandler;
@ -198,9 +194,7 @@ public class SecurityConfiguration {
http.cors(cors -> cors.disable());
}
if (securityProperties.getCsrfDisabled() || !loginEnabledValue) {
http.csrf(CsrfConfigurer::disable);
}
http.csrf(CsrfConfigurer::disable);
if (loginEnabledValue) {
boolean v2Enabled = appConfig.v2Enabled();
@ -210,48 +204,6 @@ public class SecurityConfiguration {
.addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.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(
sessionManagement -> {
if (v2Enabled) {

View File

@ -5901,6 +5901,7 @@ subtitle = "Sign in with your Stirling account"
[setup.selfhosted]
title = "Sign in to Server"
subtitle = "Enter your server credentials"
link = "or connect to a self-hosted account"
[setup.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"
unreachable = "Could not connect to server"
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]
title = "Sign In"

View File

@ -2152,7 +2152,11 @@ version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"byteorder",
"log",
"security-framework 2.11.1",
"security-framework 3.5.1",
"windows-sys 0.60.2",
"zeroize",
]
@ -2378,7 +2382,7 @@ dependencies = [
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework 2.11.1",
"security-framework-sys",
"tempfile",
]
@ -3841,6 +3845,19 @@ dependencies = [
"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]]
name = "security-framework-sys"
version = "2.15.0"

View File

@ -32,7 +32,7 @@ tauri-plugin-http = "2.4.4"
tauri-plugin-single-instance = "2.0.1"
tauri-plugin-store = "2.1.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"] }
reqwest = { version = "0.11", features = ["json"] }
tiny_http = "0.12"

View File

@ -1,4 +1,4 @@
use keyring::Entry;
use keyring::{Entry};
use serde::{Deserialize, Serialize};
use std::sync::{Arc, Mutex};
use tauri::AppHandle;
@ -21,53 +21,70 @@ pub struct UserInfo {
}
fn get_keyring_entry() -> Result<Entry, String> {
Entry::new(KEYRING_SERVICE, KEYRING_TOKEN_KEY)
.map_err(|e| format!("Failed to access keyring: {}", e))
log::debug!("Creating keyring entry with service='{}' username='{}'", KEYRING_SERVICE, KEYRING_TOKEN_KEY);
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]
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()?;
entry
.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(())
}
#[tauri::command]
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()?;
match entry.get_password() {
Ok(token) => Ok(Some(token)),
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]
pub async fn clear_auth_token(_app_handle: AppHandle) -> Result<(), String> {
log::info!("Clearing auth token from keyring");
let entry = get_keyring_entry()?;
// Delete the token - ignore error if it doesn't exist
match entry.delete_credential() {
Ok(_) => {
log::info!("Auth token cleared successfully");
Ok(())
}
Err(keyring::Error::NoEntry) => {
log::info!("Auth token was already cleared");
Ok(())
}
Ok(_) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(format!("Failed to clear token: {}", e)),
}
}
@ -78,8 +95,6 @@ pub async fn save_user_info(
username: String,
email: Option<String>,
) -> Result<(), String> {
log::info!("Saving user info for: {}", username);
let user_info = UserInfo { username, email };
let store = app_handle
@ -96,7 +111,6 @@ pub async fn save_user_info(
.save()
.map_err(|e| format!("Failed to save store: {}", e))?;
log::info!("User info saved successfully");
Ok(())
}
@ -117,8 +131,6 @@ pub async fn get_user_info(app_handle: AppHandle) -> Result<Option<UserInfo>, St
#[tauri::command]
pub async fn clear_user_info(app_handle: AppHandle) -> Result<(), String> {
log::info!("Clearing user info");
let store = app_handle
.store(STORE_FILE)
.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()
.map_err(|e| format!("Failed to save store: {}", e))?;
log::info!("User info cleared successfully");
Ok(())
}
@ -186,12 +197,8 @@ pub async fn login(
supabase_key: String,
saas_server_url: 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)
// Compare against the configured SaaS server URL
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
let client = reqwest::Client::new();
@ -248,8 +255,6 @@ pub async fn login(
.or_else(|| email.clone())
.unwrap_or_else(|| username);
log::info!("Supabase login successful for user: {}", username);
Ok(LoginResponse {
token: login_response.access_token,
username,

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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} />
</>
);
};

View File

@ -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>
);
};

View File

@ -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')}
/>
</>
);
};

View File

@ -1,8 +1,9 @@
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 { ServerConfig } from '@app/services/connectionModeService';
import { connectionModeService } from '@app/services/connectionModeService';
import LocalIcon from '@app/components/shared/LocalIcon';
interface ServerSelectionProps {
onSelect: (config: ServerConfig) => void;
@ -14,11 +15,13 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
const [customUrl, setCustomUrl] = useState('');
const [testing, setTesting] = useState(false);
const [testError, setTestError] = useState<string | null>(null);
const [securityDisabled, setSecurityDisabled] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const url = customUrl.trim();
// Normalize URL: trim and remove trailing slashes
const url = customUrl.trim().replace(/\/+$/, '');
if (!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
setTesting(true);
setTestError(null);
setSecurityDisabled(false);
try {
const isReachable = await connectionModeService.testConnection(url);
@ -38,9 +42,67 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
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({
url,
enabledOAuthProviders: enabledProviders.length > 0 ? enabledProviders : undefined,
});
} catch (error) {
console.error('Connection test failed:', error);
@ -64,6 +126,7 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
onChange={(e) => {
setCustomUrl(e.target.value);
setTestError(null);
setSecurityDisabled(false);
}}
disabled={loading || testing}
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
type="submit"
loading={testing || loading}

View File

@ -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} />
</>
);
};

View File

@ -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));
}

View File

@ -1,18 +1,16 @@
import React, { useState } from 'react';
import { Container, Paper, Stack, Title, Text, Button, Image } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ModeSelection } from '@app/components/SetupWizard/ModeSelection';
import { ServerSelection } from '@app/components/SetupWizard/ServerSelection';
import { LoginForm } from '@app/components/SetupWizard/LoginForm';
import { connectionModeService, ServerConfig } from '@app/services/connectionModeService';
import { authService } from '@app/services/authService';
import { DesktopAuthLayout } from '@app/components/SetupWizard/DesktopAuthLayout';
import { SaaSLoginScreen } from '@app/components/SetupWizard/SaaSLoginScreen';
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 { useLogoPath } from '@app/hooks/useLogoPath';
import { STIRLING_SAAS_URL } from '@desktop/constants/connection';
import '@app/components/SetupWizard/SetupWizard.css';
import '@app/routes/authShared/auth.css';
enum SetupStep {
ModeSelection,
SaaSLogin,
ServerSelection,
SelfHostedLogin,
@ -24,25 +22,11 @@ interface SetupWizardProps {
export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
const { t } = useTranslation();
const logoPath = useLogoPath();
const [activeStep, setActiveStep] = useState<SetupStep>(SetupStep.ModeSelection);
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
const [activeStep, setActiveStep] = useState<SetupStep>(SetupStep.SaaSLogin);
const [serverConfig, setServerConfig] = useState<ServerConfig | null>({ url: STIRLING_SAAS_URL });
const [loading, setLoading] = useState(false);
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) => {
if (!serverConfig) {
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) => {
setServerConfig(config);
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 = () => {
setError(null);
if (activeStep === SetupStep.SaaSLogin) {
setActiveStep(SetupStep.ModeSelection);
setServerConfig(null);
} else if (activeStep === SetupStep.SelfHostedLogin) {
if (activeStep === SetupStep.SelfHostedLogin) {
setActiveStep(SetupStep.ServerSelection);
} else if (activeStep === SetupStep.ServerSelection) {
setActiveStep(SetupStep.ModeSelection);
setServerConfig(null);
}
};
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 '';
setActiveStep(SetupStep.SaaSLogin);
setServerConfig({ url: STIRLING_SAAS_URL });
}
};
return (
<div className="setup-container">
<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>
<DesktopAuthLayout>
{/* Step Content */}
{activeStep === SetupStep.SaaSLogin && (
<SaaSLoginScreen
serverUrl={serverConfig?.url || STIRLING_SAAS_URL}
onLogin={handleSaaSLogin}
onOAuthSuccess={handleSaaSLoginOAuth}
onSelfHostedClick={handleSelfHostedClick}
loading={loading}
error={error}
/>
)}
{/* 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>
)}
{activeStep === SetupStep.ServerSelection && (
<ServerSelectionScreen
onSelect={handleServerSelection}
loading={loading}
error={error}
/>
)}
{/* Step Content */}
{activeStep === SetupStep.ModeSelection && (
<ModeSelection onSelect={handleModeSelection} loading={loading} />
)}
{activeStep === SetupStep.SelfHostedLogin && (
<SelfHostedLoginScreen
serverUrl={serverConfig?.url || ''}
enabledOAuthProviders={serverConfig?.enabledOAuthProviders}
onLogin={handleSelfHostedLogin}
onOAuthSuccess={handleSelfHostedOAuthSuccess}
loading={loading}
error={error}
/>
)}
{activeStep === SetupStep.SaaSLogin && (
<LoginForm
serverUrl={serverConfig?.url || ''}
isSaaS={true}
onLogin={handleSaaSLogin}
loading={loading}
/>
)}
{activeStep === SetupStep.ServerSelection && (
<ServerSelection onSelect={handleServerSelection} loading={loading} />
)}
{activeStep === SetupStep.SelfHostedLogin && (
<LoginForm
serverUrl={serverConfig?.url || ''}
isSaaS={false}
onLogin={handleSelfHostedLogin}
loading={loading}
/>
)}
{/* Back Button */}
{activeStep > SetupStep.ModeSelection && !loading && (
<Button
variant="subtle"
onClick={handleBack}
fullWidth
mt="md"
>
{t('common.back', 'Back')}
</Button>
)}
</Stack>
</Paper>
</Container>
</div>
{/* Back Button */}
{activeStep > SetupStep.SaaSLogin && !loading && (
<div className="navigation-link-container" style={{ marginTop: '1.5rem' }}>
<button
type="button"
onClick={handleBack}
className="navigation-link-button"
>
{t('common.back', 'Back')}
</button>
</div>
)}
</DesktopAuthLayout>
);
};

View File

@ -14,7 +14,7 @@ import { getApiBaseUrl } from '@app/services/apiClientConfig';
const apiClient = create({
baseURL: getApiBaseUrl(),
responseType: 'json',
withCredentials: true,
withCredentials: false, // Desktop doesn't need credentials
});
// Setup interceptors (desktop-specific auth and backend ready checks)

View File

@ -48,13 +48,21 @@ export function setupApiInterceptors(client: AxiosInstance): void {
// Debug logging
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();
if (isRemote) {
// Self-hosted mode: enable credentials for session management
extendedConfig.withCredentials = true;
const token = await authService.getAuthToken();
if (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)
@ -85,7 +93,9 @@ export function setupApiInterceptors(client: AxiosInstance): void {
// Response interceptor: Handle auth errors
client.interceptors.response.use(
(response) => response,
(response) => {
return response;
},
async (error) => {
const originalRequest = error.config as ExtendedRequestConfig;

View File

@ -25,6 +25,7 @@ export class AuthService {
private static instance: AuthService;
private authStatus: AuthStatus = 'unauthenticated';
private userInfo: UserInfo | null = null;
private cachedToken: string | null = null;
private authListeners = new Set<(status: AuthStatus, userInfo: UserInfo | null) => void>();
static getInstance(): AuthService {
@ -38,13 +39,32 @@ export class AuthService {
* Save token to all storage locations and notify listeners
*/
private async saveTokenEverywhere(token: string): Promise<void> {
// Save to Tauri store
await invoke('save_auth_token', { token });
console.log('[Desktop AuthService] Token saved to Tauri store');
// 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');
}
// Sync to localStorage for web layer
localStorage.setItem('stirling_jwt', token);
console.log('[Desktop AuthService] Token saved to localStorage');
try {
// Save to Tauri store
await invoke('save_auth_token', { token });
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
localStorage.setItem('stirling_jwt', token);
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
window.dispatchEvent(new CustomEvent('jwt-available'));
@ -56,20 +76,25 @@ export class AuthService {
*/
private async getTokenFromAnySource(): Promise<string | null> {
// Try Tauri store first
console.log('[Desktop AuthService] Retrieving token from Tauri store...');
const token = await invoke<string | null>('get_auth_token');
try {
const token = await invoke<string | null>('get_auth_token');
if (token) {
console.log(`[Desktop AuthService] Token found in Tauri store (length: ${token.length})`);
return token;
if (token) {
console.log(`[Desktop AuthService] ✅ Token found in Tauri store (length: ${token.length})`);
return token;
}
console.log('[Desktop AuthService] No token in Tauri store, checking localStorage...');
} catch (error) {
console.error('[Desktop AuthService] ❌ Failed to read from Tauri store:', error);
}
console.log('[Desktop AuthService] No token in Tauri store');
// Fallback to localStorage
const localStorageToken = localStorage.getItem('stirling_jwt');
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;
@ -79,6 +104,10 @@ export class AuthService {
* Clear token from all storage locations
*/
private async clearTokenEverywhere(): Promise<void> {
// Invalidate cache
this.cachedToken = null;
console.log('[Desktop AuthService] Cache invalidated');
await invoke('clear_auth_token');
localStorage.removeItem('stirling_jwt');
}
@ -183,7 +212,22 @@ export class AuthService {
async getAuthToken(): Promise<string | null> {
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) {
console.error('[Desktop AuthService] Failed to get auth token:', error);
return null;

View File

@ -5,6 +5,7 @@ export type ConnectionMode = 'saas' | 'selfhosted';
export interface ServerConfig {
url: string;
enabledOAuthProviders?: string[];
}
export interface ConnectionConfig {

View File

@ -61,7 +61,7 @@ class TauriHttpClient {
headers: {},
timeout: 120000,
responseType: 'json',
withCredentials: true,
withCredentials: false, // Desktop doesn't need credentials (backend has allowCredentials=false)
};
public interceptors: Interceptors = {
@ -173,14 +173,15 @@ class TauriHttpClient {
}
try {
// Debug logging
console.debug(`[tauriHttpClient] Fetch request:`, { url, method });
// Convert withCredentials to fetch API's credentials option
const credentials: RequestCredentials = finalConfig.withCredentials ? 'include' : 'omit';
// Make the request using Tauri's native HTTP client (standard Fetch API)
const response = await fetch(url, {
method,
headers,
body,
credentials,
});
// Parse response based on responseType

View File

@ -13,7 +13,6 @@ import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBann
interface SecuritySettingsData {
enableLogin?: boolean;
csrfDisabled?: boolean;
loginMethod?: string;
loginAttemptCount?: number;
loginResetTimeMinutes?: number;
@ -123,7 +122,6 @@ export default function AdminSecuritySection() {
const deltaSettings: Record<string, any> = {
// Security settings
'security.enableLogin': securitySettings.enableLogin,
'security.csrfDisabled': securitySettings.csrfDisabled,
'security.loginMethod': securitySettings.loginMethod,
'security.loginAttemptCount': securitySettings.loginAttemptCount,
'security.loginResetTimeMinutes': securitySettings.loginResetTimeMinutes,
@ -282,23 +280,6 @@ export default function AdminSecuritySection() {
disabled={!loginEnabled}
/>
</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>
</Paper>