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/core/components/shared/AllToolsNavButton.tsx b/frontend/src/core/components/shared/AllToolsNavButton.tsx index 1608d5bd3..3ba3bb70b 100644 --- a/frontend/src/core/components/shared/AllToolsNavButton.tsx +++ b/frontend/src/core/components/shared/AllToolsNavButton.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { Tooltip } from '@app/components/shared/Tooltip'; import AppsIcon from '@mui/icons-material/AppsRounded'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext'; @@ -11,13 +10,11 @@ import QuickAccessButton from '@app/components/shared/quickAccessBar/QuickAccess interface AllToolsNavButtonProps { activeButton: string; setActiveButton: (id: string) => void; - tooltipPosition?: 'left' | 'right' | 'top' | 'bottom'; } const AllToolsNavButton: React.FC = ({ activeButton, setActiveButton, - tooltipPosition = 'right' }) => { const { t } = useTranslation(); const { handleReaderToggle, handleBackToTools, selectedToolKey, leftPanelView } = useToolWorkflow(); @@ -55,26 +52,18 @@ const AllToolsNavButton: React.FC = ({ }; return ( - -
- } - label={t("quickAccess.allTools", "Tools")} - isActive={isActive} - onClick={handleNavClick} - href={navProps.href} - ariaLabel={t("quickAccess.allTools", "Tools")} - textClassName="all-tools-text" - component="a" - /> -
-
+
+ } + label={t("quickAccess.allTools", "Tools")} + isActive={isActive} + onClick={handleNavClick} + href={navProps.href} + ariaLabel={t("quickAccess.allTools", "Tools")} + textClassName="all-tools-text" + component="a" + /> +
); }; diff --git a/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx b/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx index 00c9787fd..3248ae9f1 100644 --- a/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx +++ b/frontend/src/core/components/shared/rightRail/ViewerAnnotationControls.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { ActionIcon, Popover } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import LocalIcon from '@app/components/shared/LocalIcon'; @@ -28,6 +28,7 @@ export default function ViewerAnnotationControls({ currentView, disabled = false const [selectedColor, setSelectedColor] = useState('#000000'); const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); const [isHoverColorPickerOpen, setIsHoverColorPickerOpen] = useState(false); + const [pendingAnnotationAfterRedaction, setPendingAnnotationAfterRedaction] = useState(false); // Viewer context for PDF controls - safely handle when not available const viewerContext = React.useContext(ViewerContext); @@ -47,9 +48,19 @@ export default function ViewerAnnotationControls({ currentView, disabled = false const isRedactMode = selectedTool === 'redact'; // Get redaction pending state and navigation guard - const { pendingCount: redactionPendingCount, isRedacting: _isRedacting } = useRedactionMode(); + const { pendingCount: redactionPendingCount, isRedacting: _isRedacting, activeType } = useRedactionMode(); const { requestNavigation, setHasUnsavedChanges } = useNavigationGuard(); - const { setRedactionMode, activateTextSelection, setRedactionConfig, setRedactionsApplied } = useRedaction(); + const { setRedactionMode, activateTextSelection, setRedactionConfig, setRedactionsApplied, redactionApiRef, setActiveType } = useRedaction(); + + const activateDrawingTools = useCallback(() => { + if (!signatureApiRef?.current) return; + try { + signatureApiRef.current.activateDrawMode(); + signatureApiRef.current.updateDrawSettings(selectedColor, 2); + } catch (error) { + console.log('Signature API not ready:', error); + } + }, [selectedColor, signatureApiRef]); // Turn off annotation mode when switching away from viewer useEffect(() => { @@ -60,15 +71,10 @@ export default function ViewerAnnotationControls({ currentView, disabled = false // Activate draw mode when annotation mode becomes active useEffect(() => { - if (viewerContext?.isAnnotationMode && signatureApiRef?.current && currentView === 'viewer') { - try { - signatureApiRef.current.activateDrawMode(); - signatureApiRef.current.updateDrawSettings(selectedColor, 2); - } catch (error) { - console.log('Signature API not ready:', error); - } + if (viewerContext?.isAnnotationMode && currentView === 'viewer') { + activateDrawingTools(); } - }, [viewerContext?.isAnnotationMode, currentView, selectedColor, signatureApiRef]); + }, [viewerContext?.isAnnotationMode, currentView, activateDrawingTools]); // Don't show any annotation controls in sign mode if (isSignMode) { @@ -99,6 +105,13 @@ export default function ViewerAnnotationControls({ currentView, disabled = false } }; + const exitRedactionMode = useCallback(() => { + navActions.setToolAndWorkbench(null, 'viewer'); + setLeftPanelView('toolPicker'); + setRedactionMode(false); + setActiveType(null); + }, [navActions, setLeftPanelView, setRedactionMode, setActiveType]); + // Handle redaction mode toggle const handleRedactionToggle = async () => { if (isRedactMode) { @@ -121,13 +134,22 @@ export default function ViewerAnnotationControls({ currentView, disabled = false }, 100); } else { // Exit redaction mode - keep viewer workbench and show all tools in sidebar - navActions.setToolAndWorkbench(null, 'viewer'); - setLeftPanelView('toolPicker'); - setRedactionMode(false); + exitRedactionMode(); } } else { await saveAnnotationsIfNeeded(); + if (viewerContext?.isAnnotationMode) { + viewerContext.setAnnotationMode(false); + if (signatureApiRef?.current) { + try { + signatureApiRef.current.deactivateTools(); + } catch (error) { + console.log('Unable to deactivate annotation tools:', error); + } + } + } + // Enter redaction mode - select redact tool with manual mode // If we're already in the viewer, keep the viewer workbench and open the tool sidebar if (workbench === 'viewer') { @@ -150,11 +172,32 @@ export default function ViewerAnnotationControls({ currentView, disabled = false setRedactionMode(true); // Activate text selection mode after a short delay setTimeout(() => { - activateTextSelection(); + const currentType = redactionApiRef.current?.getActiveType?.(); + if (currentType !== 'redactSelection') { + activateTextSelection(); + } }, 200); } }; + const startAnnotationMode = useCallback(() => { + viewerContext?.setAnnotationMode(true); + activateDrawingTools(); + }, [viewerContext, activateDrawingTools]); + + useEffect(() => { + let timer: ReturnType | null = null; + if (!isRedactMode && pendingAnnotationAfterRedaction) { + timer = setTimeout(() => { + setPendingAnnotationAfterRedaction(false); + startAnnotationMode(); + }, 200); + } + return () => { + if (timer) clearTimeout(timer); + }; + }, [isRedactMode, pendingAnnotationAfterRedaction, startAnnotationMode]); + return ( <> {/* Redaction Mode Toggle */} @@ -217,7 +260,7 @@ export default function ViewerAnnotationControls({ currentView, disabled = false radius="md" className="right-rail-icon" onClick={() => { - viewerContext?.toggleAnnotationMode(); + viewerContext?.setAnnotationMode(false); setIsHoverColorPickerOpen(false); // Close hover color picker when toggling off // Deactivate drawing tool when exiting annotation mode if (signatureApiRef?.current) { @@ -228,7 +271,7 @@ export default function ViewerAnnotationControls({ currentView, disabled = false } } }} - disabled={disabled} + disabled={disabled} aria-label="Drawing mode active" > @@ -259,14 +302,24 @@ export default function ViewerAnnotationControls({ currentView, disabled = false radius="md" className="right-rail-icon" onClick={() => { + const scheduleAnnotationAfterRedaction = () => { + setPendingAnnotationAfterRedaction(true); + exitRedactionMode(); + }; + + const beginAnnotation = () => { + if (isRedactMode) { + scheduleAnnotationAfterRedaction(); + } else { + startAnnotationMode(); + } + }; + // If in redaction mode with pending redactions, show warning modal if (isRedactMode && redactionPendingCount > 0) { - requestNavigation(() => { - viewerContext?.setAnnotationMode(true); - }); + requestNavigation(beginAnnotation); } else { - // Direct activation - useEffect will handle draw mode activation - viewerContext?.toggleAnnotationMode(); + beginAnnotation(); } }} disabled={disabled} diff --git a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx index 85087f302..022037fd3 100644 --- a/frontend/src/core/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/core/components/viewer/EmbedPdfViewer.tsx @@ -53,6 +53,7 @@ const EmbedPdfViewerContent = ({ getScrollState, getRotationState, isAnnotationMode, + setAnnotationMode, isAnnotationsVisible, exportActions, } = useViewer(); @@ -90,11 +91,9 @@ const EmbedPdfViewerContent = ({ // Navigation guard for unsaved changes const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = useNavigationGuard(); - // Check if we're in signature mode OR viewer annotation mode OR redaction mode const { selectedTool } = useNavigationState(); - // Tools that use the stamp/signature placement system with hover preview - const isSignatureMode = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage'; - // Check if we're in manual redaction mode + const isInAnnotationTool = selectedTool === 'sign' || selectedTool === 'addText' || selectedTool === 'addImage'; + const isSignatureMode = isInAnnotationTool; const isManualRedactMode = selectedTool === 'redact'; // Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations @@ -104,8 +103,15 @@ const EmbedPdfViewerContent = ({ // Enable redaction when the redact tool is selected and annotation mode is NOT active // This allows switching between redaction and annotation tools while redact is the selected tool const shouldEnableRedaction = (isManualRedactMode || isRedactionMode) && !isAnnotationMode; + + // Keep annotation mode enabled when entering placement tools without overriding manual toggles + useEffect(() => { + if (isInAnnotationTool) { + setAnnotationMode(true); + } + }, [isInAnnotationTool, setAnnotationMode]); const isPlacementOverlayActive = Boolean( - isSignatureMode && shouldEnableAnnotations && isPlacementMode && signatureConfig + isInAnnotationTool && isPlacementMode && signatureConfig ); // Track which file tab is active @@ -378,6 +384,7 @@ const EmbedPdfViewerContent = ({ file={effectiveFile.file} url={effectiveFile.url} enableAnnotations={shouldEnableAnnotations} + showBakedAnnotations={isAnnotationsVisible} enableRedaction={shouldEnableRedaction} isManualRedactionMode={isManualRedactMode} signatureApiRef={signatureApiRef as React.RefObject} diff --git a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx index 7a92fbad9..9291b5f70 100644 --- a/frontend/src/core/components/viewer/LocalEmbedPDF.tsx +++ b/frontend/src/core/components/viewer/LocalEmbedPDF.tsx @@ -56,13 +56,25 @@ interface LocalEmbedPDFProps { enableAnnotations?: boolean; enableRedaction?: boolean; isManualRedactionMode?: boolean; + showBakedAnnotations?: boolean; onSignatureAdded?: (annotation: any) => void; signatureApiRef?: React.RefObject; historyApiRef?: React.RefObject; redactionTrackerRef?: React.RefObject; } -export function LocalEmbedPDF({ file, url, enableAnnotations = false, enableRedaction = false, isManualRedactionMode = false, onSignatureAdded, signatureApiRef, historyApiRef, redactionTrackerRef }: LocalEmbedPDFProps) { +export function LocalEmbedPDF({ + file, + url, + enableAnnotations = false, + enableRedaction = false, + isManualRedactionMode = false, + showBakedAnnotations = true, + onSignatureAdded, + signatureApiRef, + historyApiRef, + redactionTrackerRef, +}: LocalEmbedPDFProps) { const { t } = useTranslation(); const [pdfUrl, setPdfUrl] = useState(null); const [, setAnnotations] = useState>([]); @@ -105,7 +117,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, enableReda }), createPluginRegistration(RenderPluginPackage, { withForms: true, - withAnnotations: true, + withAnnotations: showBakedAnnotations && !enableAnnotations, // Show baked annotations only when: visibility is ON and annotation layer is OFF }), // Register interaction manager (required for zoom and selection features) @@ -176,7 +188,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, enableReda // Register print plugin for printing PDFs createPluginRegistration(PrintPluginPackage), ]; - }, [pdfUrl]); + }, [pdfUrl, enableAnnotations, showBakedAnnotations]); // Initialize the engine with the React hook - use local WASM for offline support const { engine, isLoading, error } = usePdfiumEngine({ diff --git a/frontend/src/core/components/viewer/PdfViewerToolbar.tsx b/frontend/src/core/components/viewer/PdfViewerToolbar.tsx index a2e36d4e2..258b8ba43 100644 --- a/frontend/src/core/components/viewer/PdfViewerToolbar.tsx +++ b/frontend/src/core/components/viewer/PdfViewerToolbar.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { Button, Paper, Group, NumberInput } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { useViewer } from '@app/contexts/ViewerContext'; +import { Tooltip } from '@app/components/shared/Tooltip'; import FirstPageIcon from '@mui/icons-material/FirstPage'; import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos'; import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos'; @@ -209,21 +210,27 @@ export function PdfViewerToolbar({ {/* Dual Page Toggle */} - + + {/* Zoom Controls */} diff --git a/frontend/src/core/contexts/ViewerContext.tsx b/frontend/src/core/contexts/ViewerContext.tsx index 9217511ef..aa2dc497b 100644 --- a/frontend/src/core/contexts/ViewerContext.tsx +++ b/frontend/src/core/contexts/ViewerContext.tsx @@ -95,7 +95,6 @@ interface ViewerContextType { // Annotation/drawing mode for viewer isAnnotationMode: boolean; setAnnotationMode: (enabled: boolean) => void; - toggleAnnotationMode: () => void; // Active file index for multi-file viewing activeFileIndex: number; @@ -230,10 +229,6 @@ export const ViewerProvider: React.FC = ({ children }) => { setIsAnnotationModeState(enabled); }; - const toggleAnnotationMode = () => { - setIsAnnotationModeState(prev => !prev); - }; - // State getters - read from bridge refs const getScrollState = (): ScrollState => { return bridgeRefs.current.scroll?.state || { currentPage: 1, totalPages: 0 }; @@ -318,7 +313,6 @@ export const ViewerProvider: React.FC = ({ children }) => { toggleAnnotationsVisibility, isAnnotationMode, setAnnotationMode, - toggleAnnotationMode, // Active file index activeFileIndex, 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);