diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 9719752dc..2689b6b98 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -589,6 +589,26 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -716,6 +736,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.6" @@ -908,6 +934,15 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "document-features" version = "0.2.12" @@ -1626,6 +1661,12 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.16.0" @@ -2820,6 +2861,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -3651,6 +3702,16 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rust_decimal" version = "1.39.0" @@ -4256,6 +4317,7 @@ dependencies = [ "sha2", "tauri", "tauri-build", + "tauri-plugin-deep-link", "tauri-plugin-fs", "tauri-plugin-http", "tauri-plugin-log", @@ -4615,6 +4677,27 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e82759f7c7d51de3cbde51c04b3f2332de52436ed84541182cd8944b04e9e73" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.17", + "tracing", + "url", + "windows-registry", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-fs" version = "2.4.4" @@ -4735,6 +4818,7 @@ dependencies = [ "serde", "serde_json", "tauri", + "tauri-plugin-deep-link", "thiserror 2.0.17", "tracing", "windows-sys 0.60.2", @@ -4954,6 +5038,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tiny_http" version = "0.12.0" diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index dc84ad8a2..455436cdf 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -29,9 +29,10 @@ tauri-plugin-log = "2.0.0-rc" tauri-plugin-shell = "2.1.0" tauri-plugin-fs = "2.4.4" tauri-plugin-http = "2.4.4" -tauri-plugin-single-instance = "2.0.1" +tauri-plugin-single-instance = { version = "2.3.6", features = ["deep-link"] } tauri-plugin-store = "2.1.0" tauri-plugin-opener = "2.0.0" +tauri-plugin-deep-link = "2.4.5" keyring = { version = "3.6.1", features = ["apple-native", "windows-native"] } tokio = { version = "1.0", features = ["time", "sync"] } reqwest = { version = "0.11", features = ["json"] } diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json index b992b3221..445e1d30a 100644 --- a/frontend/src-tauri/capabilities/default.json +++ b/frontend/src-tauri/capabilities/default.json @@ -19,6 +19,8 @@ { "identifier": "fs:allow-read-file", "allow": [{ "path": "**" }] - } + }, + "opener:default", + "shell:allow-open" ] } diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index a08587cc1..61cbd6d43 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -1,4 +1,4 @@ -use tauri::{Manager, RunEvent, WindowEvent, Emitter}; +use tauri::{AppHandle, Emitter, Manager, RunEvent, WindowEvent}; mod utils; mod commands; @@ -28,6 +28,17 @@ use commands::{ }; use state::connection_state::AppConnectionState; use utils::{add_log, get_tauri_logs}; +use tauri_plugin_deep_link::DeepLinkExt; + +fn dispatch_deep_link(app: &AppHandle, url: &str) { + add_log(format!("🔗 Dispatching deep link: {}", url)); + let _ = app.emit("deep-link", url.to_string()); + + if let Some(window) = app.get_webview_window("main") { + let _ = window.set_focus(); + let _ = window.unminimize(); + } +} #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { @@ -42,6 +53,7 @@ pub fn run() { .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_store::Builder::new().build()) + .plugin(tauri_plugin_deep_link::init()) .manage(AppConnectionState::default()) .plugin(tauri_plugin_single_instance::init(|app, args, _cwd| { // This callback runs when a second instance tries to start @@ -78,6 +90,29 @@ pub fn run() { } } + { + let app_handle = app.handle(); + // On macOS the plugin registers schemes via bundle metadata, so runtime registration is required only on Windows/Linux + #[cfg(any(target_os = "linux", target_os = "windows"))] + if let Err(err) = app_handle.deep_link().register_all() { + add_log(format!("⚠️ Failed to register deep link handler: {}", err)); + } + + if let Ok(Some(urls)) = app_handle.deep_link().get_current() { + let initial_handle = app_handle.clone(); + for url in urls { + dispatch_deep_link(&initial_handle, url.as_str()); + } + } + + let event_app_handle = app_handle.clone(); + app_handle.deep_link().on_open_url(move |event| { + for url in event.urls() { + dispatch_deep_link(&event_app_handle, url.as_str()); + } + }); + } + // Start backend immediately, non-blocking let app_handle = app.handle().clone(); diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index b10fec0e5..43546413c 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -77,6 +77,13 @@ }, "fs": { "requireLiteralLeadingDot": false + }, + "deep-link": { + "desktop": { + "schemes": [ + "stirlingpdf" + ] + } } } } diff --git a/frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx b/frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx index ab82bde1e..6acd28858 100644 --- a/frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx +++ b/frontend/src/desktop/components/SetupWizard/SaaSLoginScreen.tsx @@ -14,6 +14,7 @@ interface SaaSLoginScreenProps { onLogin: (username: string, password: string) => Promise; onOAuthSuccess: (userInfo: UserInfo) => Promise; onSelfHostedClick: () => void; + onSwitchToSignup: () => void; loading: boolean; error: string | null; } @@ -23,6 +24,7 @@ export const SaaSLoginScreen: React.FC = ({ onLogin, onOAuthSuccess, onSelfHostedClick, + onSwitchToSignup, loading, error, }) => { @@ -89,6 +91,20 @@ export const SaaSLoginScreen: React.FC = ({ submitButtonText={t('setup.login.submit', 'Login')} /> +
+ +
+ ); diff --git a/frontend/src/desktop/components/SetupWizard/SaaSSignupScreen.tsx b/frontend/src/desktop/components/SetupWizard/SaaSSignupScreen.tsx new file mode 100644 index 000000000..6ad330926 --- /dev/null +++ b/frontend/src/desktop/components/SetupWizard/SaaSSignupScreen.tsx @@ -0,0 +1,104 @@ +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import LoginHeader from '@app/routes/login/LoginHeader'; +import ErrorMessage from '@app/routes/login/ErrorMessage'; +import SignupForm from '@app/routes/signup/SignupForm'; +import { useSignupFormValidation, SignupFieldErrors } from '@app/routes/signup/SignupFormValidation'; +import { authService } from '@app/services/authService'; +import '@app/routes/authShared/auth.css'; + +interface SaaSSignupScreenProps { + loading: boolean; + error: string | null; + onLogin: (username: string, password: string) => Promise; + onSwitchToLogin: () => void; +} + +export const SaaSSignupScreen: React.FC = ({ + loading, + error, + onLogin: _onLogin, + onSwitchToLogin: _onSwitchToLogin, +}) => { + const { t } = useTranslation(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [validationError, setValidationError] = useState(null); + const [signupFieldErrors, setSignupFieldErrors] = useState({}); + const [signupSuccessMessage, setSignupSuccessMessage] = useState(null); + const [isSignupSubmitting, setIsSignupSubmitting] = useState(false); + const { validateSignupForm } = useSignupFormValidation(); + + const displayError = error || validationError; + + const handleSignupSubmit = async () => { + setValidationError(null); + setSignupSuccessMessage(null); + setSignupFieldErrors({}); + + const validation = validateSignupForm(email, password, confirmPassword); + if (!validation.isValid) { + setValidationError(validation.error); + setSignupFieldErrors(validation.fieldErrors || {}); + return; + } + + try { + setIsSignupSubmitting(true); + await authService.signUpSaas(email.trim(), password); + setSignupSuccessMessage(t('signup.checkEmailConfirmation', 'Check your email for a confirmation link to complete your registration.')); + setSignupFieldErrors({}); + setValidationError(null); + } catch (err) { + setSignupSuccessMessage(null); + const message = err instanceof Error ? err.message : t('signup.unexpectedError', { message: 'Unknown error' }); + setValidationError(message); + } finally { + setIsSignupSubmitting(false); + } + }; + + return ( + <> + + + + {signupSuccessMessage && ( +
+

{signupSuccessMessage}

+
+ )} + + { + setEmail(value); + setValidationError(null); + setSignupFieldErrors({}); + }} + setPassword={(value) => { + setPassword(value); + setValidationError(null); + setSignupFieldErrors({}); + }} + setConfirmPassword={(value) => { + setConfirmPassword(value); + setValidationError(null); + setSignupFieldErrors({}); + }} + onSubmit={handleSignupSubmit} + isSubmitting={loading || isSignupSubmitting} + fieldErrors={signupFieldErrors} + showName={false} + showTerms={false} + /> + + + ); +}; diff --git a/frontend/src/desktop/components/SetupWizard/index.tsx b/frontend/src/desktop/components/SetupWizard/index.tsx index ac4fa2369..9fbd0e6cc 100644 --- a/frontend/src/desktop/components/SetupWizard/index.tsx +++ b/frontend/src/desktop/components/SetupWizard/index.tsx @@ -2,16 +2,20 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { DesktopAuthLayout } from '@app/components/SetupWizard/DesktopAuthLayout'; import { SaaSLoginScreen } from '@app/components/SetupWizard/SaaSLoginScreen'; +import { SaaSSignupScreen } from '@app/components/SetupWizard/SaaSSignupScreen'; import { ServerSelectionScreen } from '@app/components/SetupWizard/ServerSelectionScreen'; import { SelfHostedLoginScreen } from '@app/components/SetupWizard/SelfHostedLoginScreen'; import { ServerConfig, connectionModeService } from '@app/services/connectionModeService'; import { authService, UserInfo } from '@app/services/authService'; import { tauriBackendService } from '@app/services/tauriBackendService'; import { STIRLING_SAAS_URL } from '@desktop/constants/connection'; +import { listen } from '@tauri-apps/api/event'; +import { useEffect } from 'react'; import '@app/routes/authShared/auth.css'; enum SetupStep { SaaSLogin, + SaaSSignup, ServerSelection, SelfHostedLogin, } @@ -80,6 +84,16 @@ export const SetupWizard: React.FC = ({ onComplete }) => { setActiveStep(SetupStep.ServerSelection); }; + const handleSwitchToSignup = () => { + setError(null); + setActiveStep(SetupStep.SaaSSignup); + }; + + const handleSwitchToLogin = () => { + setError(null); + setActiveStep(SetupStep.SaaSLogin); + }; + const handleServerSelection = (config: ServerConfig) => { setServerConfig(config); setError(null); @@ -128,6 +142,48 @@ export const SetupWizard: React.FC = ({ onComplete }) => { } }; + useEffect(() => { + const unsubscribePromise = listen('deep-link', async (event) => { + const url = event.payload; + if (!url) return; + + try { + const parsed = new URL(url); + + // Supabase sends tokens in the URL hash + const hash = parsed.hash.replace(/^#/, ''); + const params = new URLSearchParams(hash); + const accessToken = params.get('access_token'); + const type = params.get('type') || parsed.searchParams.get('type'); + + if (!type || (type !== 'signup' && type !== 'recovery' && type !== 'magiclink')) { + return; + } + + if (!accessToken) { + console.error('[SetupWizard] Deep link missing access_token'); + return; + } + + setLoading(true); + setError(null); + + await authService.completeSupabaseSession(accessToken, serverConfig?.url || STIRLING_SAAS_URL); + await connectionModeService.switchToSaaS(serverConfig?.url || STIRLING_SAAS_URL); + tauriBackendService.startBackend().catch(console.error); + onComplete(); + } catch (err) { + console.error('[SetupWizard] Failed to handle deep link', err); + setError(err instanceof Error ? err.message : 'Failed to complete signup'); + setLoading(false); + } + }); + + return () => { + void unsubscribePromise.then((unsub) => unsub()); + }; + }, [onComplete, serverConfig?.url]); + const handleBack = () => { setError(null); if (activeStep === SetupStep.SelfHostedLogin) { @@ -135,6 +191,8 @@ export const SetupWizard: React.FC = ({ onComplete }) => { } else if (activeStep === SetupStep.ServerSelection) { setActiveStep(SetupStep.SaaSLogin); setServerConfig({ url: STIRLING_SAAS_URL }); + } else if (activeStep === SetupStep.SaaSSignup) { + setActiveStep(SetupStep.SaaSLogin); } }; @@ -147,11 +205,21 @@ export const SetupWizard: React.FC = ({ onComplete }) => { onLogin={handleSaaSLogin} onOAuthSuccess={handleSaaSLoginOAuth} onSelfHostedClick={handleSelfHostedClick} + onSwitchToSignup={handleSwitchToSignup} loading={loading} error={error} /> )} + {activeStep === SetupStep.SaaSSignup && ( + + )} + {activeStep === SetupStep.ServerSelection && ( { + if (!accessToken || !accessToken.trim()) { + throw new Error('Invalid access token'); + } + if (!SUPABASE_KEY) { + throw new Error('VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured'); + } + + await this.saveTokenEverywhere(accessToken); + + const userInfo = await this.fetchSupabaseUserInfo(serverUrl, accessToken); + + await invoke('save_user_info', { + username: userInfo.username, + email: userInfo.email || null, + }); + + this.setAuthStatus('authenticated', userInfo); + return userInfo; + } + + async signUpSaas(email: string, password: string): Promise { + if (!STIRLING_SAAS_URL) { + throw new Error('VITE_SAAS_SERVER_URL is not configured'); + } + if (!SUPABASE_KEY) { + throw new Error('VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured'); + } + + const redirectParam = encodeURIComponent(DESKTOP_DEEP_LINK_CALLBACK); + const signupUrl = `${STIRLING_SAAS_URL.replace(/\/$/, '')}/auth/v1/signup?redirect_to=${redirectParam}`; + + try { + const response = await axios.post( + signupUrl, + { email, password, email_redirect_to: DESKTOP_DEEP_LINK_CALLBACK }, + { + headers: { + 'Content-Type': 'application/json;charset=UTF-8', + apikey: SUPABASE_KEY, + Authorization: `Bearer ${SUPABASE_KEY}`, + }, + } + ); + + if (response.status >= 400) { + throw new Error('Sign up failed'); + } + } catch (error) { + if (axios.isAxiosError(error)) { + const message = + error.response?.data?.error_description || + error.response?.data?.msg || + error.response?.data?.message || + error.message; + throw new Error(message || 'Sign up failed'); + } + throw error instanceof Error ? error : new Error('Sign up failed'); + } + } + async login(serverUrl: string, username: string, password: string): Promise { try { console.log('Logging in to:', serverUrl);