From 2534c532b7db82924d42d6c86e54c3f10c0f4d64 Mon Sep 17 00:00:00 2001 From: "stirlingbot[bot]" <195170888+stirlingbot[bot]@users.noreply.github.com> Date: Tue, 25 Nov 2025 10:01:38 +0000 Subject: [PATCH 01/40] :globe_with_meridians: [V2] Sync Translations + Update README Progress Table (#4946) ### Description of Changes This Pull Request was automatically generated to synchronize updates to translation files and documentation for the **V2 branch**. Below are the details of the changes made: #### **1. Synchronization of Translation Files** - Updated translation files (`frontend/public/locales/*/translation.json`) to reflect changes in the reference file `en-GB/translation.json`. - Ensured consistency and synchronization across all supported language files. - Highlighted any missing or incomplete translations. #### **2. Update README.md** - Generated the translation progress table in `README.md`. - Added a summary of the current translation status for all supported languages. - Included up-to-date statistics on translation coverage. #### **Why these changes are necessary** - Keeps translation files aligned with the latest reference updates. - Ensures the documentation reflects the current translation progress. --- Auto-generated by [create-pull-request][1]. [1]: https://github.com/peter-evans/create-pull-request Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com> --- README.md | 74 +++++++++++++++++++++++++++---------------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 1d7188998b..b5a58362d2 100644 --- a/README.md +++ b/README.md @@ -115,46 +115,46 @@ Stirling-PDF currently supports 40 languages! | Language | Progress | | -------------------------------------------- | -------------------------------------- | -| Arabic (العربية) (ar_AR) | ![94%](https://geps.dev/progress/94) | -| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![93%](https://geps.dev/progress/93) | -| Basque (Euskara) (eu_ES) | ![93%](https://geps.dev/progress/93) | -| Bulgarian (Български) (bg_BG) | ![94%](https://geps.dev/progress/94) | -| Catalan (Català) (ca_CA) | ![93%](https://geps.dev/progress/93) | -| Croatian (Hrvatski) (hr_HR) | ![93%](https://geps.dev/progress/93) | -| Czech (Česky) (cs_CZ) | ![91%](https://geps.dev/progress/91) | -| Danish (Dansk) (da_DK) | ![92%](https://geps.dev/progress/92) | -| Dutch (Nederlands) (nl_NL) | ![93%](https://geps.dev/progress/93) | +| Arabic (العربية) (ar_AR) | ![87%](https://geps.dev/progress/87) | +| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![86%](https://geps.dev/progress/86) | +| Basque (Euskara) (eu_ES) | ![86%](https://geps.dev/progress/86) | +| Bulgarian (Български) (bg_BG) | ![86%](https://geps.dev/progress/86) | +| Catalan (Català) (ca_CA) | ![85%](https://geps.dev/progress/85) | +| Croatian (Hrvatski) (hr_HR) | ![86%](https://geps.dev/progress/86) | +| Czech (Česky) (cs_CZ) | ![84%](https://geps.dev/progress/84) | +| Danish (Dansk) (da_DK) | ![85%](https://geps.dev/progress/85) | +| Dutch (Nederlands) (nl_NL) | ![85%](https://geps.dev/progress/85) | | English (English) (en_GB) | ![100%](https://geps.dev/progress/100) | | English (US) (en_US) | ![100%](https://geps.dev/progress/100) | -| French (Français) (fr_FR) | ![93%](https://geps.dev/progress/93) | -| German (Deutsch) (de_DE) | ![93%](https://geps.dev/progress/93) | -| Greek (Ελληνικά) (el_GR) | ![93%](https://geps.dev/progress/93) | -| Hindi (हिंदी) (hi_IN) | ![94%](https://geps.dev/progress/94) | -| Hungarian (Magyar) (hu_HU) | ![94%](https://geps.dev/progress/94) | -| Indonesian (Bahasa Indonesia) (id_ID) | ![93%](https://geps.dev/progress/93) | -| Irish (Gaeilge) (ga_IE) | ![94%](https://geps.dev/progress/94) | -| Italian (Italiano) (it_IT) | ![93%](https://geps.dev/progress/93) | -| Japanese (日本語) (ja_JP) | ![94%](https://geps.dev/progress/94) | -| Korean (한국어) (ko_KR) | ![94%](https://geps.dev/progress/94) | -| Norwegian (Norsk) (no_NB) | ![93%](https://geps.dev/progress/93) | -| Persian (فارسی) (fa_IR) | ![94%](https://geps.dev/progress/94) | -| Polish (Polski) (pl_PL) | ![93%](https://geps.dev/progress/93) | -| Portuguese (Português) (pt_PT) | ![93%](https://geps.dev/progress/93) | -| Portuguese Brazilian (Português) (pt_BR) | ![93%](https://geps.dev/progress/93) | -| Romanian (Română) (ro_RO) | ![93%](https://geps.dev/progress/93) | -| Russian (Русский) (ru_RU) | ![94%](https://geps.dev/progress/94) | -| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![93%](https://geps.dev/progress/93) | -| Simplified Chinese (简体中文) (zh_CN) | ![94%](https://geps.dev/progress/94) | -| Slovakian (Slovensky) (sk_SK) | ![93%](https://geps.dev/progress/93) | -| Slovenian (Slovenščina) (sl_SI) | ![94%](https://geps.dev/progress/94) | -| Spanish (Español) (es_ES) | ![94%](https://geps.dev/progress/94) | -| Swedish (Svenska) (sv_SE) | ![93%](https://geps.dev/progress/93) | -| Thai (ไทย) (th_TH) | ![93%](https://geps.dev/progress/93) | +| French (Français) (fr_FR) | ![85%](https://geps.dev/progress/85) | +| German (Deutsch) (de_DE) | ![86%](https://geps.dev/progress/86) | +| Greek (Ελληνικά) (el_GR) | ![86%](https://geps.dev/progress/86) | +| Hindi (हिंदी) (hi_IN) | ![86%](https://geps.dev/progress/86) | +| Hungarian (Magyar) (hu_HU) | ![86%](https://geps.dev/progress/86) | +| Indonesian (Bahasa Indonesia) (id_ID) | ![85%](https://geps.dev/progress/85) | +| Irish (Gaeilge) (ga_IE) | ![86%](https://geps.dev/progress/86) | +| Italian (Italiano) (it_IT) | ![85%](https://geps.dev/progress/85) | +| Japanese (日本語) (ja_JP) | ![86%](https://geps.dev/progress/86) | +| Korean (한국어) (ko_KR) | ![86%](https://geps.dev/progress/86) | +| Norwegian (Norsk) (no_NB) | ![86%](https://geps.dev/progress/86) | +| Persian (فارسی) (fa_IR) | ![86%](https://geps.dev/progress/86) | +| Polish (Polski) (pl_PL) | ![86%](https://geps.dev/progress/86) | +| Portuguese (Português) (pt_PT) | ![86%](https://geps.dev/progress/86) | +| Portuguese Brazilian (Português) (pt_BR) | ![86%](https://geps.dev/progress/86) | +| Romanian (Română) (ro_RO) | ![85%](https://geps.dev/progress/85) | +| Russian (Русский) (ru_RU) | ![86%](https://geps.dev/progress/86) | +| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![86%](https://geps.dev/progress/86) | +| Simplified Chinese (简体中文) (zh_CN) | ![87%](https://geps.dev/progress/87) | +| Slovakian (Slovensky) (sk_SK) | ![86%](https://geps.dev/progress/86) | +| Slovenian (Slovenščina) (sl_SI) | ![86%](https://geps.dev/progress/86) | +| Spanish (Español) (es_ES) | ![86%](https://geps.dev/progress/86) | +| Swedish (Svenska) (sv_SE) | ![86%](https://geps.dev/progress/86) | +| Thai (ไทย) (th_TH) | ![86%](https://geps.dev/progress/86) | | Tibetan (བོད་ཡིག་) (bo_CN) | ![65%](https://geps.dev/progress/65) | -| Traditional Chinese (繁體中文) (zh_TW) | ![94%](https://geps.dev/progress/94) | -| Turkish (Türkçe) (tr_TR) | ![94%](https://geps.dev/progress/94) | -| Ukrainian (Українська) (uk_UA) | ![94%](https://geps.dev/progress/94) | -| Vietnamese (Tiếng Việt) (vi_VN) | ![93%](https://geps.dev/progress/93) | +| Traditional Chinese (繁體中文) (zh_TW) | ![87%](https://geps.dev/progress/87) | +| Turkish (Türkçe) (tr_TR) | ![86%](https://geps.dev/progress/86) | +| Ukrainian (Українська) (uk_UA) | ![86%](https://geps.dev/progress/86) | +| Vietnamese (Tiếng Việt) (vi_VN) | ![86%](https://geps.dev/progress/86) | | Malayalam (മലയാളം) (ml_IN) | ![73%](https://geps.dev/progress/73) | ## Stirling PDF Enterprise From 64d343b76544c46646052086904b10a077fb20cb Mon Sep 17 00:00:00 2001 From: James Brunton Date: Tue, 25 Nov 2025 11:56:25 +0000 Subject: [PATCH 02/40] Add SSO login options to desktop app (#4954) # Description of Changes Add SSO login options to desktop app --- .../public/locales/en-GB/translation.json | 18 +- frontend/src-tauri/Cargo.lock | 59 ++++ frontend/src-tauri/Cargo.toml | 9 +- frontend/src-tauri/src/commands/auth.rs | 299 +++++++++++++++++- frontend/src-tauri/src/commands/mod.rs | 1 + frontend/src-tauri/src/lib.rs | 9 + .../components/SetupWizard/LoginForm.tsx | 93 +++++- .../components/SetupWizard/SetupWizard.css | 9 +- .../desktop/components/SetupWizard/index.tsx | 8 +- frontend/src/desktop/constants/connection.ts | 11 +- frontend/src/desktop/services/authService.ts | 112 ++++++- .../src/desktop/utils/oauthCallbackHtml.ts | 154 +++++++++ 12 files changed, 753 insertions(+), 29 deletions(-) create mode 100644 frontend/src/desktop/utils/oauthCallbackHtml.ts diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index df5a96a3d2..821357cd41 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -5775,9 +5775,23 @@ }, "error": { "emptyUsername": "Please enter your username", - "emptyPassword": "Please enter your password" + "emptyPassword": "Please enter your password", + "oauthFailed": "OAuth login failed. Please try again." }, - "submit": "Login" + "submit": "Login", + "signInWith": "Sign in with", + "oauthPending": "Opening browser for authentication...", + "orContinueWith": "Or continue with email" + } + }, + "oauth": { + "success": { + "title": "Authentication Successful", + "message": "You can close this window and return to Stirling PDF." + }, + "error": { + "title": "Authentication Failed", + "message": "Authentication was not successful. You can close this window and try again." } }, "settings": { diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 7ddc3dbfd6..8d8bc6ffcc 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -81,6 +81,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -558,6 +564,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "combine" version = "4.6.7" @@ -4215,22 +4227,29 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" name = "stirling-pdf" version = "0.1.0" dependencies = [ + "base64 0.22.1", "core-foundation 0.10.1", "core-services", "keyring", "log", + "rand 0.8.5", "reqwest 0.11.27", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-fs", "tauri-plugin-http", "tauri-plugin-log", + "tauri-plugin-opener", "tauri-plugin-shell", "tauri-plugin-single-instance", "tauri-plugin-store", + "tiny_http", "tokio", + "url", + "urlencoding", ] [[package]] @@ -4646,6 +4665,28 @@ dependencies = [ "time", ] +[[package]] +name = "tauri-plugin-opener" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c26b72571d25dee25667940027114e60f569fc3974f8cefbe50c2cbc5fd65e3b" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation 0.3.2", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", + "url", + "windows", + "zbus", +] + [[package]] name = "tauri-plugin-shell" version = "2.3.3" @@ -4895,6 +4936,18 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.8.1" @@ -5273,6 +5326,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.3.0" diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 968edc53d6..c32ff2fa01 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -31,9 +31,16 @@ tauri-plugin-fs = "2.4.4" 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" -tokio = { version = "1.0", features = ["time"] } +tokio = { version = "1.0", features = ["time", "sync"] } reqwest = { version = "0.11", features = ["json"] } +tiny_http = "0.12" +url = "2.5" +urlencoding = "2.1" +sha2 = "0.10" +base64 = "0.22" +rand = "0.8" [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.10" diff --git a/frontend/src-tauri/src/commands/auth.rs b/frontend/src-tauri/src/commands/auth.rs index 64437a1e20..30ec0d6c40 100644 --- a/frontend/src-tauri/src/commands/auth.rs +++ b/frontend/src-tauri/src/commands/auth.rs @@ -1,7 +1,13 @@ use keyring::Entry; use serde::{Deserialize, Serialize}; +use std::sync::{Arc, Mutex}; use tauri::AppHandle; use tauri_plugin_store::StoreExt; +use tiny_http::{Response, Server}; +use sha2::{Sha256, Digest}; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use rand::{thread_rng, Rng}; +use rand::distributions::Alphanumeric; const STORE_FILE: &str = "connection.json"; const USER_INFO_KEY: &str = "user_info"; @@ -177,12 +183,13 @@ pub async fn login( server_url: String, username: String, password: String, + supabase_key: String, + saas_server_url: String, ) -> Result { 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 from environment - let saas_server_url = env!("VITE_SAAS_SERVER_URL"); + // 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)" }); @@ -193,10 +200,6 @@ pub async fn login( // Supabase authentication flow let login_url = format!("{}/auth/v1/token?grant_type=password", server_url.trim_end_matches('/')); - // Supabase public API key from environment variable (required at compile time) - // Set VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY before building - let supabase_key = env!("VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY"); - let request_body = serde_json::json!({ "email": username, "password": password, @@ -205,7 +208,7 @@ pub async fn login( let response = client .post(&login_url) .header("Content-Type", "application/json;charset=UTF-8") - .header("apikey", supabase_key) + .header("apikey", &supabase_key) .header("Authorization", format!("Bearer {}", supabase_key)) .header("X-Client-Info", "supabase-js-web/2.58.0") .header("X-Supabase-Api-Version", "2024-01-01") @@ -301,3 +304,285 @@ pub async fn login( }) } } + +/// Generate PKCE code_verifier (random 43-128 character string) +fn generate_code_verifier() -> String { + thread_rng() + .sample_iter(&Alphanumeric) + .take(128) + .map(char::from) + .collect() +} + +/// Generate PKCE code_challenge from code_verifier (SHA256 hash, base64url encoded) +fn generate_code_challenge(code_verifier: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(code_verifier.as_bytes()); + let hash = hasher.finalize(); + URL_SAFE_NO_PAD.encode(hash) +} + +/// Opens the system browser for OAuth authentication with localhost callback server +/// Uses 127.0.0.1 (loopback) which is supported by Google OAuth with any port +/// Implements PKCE (Proof Key for Code Exchange) for secure OAuth flow +#[tauri::command] +pub async fn start_oauth_login( + _app_handle: AppHandle, + provider: String, + auth_server_url: String, + supabase_key: String, + success_html: String, + error_html: String, +) -> Result { + log::info!("Starting OAuth login for provider: {} with auth server: {}", provider, auth_server_url); + + // Generate PKCE code_verifier and code_challenge + let code_verifier = generate_code_verifier(); + let code_challenge = generate_code_challenge(&code_verifier); + + log::debug!("PKCE code_verifier generated: {} chars", code_verifier.len()); + log::debug!("PKCE code_challenge: {}", code_challenge); + + // Use port 0 to let OS assign an available port (avoids port reuse issues) + // Supabase allows any localhost port via redirect_to parameter + let server = Server::http("127.0.0.1:0") + .map_err(|e| format!("Failed to create OAuth callback server: {}", e))?; + + let port = match server.server_addr() { + tiny_http::ListenAddr::IP(addr) => addr.port(), + #[cfg(unix)] + tiny_http::ListenAddr::Unix(_) => { + return Err("OAuth callback server bound to Unix socket instead of TCP port".to_string()) + } + }; + + let callback_url = format!("http://127.0.0.1:{}/callback", port); + log::info!("OAuth callback URL: {}", callback_url); + + // Build OAuth URL with authorization code flow + PKCE + // Note: Use redirect_to (not redirect_uri) to tell Supabase where to redirect after processing + // Supabase handles its own /auth/v1/callback internally + // prompt=select_account forces Google to show account picker every time + let oauth_url = format!( + "{}/auth/v1/authorize?provider={}&redirect_to={}&code_challenge={}&code_challenge_method=S256&prompt=select_account", + auth_server_url.trim_end_matches('/'), + provider, + urlencoding::encode(&callback_url), + urlencoding::encode(&code_challenge) + ); + + log::info!("Full OAuth URL: {}", oauth_url); + log::info!("========================================"); + + // Open system browser + if let Err(e) = tauri_plugin_opener::open_url(&oauth_url, None::<&str>) { + log::error!("Failed to open browser: {}", e); + return Err(format!("Failed to open browser: {}", e)); + } + + // Wait for OAuth callback with timeout + let result = Arc::new(Mutex::new(None)); + let result_clone = Arc::clone(&result); + + // Spawn server handling in blocking thread + let server_handle = std::thread::spawn(move || { + log::info!("Waiting for OAuth callback..."); + + // Wait for callback (with timeout) + for _ in 0..120 { // 2 minute timeout + if let Ok(Some(request)) = server.recv_timeout(std::time::Duration::from_secs(1)) { + let url_str = format!("http://127.0.0.1{}", request.url()); + log::debug!("Received OAuth callback: {}", url_str); + + // Parse the authorization code from URL + let callback_data = parse_oauth_callback(&url_str); + + // Respond with appropriate HTML based on result + let html_response = match &callback_data { + Ok(_) => { + log::info!("Successfully extracted authorization code"); + success_html.clone() + } + Err(error_msg) => { + log::warn!("OAuth callback error: {}", error_msg); + // Replace {error} placeholder with actual error message + error_html.replace("{error}", error_msg) + } + }; + + let response = Response::from_string(html_response) + .with_header(tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]).unwrap()) + .with_header(tiny_http::Header::from_bytes(&b"Connection"[..], &b"close"[..]).unwrap()); + + let _ = request.respond(response); + + // Store result and exit loop + let mut result_lock = result_clone.lock().unwrap(); + *result_lock = Some(callback_data); + break; + } + } + }); + + // Wait for server thread to complete + server_handle.join() + .map_err(|_| "OAuth callback server thread panicked".to_string())?; + + // Get result + let callback_data = result.lock().unwrap().take() + .ok_or_else(|| "OAuth callback timeout - no response received".to_string())?; + + // Handle the callback data - exchange authorization code for tokens + match callback_data? { + OAuthCallbackData::Code { code, redirect_uri } => { + log::info!("OAuth completed with authorization code flow, exchanging code..."); + exchange_code_for_token(&auth_server_url, &code, &redirect_uri, &code_verifier, &supabase_key).await + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct OAuthCallbackResult { + pub access_token: String, + pub refresh_token: Option, + pub expires_in: Option, +} + +// Internal enum for handling authorization code flow +#[derive(Debug, Clone)] +enum OAuthCallbackData { + Code { code: String, redirect_uri: String }, +} + +/// Exchange authorization code for access token using PKCE +async fn exchange_code_for_token( + auth_server_url: &str, + code: &str, + _redirect_uri: &str, + code_verifier: &str, + supabase_key: &str, +) -> Result { + log::info!("Exchanging authorization code for access token with PKCE"); + + let client = reqwest::Client::new(); + // grant_type goes in query string, not body! + let token_url = format!("{}/auth/v1/token?grant_type=pkce", auth_server_url.trim_end_matches('/')); + + // Body should be JSON with auth_code and code_verifier + let body = serde_json::json!({ + "auth_code": code, + "code_verifier": code_verifier, + }); + + log::debug!("Token exchange URL: {}", token_url); + log::debug!("Code verifier length: {} chars", code_verifier.len()); + + let response = client + .post(&token_url) + .header("Content-Type", "application/json") + .header("apikey", supabase_key) + .header("Authorization", format!("Bearer {}", supabase_key)) + .json(&body) + .send() + .await + .map_err(|e| format!("Failed to exchange code for token: {}", e))?; + + let status = response.status(); + if !status.is_success() { + let error_text = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + log::error!("Token exchange failed with status {}: {}", status, error_text); + return Err(format!("Token exchange failed: {}", error_text)); + } + + // Parse token response + let token_response: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Failed to parse token response: {}", e))?; + + log::info!("Token exchange successful"); + + let access_token = token_response + .get("access_token") + .and_then(|v| v.as_str()) + .ok_or_else(|| "No access_token in token response".to_string())? + .to_string(); + + let refresh_token = token_response + .get("refresh_token") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + let expires_in = token_response + .get("expires_in") + .and_then(|v| v.as_i64()); + + Ok(OAuthCallbackResult { + access_token, + refresh_token, + expires_in, + }) +} + +fn parse_oauth_callback(url_str: &str) -> Result { + // Parse URL to extract authorization code or error + let parsed_url = url::Url::parse(url_str) + .map_err(|e| format!("Failed to parse callback URL: {}", e))?; + + // Check for OAuth error first (error responses take precedence) + let mut error = None; + let mut error_description = None; + let mut code = None; + + for (key, value) in parsed_url.query_pairs() { + match key.as_ref() { + "error" => error = Some(value.to_string()), + "error_description" => error_description = Some(value.to_string()), + "code" => code = Some(value.to_string()), + _ => {} + } + } + + // If OAuth provider returned an error, fail immediately + if let Some(error_code) = error { + let error_msg = if let Some(description) = error_description { + format!("OAuth authentication failed: {} - {}", error_code, description) + } else { + format!("OAuth authentication failed: {}", error_code) + }; + log::error!("{}", error_msg); + return Err(error_msg); + } + + // If we have a code, return it + if let Some(auth_code) = code { + log::info!("Found authorization code in callback"); + + // Reconstruct the redirect_uri (without query params) for token exchange + let redirect_uri = if let Some(port) = parsed_url.port() { + format!("{}://{}:{}{}", + parsed_url.scheme(), + parsed_url.host_str().unwrap_or("127.0.0.1"), + port, + parsed_url.path() + ) + } else { + format!("{}://{}{}", + parsed_url.scheme(), + parsed_url.host_str().unwrap_or("127.0.0.1"), + parsed_url.path() + ) + }; + + return Ok(OAuthCallbackData::Code { + code: auth_code, + redirect_uri, + }); + } + + // No authorization code or error found + Err("No authorization code or error found in OAuth callback".to_string()) +} diff --git a/frontend/src-tauri/src/commands/mod.rs b/frontend/src-tauri/src/commands/mod.rs index 0f58f2a1da..ceca51dd88 100644 --- a/frontend/src-tauri/src/commands/mod.rs +++ b/frontend/src-tauri/src/commands/mod.rs @@ -21,6 +21,7 @@ pub use auth::{ login, save_auth_token, save_user_info, + start_oauth_login, }; pub use default_app::{is_default_pdf_handler, set_as_default_pdf_handler}; pub use health::check_backend_health; diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index f3b8259dc4..838bdf1ec5 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -25,6 +25,7 @@ use commands::{ set_connection_mode, set_as_default_pdf_handler, start_backend, + start_oauth_login, }; use state::connection_state::AppConnectionState; use utils::{add_log, get_tauri_logs}; @@ -32,6 +33,12 @@ use utils::{add_log, get_tauri_logs}; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .plugin( + tauri_plugin_log::Builder::new() + .level(log::LevelFilter::Info) + .build() + ) + .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_http::init()) @@ -95,6 +102,7 @@ pub fn run() { save_user_info, get_user_info, clear_user_info, + start_oauth_login, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") @@ -115,6 +123,7 @@ pub fn run() { RunEvent::Opened { urls } => { add_log(format!("📂 Tauri file opened event: {:?}", urls)); let mut added_files = false; + for url in urls { let url_str = url.as_str(); if url_str.starts_with("file://") { diff --git a/frontend/src/desktop/components/SetupWizard/LoginForm.tsx b/frontend/src/desktop/components/SetupWizard/LoginForm.tsx index 5facdf96b8..42e0a61cdc 100644 --- a/frontend/src/desktop/components/SetupWizard/LoginForm.tsx +++ b/frontend/src/desktop/components/SetupWizard/LoginForm.tsx @@ -1,6 +1,10 @@ import React, { useState } from 'react'; -import { Stack, TextInput, PasswordInput, Button, Text } from '@mantine/core'; +import { Stack, TextInput, PasswordInput, Button, Text, Divider, Group } 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; @@ -14,6 +18,7 @@ export const LoginForm: React.FC = ({ serverUrl, isSaaS = false, const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [validationError, setValidationError] = useState(null); + const [oauthLoading, setOauthLoading] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -33,6 +38,51 @@ export const LoginForm: React.FC = ({ serverUrl, isSaaS = false, 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 (
@@ -40,6 +90,47 @@ export const LoginForm: React.FC = ({ serverUrl, isSaaS = false, {t('setup.login.connectingTo', 'Connecting to:')} {isSaaS ? 'stirling.com' : serverUrl} + {/* OAuth Login Buttons - Only show for SaaS */} + {isSaaS && ( + <> + + + + + + + + {oauthLoading && ( + + {t('setup.login.oauthPending', 'Opening browser for authentication...')} + + )} + + + + + )} + = ({ onComplete }) => { setLoading(true); setError(null); - await authService.login(serverConfig.url, username, password); + // Only attempt password login if a password is provided + // If password is empty, assume OAuth login already completed + const isAlreadyAuthenticated = await authService.isAuthenticated(); + if (!isAlreadyAuthenticated && password) { + await authService.login(serverConfig.url, username, password); + } + await connectionModeService.switchToSaaS(serverConfig.url); await tauriBackendService.startBackend(); onComplete(); diff --git a/frontend/src/desktop/constants/connection.ts b/frontend/src/desktop/constants/connection.ts index 7f7d538362..053bfb47b8 100644 --- a/frontend/src/desktop/constants/connection.ts +++ b/frontend/src/desktop/constants/connection.ts @@ -2,11 +2,10 @@ * Connection-related constants for desktop app */ -// SaaS server URL from environment variable (required) +// SaaS server URL from environment variable // The SaaS authentication server -// Will throw error if VITE_SAAS_SERVER_URL is not set -if (!import.meta.env.VITE_SAAS_SERVER_URL) { - throw new Error('VITE_SAAS_SERVER_URL environment variable is required'); -} +export const STIRLING_SAAS_URL: string = import.meta.env.VITE_SAAS_SERVER_URL || ''; -export const STIRLING_SAAS_URL = import.meta.env.VITE_SAAS_SERVER_URL; +// Supabase publishable key from environment variable +// Used for SaaS authentication +export const SUPABASE_KEY: string = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || ''; diff --git a/frontend/src/desktop/services/authService.ts b/frontend/src/desktop/services/authService.ts index 65d0c4cce6..56bb7c0854 100644 --- a/frontend/src/desktop/services/authService.ts +++ b/frontend/src/desktop/services/authService.ts @@ -1,5 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; import axios from 'axios'; +import { STIRLING_SAAS_URL, SUPABASE_KEY } from '@app/constants/connection'; export interface UserInfo { username: string; @@ -12,7 +13,13 @@ interface LoginResponse { email: string | null; } -export type AuthStatus = 'authenticated' | 'unauthenticated' | 'refreshing'; +interface OAuthCallbackResult { + access_token: string; + refresh_token: string | null; + expires_in: number | null; +} + +export type AuthStatus = 'authenticated' | 'unauthenticated' | 'refreshing' | 'oauth_pending'; export class AuthService { private static instance: AuthService; @@ -50,11 +57,23 @@ export class AuthService { try { console.log('Logging in to:', serverUrl); + // Validate SaaS configuration if connecting to SaaS + if (serverUrl === STIRLING_SAAS_URL) { + 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'); + } + } + // Call Rust login command (bypasses CORS) const response = await invoke('login', { serverUrl, username, password, + supabaseKey: SUPABASE_KEY, + saasServerUrl: STIRLING_SAAS_URL, }); const { token, username: returnedUsername, email } = response; @@ -80,13 +99,7 @@ export class AuthService { } catch (error) { console.error('Login failed:', error); this.setAuthStatus('unauthenticated', null); - - // Rust commands return string errors - if (typeof error === 'string') { - throw new Error(error); - } - - throw new Error('Login failed. Please try again.'); + throw error; } } @@ -193,6 +206,89 @@ export class AuthService { this.setAuthStatus('unauthenticated', null); } } + + /** + * Start OAuth login flow by opening system browser with localhost callback + */ + async loginWithOAuth(provider: string, authServerUrl: string, successHtml: string, errorHtml: string): Promise { + try { + console.log('Starting OAuth login with provider:', provider); + this.setAuthStatus('oauth_pending', null); + + // Validate Supabase key is configured for OAuth + if (!SUPABASE_KEY) { + throw new Error('VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured'); + } + + // Call Rust command which: + // 1. Starts localhost HTTP server on random port + // 2. Opens browser to OAuth provider + // 3. Waits for callback + // 4. Returns tokens + const result = await invoke('start_oauth_login', { + provider, + authServerUrl, + supabaseKey: SUPABASE_KEY, + successHtml, + errorHtml, + }); + + console.log('OAuth authentication successful, storing tokens'); + + // Save the access token to keyring + await invoke('save_auth_token', { token: result.access_token }); + + // Fetch user info from Supabase using the access token + const userInfo = await this.fetchSupabaseUserInfo(authServerUrl, result.access_token); + + // Save user info to store + await invoke('save_user_info', { + username: userInfo.username, + email: userInfo.email || null, + }); + + this.setAuthStatus('authenticated', userInfo); + console.log('OAuth login successful'); + + return userInfo; + } catch (error) { + console.error('Failed to complete OAuth login:', error); + this.setAuthStatus('unauthenticated', null); + throw error; + } + } + + /** + * Fetch user info from Supabase using access token + */ + private async fetchSupabaseUserInfo(authServerUrl: string, accessToken: string): Promise { + try { + const userEndpoint = `${authServerUrl}/auth/v1/user`; + + const response = await axios.get(userEndpoint, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'apikey': SUPABASE_KEY, + }, + }); + + const data = response.data; + console.log('User info fetched:', data.email); + + return { + username: data.user_metadata?.full_name || data.email || 'Unknown', + email: data.email, + }; + } catch (error) { + console.error('Failed to fetch user info from Supabase:', error); + // Fallback to basic info + return { + username: 'User', + email: undefined, + }; + } + } + } export const authService = AuthService.getInstance(); diff --git a/frontend/src/desktop/utils/oauthCallbackHtml.ts b/frontend/src/desktop/utils/oauthCallbackHtml.ts new file mode 100644 index 0000000000..a2119d9981 --- /dev/null +++ b/frontend/src/desktop/utils/oauthCallbackHtml.ts @@ -0,0 +1,154 @@ +/** + * Builds HTML for OAuth callback pages with i18n and dark mode support + */ + +interface OAuthCallbackHtmlOptions { + title: string; + message: string; + isError?: boolean; + errorPlaceholder?: boolean; +} + +/** + * Generates OAuth callback HTML with automatic dark mode support + */ +export function buildOAuthCallbackHtml({ + title, + message, + isError = false, + errorPlaceholder = false, +}: OAuthCallbackHtmlOptions): string { + const iconColor = isError ? '#d32f2f' : '#2e7d32'; + const iconColorDark = isError ? '#ef5350' : '#66bb6a'; + const icon = isError ? '✗' : '✓'; + + return ` + + + + + ${title} + + + +
+
${icon}
+

${title}

+

${message}

+ ${errorPlaceholder ? '
{error}
' : ''} +
+ +`; +} From 991e03970ed551edb34ce4a12f5de93dea8799cd Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Tue, 25 Nov 2025 12:30:44 +0000 Subject: [PATCH 03/40] Fix settings loading (#4997) # Description of Changes --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### Translations (if applicable) - [ ] I ran [`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- frontend/src/core/hooks/useAdminSettings.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/core/hooks/useAdminSettings.ts b/frontend/src/core/hooks/useAdminSettings.ts index 9054d00bac..e88e06400c 100644 --- a/frontend/src/core/hooks/useAdminSettings.ts +++ b/frontend/src/core/hooks/useAdminSettings.ts @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import apiClient from '@app/services/apiClient'; import { mergePendingSettings, isFieldPending, hasPendingChanges } from '@app/utils/settingsPendingHelper'; @@ -51,7 +51,7 @@ export function useAdminSettings( const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - const fetchSettings = async () => { + const fetchSettings = useCallback(async () => { try { setLoading(true); @@ -87,7 +87,7 @@ export function useAdminSettings( } finally { setLoading(false); } - }; + }, [sectionName]); const saveSettings = async () => { try { From 2d8b0ff08cb4aa36ac968a6d6332ffa6b28ecadf Mon Sep 17 00:00:00 2001 From: James Brunton Date: Tue, 25 Nov 2025 13:11:13 +0000 Subject: [PATCH 04/40] Various fixes for desktop (#4976) # Description of Changes - ~Force classic logo~ - Refer to email instead of username in SaaS sign in flow - Allow drag-and-drop files into desktop app - Convert terminology & icons from upload/download to open/save in desktop version --- .../public/locales/en-GB/translation.json | 20 +++++++++++-- frontend/src-tauri/src/lib.rs | 30 +++++++++++++++---- .../components/fileEditor/AddFileCard.tsx | 10 +++++-- .../fileEditor/FileEditorThumbnail.tsx | 8 +++-- .../fileManager/EmptyFilesState.tsx | 10 +++++-- .../components/fileManager/FileActions.tsx | 8 +++-- .../fileManager/FileSourceButtons.tsx | 8 +++-- .../components/pageEditor/FileThumbnail.tsx | 10 +++++-- .../components/shared/FilePickerModal.tsx | 6 ++-- .../core/components/shared/LandingPage.tsx | 10 +++++-- .../src/core/components/shared/RightRail.tsx | 10 +++++-- .../EditTableOfContentsWorkbenchView.tsx | 4 ++- .../tools/shared/ReviewToolStep.tsx | 8 +++-- .../components/tools/showJS/ShowJSView.tsx | 4 ++- frontend/src/core/hooks/useFileActionIcons.ts | 15 ++++++++++ .../core/hooks/useFileActionTerminology.ts | 22 ++++++++++++++ .../components/SetupWizard/LoginForm.tsx | 12 ++++++-- .../src/desktop/hooks/useFileActionIcons.ts | 15 ++++++++++ .../desktop/hooks/useFileActionTerminology.ts | 22 ++++++++++++++ 19 files changed, 195 insertions(+), 37 deletions(-) create mode 100644 frontend/src/core/hooks/useFileActionIcons.ts create mode 100644 frontend/src/core/hooks/useFileActionTerminology.ts create mode 100644 frontend/src/desktop/hooks/useFileActionIcons.ts create mode 100644 frontend/src/desktop/hooks/useFileActionTerminology.ts diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 821357cd41..bf287c732c 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -130,6 +130,8 @@ "save": "Save", "saveToBrowser": "Save to Browser", "download": "Download", + "downloadUnavailable": "Download unavailable for this item", + "saveUnavailable": "Save unavailable for this item", "pin": "Pin File (keep active after tool run)", "unpin": "Unpin File (replace after tool run)", "undoOperationTooltip": "Click to undo the last operation and restore the original files", @@ -872,7 +874,8 @@ }, "landing": { "addFiles": "Add Files", - "uploadFromComputer": "Upload from computer" + "uploadFromComputer": "Upload from computer", + "openFromComputer": "Open from computer" }, "viewPdf": { "tags": "view,read,annotate,text,image,highlight,edit", @@ -3877,6 +3880,7 @@ "exportAll": "Export PDF", "downloadSelected": "Download Selected Files", "downloadAll": "Download All", + "saveAll": "Save All", "toggleTheme": "Toggle Theme", "toggleBookmarks": "Toggle Bookmarks", "language": "Language", @@ -4570,12 +4574,17 @@ "or": "or", "dropFileHere": "Drop file here or click to upload", "dropFilesHere": "Drop files here or click the upload button", + "dropFilesHereOpen": "Drop files here or click the open button", "pdfFilesOnly": "PDF files only", "supportedFileTypes": "Supported file types", "upload": "Upload", "uploadFile": "Upload File", "uploadFiles": "Upload Files", + "open": "Open", + "openFile": "Open File", + "openFiles": "Open Files", "noFilesInStorage": "No files available in storage. Upload some files first.", + "noFilesInStorageOpen": "No files available in storage. Open some files first.", "selectFromStorage": "Select from Storage", "backToTools": "Back to Tools", "addFiles": "Add Files", @@ -4621,6 +4630,8 @@ "myFiles": "My Files", "noRecentFiles": "No recent files found", "googleDriveNotAvailable": "Google Drive integration not available", + "downloadSelected": "Download Selected", + "saveSelected": "Save Selected", "openFiles": "Open Files", "openFile": "Open File", "details": "File Details", @@ -5769,12 +5780,17 @@ "label": "Username", "placeholder": "Enter your username" }, + "email": { + "label": "Email", + "placeholder": "Enter your email" + }, "password": { "label": "Password", "placeholder": "Enter your password" }, "error": { "emptyUsername": "Please enter your username", + "emptyEmail": "Please enter your email", "emptyPassword": "Please enter your password", "oauthFailed": "OAuth login failed. Please try again." }, @@ -6032,4 +6048,4 @@ "tags": "text,annotation,label", "applySignatures": "Apply Text" } -} \ No newline at end of file +} diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 838bdf1ec5..34e49c7bed 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -119,6 +119,28 @@ pub fn run() { cleanup_backend(); // Allow the window to close } + RunEvent::WindowEvent { event: WindowEvent::DragDrop(drag_drop_event), .. } => { + use tauri::DragDropEvent; + match drag_drop_event { + DragDropEvent::Drop { paths, .. } => { + add_log(format!("📂 Files dropped: {:?}", paths)); + let mut added_files = false; + + for path in paths { + if let Some(path_str) = path.to_str() { + add_log(format!("📂 Processing dropped file: {}", path_str)); + add_opened_file(path_str.to_string()); + added_files = true; + } + } + + if added_files { + let _ = app_handle.emit("files-changed", ()); + } + } + _ => {} + } + } #[cfg(target_os = "macos")] RunEvent::Opened { urls } => { add_log(format!("📂 Tauri file opened event: {:?}", urls)); @@ -128,11 +150,9 @@ pub fn run() { let url_str = url.as_str(); if url_str.starts_with("file://") { let file_path = url_str.strip_prefix("file://").unwrap_or(url_str); - if file_path.ends_with(".pdf") { - add_log(format!("📂 Processing opened PDF: {}", file_path)); - add_opened_file(file_path.to_string()); - added_files = true; - } + add_log(format!("📂 Processing opened file: {}", file_path)); + add_opened_file(file_path.to_string()); + added_files = true; } } // Emit a generic notification that files were added (frontend will re-read storage) diff --git a/frontend/src/core/components/fileEditor/AddFileCard.tsx b/frontend/src/core/components/fileEditor/AddFileCard.tsx index fe833c17fa..4cc3741d31 100644 --- a/frontend/src/core/components/fileEditor/AddFileCard.tsx +++ b/frontend/src/core/components/fileEditor/AddFileCard.tsx @@ -6,6 +6,8 @@ import { useFilesModalContext } from '@app/contexts/FilesModalContext'; import LocalIcon from '@app/components/shared/LocalIcon'; import { BASE_PATH } from '@app/constants/app'; import styles from '@app/components/fileEditor/FileEditor.module.css'; +import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology'; +import { useFileActionIcons } from '@app/hooks/useFileActionIcons'; interface AddFileCardProps { onFileSelect: (files: File[]) => void; @@ -23,6 +25,8 @@ const AddFileCard = ({ const { openFilesModal } = useFilesModalContext(); const { colorScheme } = useMantineColorScheme(); const [isUploadHover, setIsUploadHover] = useState(false); + const terminology = useFileActionTerminology(); + const icons = useFileActionIcons(); const handleCardClick = () => { openFilesModal(); @@ -152,10 +156,10 @@ const AddFileCard = ({ onClick={handleNativeUploadClick} onMouseEnter={() => setIsUploadHover(true)} > - + {isUploadHover && ( - {t('landing.uploadFromComputer', 'Upload from computer')} + {terminology.uploadFromComputer} )} @@ -166,7 +170,7 @@ const AddFileCard = ({ className="text-[var(--accent-interactive)]" style={{ fontSize: '.8rem', textAlign: 'center', marginTop: '0.5rem' }} > - {t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')} + {terminology.dropFilesHere} diff --git a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx index 7e734b42fc..32ad108407 100644 --- a/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/core/components/fileEditor/FileEditorThumbnail.tsx @@ -3,7 +3,8 @@ import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Sta import { useIsMobile } from '@app/hooks/useIsMobile'; import { alert } from '@app/components/toast'; import { useTranslation } from 'react-i18next'; -import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined'; +import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology'; +import { useFileActionIcons } from '@app/hooks/useFileActionIcons'; import CloseIcon from '@mui/icons-material/Close'; import VisibilityIcon from '@mui/icons-material/Visibility'; import UnarchiveIcon from '@mui/icons-material/Unarchive'; @@ -57,6 +58,9 @@ const FileEditorThumbnail = ({ isSupported = true, }: FileEditorThumbnailProps) => { const { t } = useTranslation(); + const terminology = useFileActionTerminology(); + const icons = useFileActionIcons(); + const DownloadOutlinedIcon = icons.download; const { pinFile, unpinFile, @@ -204,7 +208,7 @@ const FileEditorThumbnail = ({ { id: 'download', icon: , - label: t('download', 'Download'), + label: terminology.download, onClick: (e) => { e.stopPropagation(); onDownloadFile(file.id); diff --git a/frontend/src/core/components/fileManager/EmptyFilesState.tsx b/frontend/src/core/components/fileManager/EmptyFilesState.tsx index c9f72a7ada..ae46909d13 100644 --- a/frontend/src/core/components/fileManager/EmptyFilesState.tsx +++ b/frontend/src/core/components/fileManager/EmptyFilesState.tsx @@ -5,12 +5,16 @@ import { useTranslation } from 'react-i18next'; import { useFileManagerContext } from '@app/contexts/FileManagerContext'; import LocalIcon from '@app/components/shared/LocalIcon'; import { BASE_PATH } from '@app/constants/app'; +import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology'; +import { useFileActionIcons } from '@app/hooks/useFileActionIcons'; const EmptyFilesState: React.FC = () => { const { t } = useTranslation(); const { colorScheme } = useMantineColorScheme(); const { onLocalFileClick } = useFileManagerContext(); const [isUploadHover, setIsUploadHover] = useState(false); + const terminology = useFileActionTerminology(); + const icons = useFileActionIcons(); const handleUploadClick = () => { onLocalFileClick(); @@ -91,10 +95,10 @@ const EmptyFilesState: React.FC = () => { onClick={handleUploadClick} onMouseEnter={() => setIsUploadHover(true)} > - + {isUploadHover && ( - {t('landing.uploadFromComputer', 'Upload from computer')} + {terminology.uploadFromComputer} )} @@ -105,7 +109,7 @@ const EmptyFilesState: React.FC = () => { className="text-[var(--accent-interactive)]" style={{ fontSize: '.8rem', textAlign: 'center' }} > - {t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')} + {terminology.dropFilesHere} diff --git a/frontend/src/core/components/fileManager/FileActions.tsx b/frontend/src/core/components/fileManager/FileActions.tsx index d32ed03149..8f919e92c1 100644 --- a/frontend/src/core/components/fileManager/FileActions.tsx +++ b/frontend/src/core/components/fileManager/FileActions.tsx @@ -2,12 +2,16 @@ import React from "react"; import { Group, Text, ActionIcon, Tooltip } from "@mantine/core"; import SelectAllIcon from "@mui/icons-material/SelectAll"; import DeleteIcon from "@mui/icons-material/Delete"; -import DownloadIcon from "@mui/icons-material/Download"; import { useTranslation } from "react-i18next"; import { useFileManagerContext } from "@app/contexts/FileManagerContext"; +import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology"; +import { useFileActionIcons } from "@app/hooks/useFileActionIcons"; const FileActions: React.FC = () => { const { t } = useTranslation(); + const terminology = useFileActionTerminology(); + const icons = useFileActionIcons(); + const DownloadIcon = icons.download; const { recentFiles, selectedFileIds, filteredFiles, onSelectAll, onDeleteSelected, onDownloadSelected } = useFileManagerContext(); @@ -95,7 +99,7 @@ const FileActions: React.FC = () => { - + = ({ const { activeSource, onSourceChange, onLocalFileClick, onGoogleDriveSelect } = useFileManagerContext(); const { t } = useTranslation(); const { isEnabled: isGoogleDriveEnabled, openPicker: openGoogleDrivePicker } = useGoogleDrivePicker(); + const terminology = useFileActionTerminology(); + const icons = useFileActionIcons(); + const UploadIcon = icons.upload; const handleGoogleDriveClick = async () => { try { @@ -76,7 +80,7 @@ const FileSourceButtons: React.FC = ({ } }} > - {horizontal ? t('fileUpload.uploadFiles', 'Upload') : t('fileUpload.uploadFiles', 'Upload Files')} + {horizontal ? terminology.upload : terminology.uploadFiles}
diff --git a/frontend/src/core/components/shared/FilePickerModal.tsx b/frontend/src/core/components/shared/FilePickerModal.tsx index 336070a742..3f5e78b345 100644 --- a/frontend/src/core/components/shared/FilePickerModal.tsx +++ b/frontend/src/core/components/shared/FilePickerModal.tsx @@ -16,6 +16,7 @@ import { import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; import { useTranslation } from 'react-i18next'; import { FileId } from '@app/types/file'; +import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology'; interface FilePickerModalProps { opened: boolean; @@ -31,6 +32,7 @@ const FilePickerModal = ({ onSelectFiles, }: FilePickerModalProps) => { const { t } = useTranslation(); + const terminology = useFileActionTerminology(); const [selectedFileIds, setSelectedFileIds] = useState([]); // Reset selection when modal opens @@ -130,7 +132,7 @@ const FilePickerModal = ({ {storedFiles.length === 0 ? ( - {t("fileUpload.noFilesInStorage", "No files available in storage. Upload some files first.")} + {terminology.noFilesInStorage} ) : ( <> @@ -253,7 +255,7 @@ const FilePickerModal = ({ disabled={selectedFileIds.length === 0} > {selectedFileIds.length > 0 - ? `${t("fileUpload.loadFromStorage", "Load")} ${selectedFileIds.length} ${t("fileUpload.uploadFiles", "Files")}` + ? `${t("fileUpload.loadFromStorage", "Load")} ${selectedFileIds.length} ${terminology.uploadFiles}` : t("fileUpload.loadFromStorage", "Load Files") } diff --git a/frontend/src/core/components/shared/LandingPage.tsx b/frontend/src/core/components/shared/LandingPage.tsx index 88cbe73b6e..46b75b7c5a 100644 --- a/frontend/src/core/components/shared/LandingPage.tsx +++ b/frontend/src/core/components/shared/LandingPage.tsx @@ -8,6 +8,8 @@ import { useFilesModalContext } from '@app/contexts/FilesModalContext'; import { BASE_PATH } from '@app/constants/app'; import { useLogoPath } from '@app/hooks/useLogoPath'; import { useFileManager } from '@app/hooks/useFileManager'; +import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology'; +import { useFileActionIcons } from '@app/hooks/useFileActionIcons'; const LandingPage = () => { const { addFiles } = useFileHandler(); @@ -19,6 +21,8 @@ const LandingPage = () => { const logoPath = useLogoPath(); const { loadRecentFiles } = useFileManager(); const [hasRecents, setHasRecents] = React.useState(false); + const terminology = useFileActionTerminology(); + const icons = useFileActionIcons(); const handleFileDrop = async (files: File[]) => { await addFiles(files); @@ -187,10 +191,10 @@ const LandingPage = () => { onClick={handleNativeUploadClick} onMouseEnter={() => setIsUploadHover(true)} > - + {isUploadHover && ( - {t('landing.uploadFromComputer', 'Upload from computer')} + {terminology.uploadFromComputer} )} @@ -239,7 +243,7 @@ const LandingPage = () => { className="text-[var(--accent-interactive)]" style={{ fontSize: '.8rem' }} > - {t('fileUpload.dropFilesHere', 'Drop files here or click the upload button')} + {terminology.dropFilesHere}
diff --git a/frontend/src/core/components/shared/RightRail.tsx b/frontend/src/core/components/shared/RightRail.tsx index 3cca4edef3..e1270c60a2 100644 --- a/frontend/src/core/components/shared/RightRail.tsx +++ b/frontend/src/core/components/shared/RightRail.tsx @@ -6,6 +6,8 @@ import { useRightRail } from '@app/contexts/RightRailContext'; import { useFileState, useFileSelection } from '@app/contexts/FileContext'; import { useNavigationState } from '@app/contexts/NavigationContext'; import { useTranslation } from 'react-i18next'; +import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology'; +import { useFileActionIcons } from '@app/hooks/useFileActionIcons'; import LanguageSelector from '@app/components/shared/LanguageSelector'; import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider'; @@ -44,6 +46,8 @@ export default function RightRail() { const { sidebarRefs } = useSidebarContext(); const { position: tooltipPosition, offset: tooltipOffset } = useRightRailTooltipSide(sidebarRefs); const { t } = useTranslation(); + const terminology = useFileActionTerminology(); + const icons = useFileActionIcons(); const viewerContext = React.useContext(ViewerContext); const { toggleTheme, themeMode } = useRainbowThemeContext(); const { buttons, actions, allButtonsDisabled } = useRightRail(); @@ -165,9 +169,9 @@ export default function RightRail() { return t('rightRail.exportAll', 'Export PDF'); } if (selectedCount > 0) { - return t('rightRail.downloadSelected', 'Download Selected Files'); + return terminology.downloadSelected; } - return t('rightRail.downloadAll', 'Download All'); + return terminology.downloadAll; }, [currentView, selectedCount, t]); return ( @@ -232,7 +236,7 @@ export default function RightRail() { (currentView === 'viewer' ? !exportState?.canExport : totalItems === 0 || allButtonsDisabled) } > - +
, downloadTooltip, tooltipPosition, diff --git a/frontend/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx b/frontend/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx index 228a5c83ba..b737921e67 100644 --- a/frontend/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx +++ b/frontend/src/core/components/tools/editTableOfContents/EditTableOfContentsWorkbenchView.tsx @@ -14,6 +14,7 @@ import { BookmarkNode } from '@app/utils/editTableOfContents'; import ErrorNotification from '@app/components/tools/shared/ErrorNotification'; import ResultsPreview from '@app/components/tools/shared/ResultsPreview'; import BookmarkEditor from '@app/components/tools/editTableOfContents/BookmarkEditor'; +import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology'; export interface EditTableOfContentsWorkbenchViewData { bookmarks: BookmarkNode[]; @@ -40,6 +41,7 @@ interface EditTableOfContentsWorkbenchViewProps { const EditTableOfContentsWorkbenchView = ({ data }: EditTableOfContentsWorkbenchViewProps) => { const { t } = useTranslation(); + const terminology = useFileActionTerminology(); if (!data) { return ( @@ -180,7 +182,7 @@ const EditTableOfContentsWorkbenchView = ({ data }: EditTableOfContentsWorkbench download={downloadFilename ?? undefined} leftSection={} > - {t('download', 'Download')} + {terminology.download} )} )} diff --git a/frontend/src/core/components/tools/showJS/ShowJSView.tsx b/frontend/src/core/components/tools/showJS/ShowJSView.tsx index 83dc508698..e442265826 100644 --- a/frontend/src/core/components/tools/showJS/ShowJSView.tsx +++ b/frontend/src/core/components/tools/showJS/ShowJSView.tsx @@ -6,6 +6,7 @@ import ArrowDownwardRoundedIcon from "@mui/icons-material/ArrowDownwardRounded"; import DownloadRoundedIcon from "@mui/icons-material/DownloadRounded"; import "@app/components/tools/showJS/ShowJSView.css"; import { useTranslation } from "react-i18next"; +import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology"; import { tokenizeToLines, @@ -28,6 +29,7 @@ interface ShowJSViewProps { const ShowJSView: React.FC = ({ data }) => { const { t } = useTranslation(); + const terminology = useFileActionTerminology(); const text = useMemo(() => { if (typeof data === "string") return data; return data?.scriptText ?? ""; @@ -173,7 +175,7 @@ const ShowJSView: React.FC = ({ data }) => { disabled={!downloadUrl} leftSection={} > - {t("download", "Download")} + {terminology.download} + ); + }; + + if (leftButtons.length === 0) { + return {rightButtons.map(renderButton)}; + } + + if (rightButtons.length === 0) { + return {leftButtons.map(renderButton)}; + } + + return ( + + {leftButtons.map(renderButton)} + {rightButtons.map(renderButton)} + + ); +} + diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/types.ts b/frontend/src/core/components/onboarding/InitialOnboardingModal/types.ts new file mode 100644 index 0000000000..d8bc96a1cf --- /dev/null +++ b/frontend/src/core/components/onboarding/InitialOnboardingModal/types.ts @@ -0,0 +1,21 @@ +import type { LicenseNotice } from '@app/types/types'; + +export interface InitialOnboardingModalProps { + opened: boolean; + onClose: () => void; + onRequestServerLicense?: (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => void; + onLicenseNoticeUpdate?: (licenseNotice: LicenseNotice) => void; +} + +export interface OnboardingState { + step: number; + selectedRole: 'admin' | 'user' | null; + selfReportedAdmin: boolean; +} + +export const DEFAULT_STATE: OnboardingState = { + step: 0, + selectedRole: null, + selfReportedAdmin: false, +}; + diff --git a/frontend/src/core/components/onboarding/InitialOnboardingModal/useInitialOnboardingState.ts b/frontend/src/core/components/onboarding/InitialOnboardingModal/useInitialOnboardingState.ts new file mode 100644 index 0000000000..066a777cca --- /dev/null +++ b/frontend/src/core/components/onboarding/InitialOnboardingModal/useInitialOnboardingState.ts @@ -0,0 +1,381 @@ +import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; +import { usePreferences } from '@app/contexts/PreferencesContext'; +import { useOnboarding } from '@app/contexts/OnboardingContext'; +import { useOs } from '@app/hooks/useOs'; +import { useNavigate } from 'react-router-dom'; +import { + SLIDE_DEFINITIONS, + type ButtonAction, + type FlowState, + type SlideId, +} from '@app/components/onboarding/onboardingFlowConfig'; +import type { LicenseNotice } from '@app/types/types'; +import { resolveFlow } from '@app/components/onboarding/InitialOnboardingModal/flowResolver'; +import { useServerExperience } from '@app/hooks/useServerExperience'; +import { DEFAULT_STATE, type InitialOnboardingModalProps, type OnboardingState } from '@app/components/onboarding/InitialOnboardingModal/types'; +import { DOWNLOAD_URLS } from '@app/constants/downloads'; + +interface UseInitialOnboardingStateResult { + state: OnboardingState; + totalSteps: number; + slideDefinition: (typeof SLIDE_DEFINITIONS)[SlideId]; + currentSlide: ReturnType<(typeof SLIDE_DEFINITIONS)[SlideId]['createSlide']>; + licenseNotice: LicenseNotice; + flowState: FlowState; + closeAndMarkSeen: () => void; + handleButtonAction: (action: ButtonAction) => void; +} + +export function useInitialOnboardingState({ + opened, + onClose, + onRequestServerLicense, + onLicenseNoticeUpdate, +}: InitialOnboardingModalProps): UseInitialOnboardingStateResult | null { + const { preferences, updatePreference } = usePreferences(); + const { startTour } = useOnboarding(); + const { + loginEnabled: loginEnabledFromServer, + configIsAdmin, + totalUsers: serverTotalUsers, + userCountResolved: serverUserCountResolved, + freeTierLimit, + hasPaidLicense, + scenarioKey, + setSelfReportedAdmin, + isNewServer, + } = useServerExperience(); + const osType = useOs(); + const navigate = useNavigate(); + const selectedDownloadUrlRef = useRef(''); + + const [state, setState] = useState(DEFAULT_STATE); + + const resetState = useCallback(() => { + setState(DEFAULT_STATE); + }, []); + + useEffect(() => { + if (!opened) { + resetState(); + } + }, [opened, resetState]); + + const handleRoleSelect = useCallback( + (role: 'admin' | 'user' | null) => { + const isAdminSelection = role === 'admin'; + setState((prev) => ({ + ...prev, + selectedRole: role, + selfReportedAdmin: isAdminSelection, + })); + + if (typeof window !== 'undefined') { + if (isAdminSelection) { + window.localStorage.setItem('stirling-self-reported-admin', 'true'); + } else { + window.localStorage.removeItem('stirling-self-reported-admin'); + } + } + + setSelfReportedAdmin(isAdminSelection); + }, + [setSelfReportedAdmin], + ); + + const closeAndMarkSeen = useCallback(() => { + if (!preferences.hasSeenIntroOnboarding) { + updatePreference('hasSeenIntroOnboarding', true); + } + onClose(); + }, [onClose, preferences.hasSeenIntroOnboarding, updatePreference]); + + const isAdmin = configIsAdmin; + const enableLogin = loginEnabledFromServer; + + const effectiveEnableLogin = enableLogin; + const effectiveIsAdmin = isAdmin; + const shouldAssumeAdminForNewServer = Boolean(isNewServer) && !effectiveEnableLogin; + + useEffect(() => { + if (shouldAssumeAdminForNewServer && !state.selfReportedAdmin) { + handleRoleSelect('admin'); + } + }, [handleRoleSelect, shouldAssumeAdminForNewServer, state.selfReportedAdmin]); + + const shouldUseServerCount = + (effectiveEnableLogin && effectiveIsAdmin) || !effectiveEnableLogin; + const licenseUserCountFromServer = + shouldUseServerCount && serverUserCountResolved ? serverTotalUsers : null; + + const effectiveLicenseUserCount = licenseUserCountFromServer ?? null; + + const os = useMemo(() => { + switch (osType) { + case 'windows': + return { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS }; + case 'mac-apple': + return { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON }; + case 'mac-intel': + return { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL }; + case 'linux-x64': + case 'linux-arm64': + return { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS }; + default: + return { label: '', url: '' }; + } + }, [osType]); + + const osOptions = useMemo(() => { + const options = [ + { label: 'Windows', url: DOWNLOAD_URLS.WINDOWS, value: 'windows' }, + { label: 'Mac (Apple Silicon)', url: DOWNLOAD_URLS.MAC_APPLE_SILICON, value: 'mac-apple' }, + { label: 'Mac (Intel)', url: DOWNLOAD_URLS.MAC_INTEL, value: 'mac-intel' }, + { label: 'Linux', url: DOWNLOAD_URLS.LINUX_DOCS, value: 'linux' }, + ]; + return options.filter(opt => opt.url); + }, []); + + const resolvedFlow = useMemo( + () => resolveFlow(effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin), + [effectiveEnableLogin, effectiveIsAdmin, state.selfReportedAdmin], + ); + const shouldSkipSecurityCheck = shouldAssumeAdminForNewServer; + const flowSlideIds = useMemo( + () => + shouldSkipSecurityCheck + ? resolvedFlow.ids.filter((id) => id !== 'security-check') + : resolvedFlow.ids, + [resolvedFlow.ids, shouldSkipSecurityCheck], + ); + const flowType = resolvedFlow.type; + const totalSteps = flowSlideIds.length; + const maxIndex = Math.max(totalSteps - 1, 0); + + useEffect(() => { + if (state.step >= flowSlideIds.length) { + setState((prev) => ({ + ...prev, + step: Math.max(flowSlideIds.length - 1, 0), + })); + } + }, [flowSlideIds.length, state.step]); + + const currentSlideId = flowSlideIds[state.step] ?? flowSlideIds[flowSlideIds.length - 1]; + const slideDefinition = SLIDE_DEFINITIONS[currentSlideId]; + + if (!slideDefinition) { + return null; + } + + const scenarioProvidesInfo = + scenarioKey && scenarioKey !== 'unknown' && scenarioKey !== 'licensed'; + const scenarioIndicatesAdmin = scenarioProvidesInfo + ? scenarioKey!.includes('admin') + : state.selfReportedAdmin || effectiveIsAdmin; + const scenarioIndicatesOverLimit = scenarioProvidesInfo + ? scenarioKey!.includes('over-limit') + : effectiveLicenseUserCount != null && effectiveLicenseUserCount > freeTierLimit; + const scenarioRequiresLicense = + scenarioKey === 'licensed' ? false : scenarioKey === 'unknown' ? !hasPaidLicense : true; + + const shouldShowServerLicenseInfo = scenarioIndicatesAdmin && scenarioRequiresLicense; + + const licenseNotice = useMemo( + () => ({ + totalUsers: effectiveLicenseUserCount, + freeTierLimit, + isOverLimit: scenarioIndicatesOverLimit, + requiresLicense: shouldShowServerLicenseInfo, + }), + [ + effectiveLicenseUserCount, + freeTierLimit, + scenarioIndicatesOverLimit, + shouldShowServerLicenseInfo, + ], + ); + + const requestServerLicenseIfNeeded = useCallback( + (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => { + if (!shouldShowServerLicenseInfo) { + return; + } + onRequestServerLicense?.(options); + }, + [onRequestServerLicense, shouldShowServerLicenseInfo], + ); + + useEffect(() => { + onLicenseNoticeUpdate?.(licenseNotice); + }, [licenseNotice, onLicenseNoticeUpdate]); + + // Initialize ref with default URL + useEffect(() => { + if (!selectedDownloadUrlRef.current && os.url) { + selectedDownloadUrlRef.current = os.url; + } + }, [os.url]); + + const handleDownloadUrlChange = useCallback((url: string) => { + selectedDownloadUrlRef.current = url; + }, []); + + const currentSlide = slideDefinition.createSlide({ + osLabel: os.label, + osUrl: os.url, + osOptions, + onDownloadUrlChange: handleDownloadUrlChange, + selectedRole: state.selectedRole, + onRoleSelect: handleRoleSelect, + licenseNotice, + loginEnabled: effectiveEnableLogin, + }); + + const goNext = useCallback(() => { + setState((prev) => ({ + ...prev, + step: Math.min(prev.step + 1, maxIndex), + })); + }, [maxIndex]); + + const goPrev = useCallback(() => { + setState((prev) => ({ + ...prev, + step: Math.max(prev.step - 1, 0), + })); + }, []); + + const launchTour = useCallback( + (mode: 'admin' | 'tools', options?: { closeOnboardingSlides?: boolean }) => { + if (options?.closeOnboardingSlides) { + closeAndMarkSeen(); + } + + startTour(mode, { + source: 'initial-onboarding-modal', + metadata: { + hasCompletedOnboarding: preferences.hasCompletedOnboarding, + toolPanelModePromptSeen: preferences.toolPanelModePromptSeen, + selfReportedAdmin: state.selfReportedAdmin, + }, + }); + }, + [closeAndMarkSeen, preferences.hasCompletedOnboarding, preferences.toolPanelModePromptSeen, startTour, state.selfReportedAdmin], + ); + + const handleButtonAction = useCallback( + (action: ButtonAction) => { + const currentSlideIdLocal = currentSlideId; + const shouldAutoLaunchLoginUserTour = + flowType === 'login-user' && currentSlideIdLocal === 'desktop-install'; + + switch (action) { + case 'next': + if (shouldAutoLaunchLoginUserTour) { + launchTour('tools', { closeOnboardingSlides: true }); + return; + } + goNext(); + return; + case 'prev': + goPrev(); + return; + case 'close': + closeAndMarkSeen(); + return; + case 'download-selected': { + const downloadUrl = selectedDownloadUrlRef.current || os.url || currentSlide.downloadUrl; + if (downloadUrl) { + window.open(downloadUrl, '_blank', 'noopener'); + } + if (shouldAutoLaunchLoginUserTour) { + launchTour('tools', { closeOnboardingSlides: true }); + return; + } + goNext(); + return; + } + case 'complete-close': + updatePreference('hasCompletedOnboarding', true); + closeAndMarkSeen(); + return; + case 'security-next': + if (!state.selectedRole) { + return; + } + if (state.selectedRole === 'admin') { + goNext(); + } else { + launchTour('tools', { closeOnboardingSlides: true }); + } + return; + case 'launch-admin': + requestServerLicenseIfNeeded({ + deferUntilTourComplete: true, + selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin, + }); + launchTour('admin', { closeOnboardingSlides: true }); + return; + case 'launch-tools': + launchTour('tools', { closeOnboardingSlides: true }); + return; + case 'launch-auto': { + const launchMode = state.selfReportedAdmin || effectiveIsAdmin ? 'admin' : 'tools'; + if (launchMode === 'admin') { + requestServerLicenseIfNeeded({ + deferUntilTourComplete: true, + selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin, + }); + } + launchTour(launchMode, { closeOnboardingSlides: true }); + return; + } + case 'skip-to-license': + updatePreference('hasCompletedOnboarding', true); + requestServerLicenseIfNeeded({ + deferUntilTourComplete: false, + selfReportedAdmin: state.selfReportedAdmin || effectiveIsAdmin, + }); + closeAndMarkSeen(); + return; + case 'see-plans': + closeAndMarkSeen(); + navigate('/settings/adminPlan'); + return; + default: + return; + } + }, + [ + closeAndMarkSeen, + currentSlide, + effectiveIsAdmin, + flowType, + goNext, + goPrev, + launchTour, + navigate, + requestServerLicenseIfNeeded, + onRequestServerLicense, + os.url, + state.selectedRole, + state.selfReportedAdmin, + updatePreference, + ], + ); + + const flowState: FlowState = { selectedRole: state.selectedRole }; + + return { + state, + totalSteps, + slideDefinition, + currentSlide, + licenseNotice, + flowState, + closeAndMarkSeen, + handleButtonAction, + }; +} + diff --git a/frontend/src/core/components/onboarding/OnboardingStepper.tsx b/frontend/src/core/components/onboarding/OnboardingStepper.tsx new file mode 100644 index 0000000000..ec6767d8ad --- /dev/null +++ b/frontend/src/core/components/onboarding/OnboardingStepper.tsx @@ -0,0 +1,52 @@ +import React from 'react'; + +interface OnboardingStepperProps { + totalSteps: number; + activeStep: number; // 0-indexed + className?: string; +} + +/** + * Renders a progress indicator where the active step is a pill and others are dots. + * Colors come from theme.css variables. + */ +export function OnboardingStepper({ totalSteps, activeStep, className }: OnboardingStepperProps) { + const items = Array.from({ length: totalSteps }, (_, index) => index); + + return ( +
+ {items.map((index) => { + const isActive = index === activeStep; + const baseStyles: React.CSSProperties = { + background: isActive + ? 'var(--onboarding-step-active)' + : 'var(--onboarding-step-inactive)', + }; + + return ( +
+ ); + })} +
+ ); +} + +export default OnboardingStepper; + + diff --git a/frontend/src/core/components/onboarding/OnboardingTour.tsx b/frontend/src/core/components/onboarding/OnboardingTour.tsx index e06fa41d52..b044fd3e56 100644 --- a/frontend/src/core/components/onboarding/OnboardingTour.tsx +++ b/frontend/src/core/components/onboarding/OnboardingTour.tsx @@ -1,105 +1,29 @@ -import React, { useMemo } from "react"; -import { TourProvider, useTour, type StepType } from '@reactour/tour'; -import { useOnboarding } from '@app/contexts/OnboardingContext'; +import React, { useEffect, useMemo } from "react"; +import { TourProvider, type StepType } from '@reactour/tour'; import { useTranslation } from 'react-i18next'; import { CloseButton, ActionIcon } from '@mantine/core'; +import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; +import CheckIcon from '@mui/icons-material/Check'; +import InitialOnboardingModal from '@app/components/onboarding/InitialOnboardingModal'; +import ServerLicenseModal from '@app/components/onboarding/ServerLicenseModal'; +import '@app/components/onboarding/OnboardingTour.css'; +import ToolPanelModePrompt from '@app/components/tools/ToolPanelModePrompt'; import { useFilesModalContext } from '@app/contexts/FilesModalContext'; import { useTourOrchestration } from '@app/contexts/TourOrchestrationContext'; import { useAdminTourOrchestration } from '@app/contexts/AdminTourOrchestrationContext'; -import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; +import { useOnboardingFlow } from '@app/components/onboarding/hooks/useOnboardingFlow'; +import { createUserStepsConfig } from '@app/components/onboarding/userStepsConfig'; +import { createAdminStepsConfig } from '@app/components/onboarding/adminStepsConfig'; +import { removeAllGlows } from '@app/components/onboarding/tourGlow'; +import TourContent from '@app/components/onboarding/TourContent'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import CheckIcon from '@mui/icons-material/Check'; -import TourWelcomeModal from '@app/components/onboarding/TourWelcomeModal'; import '@app/components/onboarding/OnboardingTour.css'; import i18n from "@app/i18n"; -// Enum case order defines order steps will appear -enum TourStep { - ALL_TOOLS, - SELECT_CROP_TOOL, - TOOL_INTERFACE, - FILES_BUTTON, - FILE_SOURCES, - WORKBENCH, - VIEW_SWITCHER, - VIEWER, - PAGE_EDITOR, - ACTIVE_FILES, - FILE_CHECKBOX, - SELECT_CONTROLS, - CROP_SETTINGS, - RUN_BUTTON, - RESULTS, - FILE_REPLACEMENT, - PIN_BUTTON, - WRAP_UP, -} - -enum AdminTourStep { - WELCOME, - CONFIG_BUTTON, - SETTINGS_OVERVIEW, - TEAMS_AND_USERS, - SYSTEM_CUSTOMIZATION, - DATABASE_SECTION, - CONNECTIONS_SECTION, - ADMIN_TOOLS, - WRAP_UP, -} - -function TourContent() { - const { isOpen } = useOnboarding(); - const { setIsOpen, setCurrentStep } = useTour(); - const previousIsOpenRef = React.useRef(isOpen); - - // Sync tour open state with context and reset to step 0 when reopening - React.useEffect(() => { - const wasClosedNowOpen = !previousIsOpenRef.current && isOpen; - previousIsOpenRef.current = isOpen; - - if (wasClosedNowOpen) { - // Tour is being opened (Help button pressed), reset to first step - setCurrentStep(0); - } - setIsOpen(isOpen); - }, [isOpen, setIsOpen, setCurrentStep]); - - return null; -} - export default function OnboardingTour() { const { t } = useTranslation(); - const { completeTour, showWelcomeModal, setShowWelcomeModal, startTour, tourType, isOpen } = useOnboarding(); + const flow = useOnboardingFlow(); const { openFilesModal, closeFilesModal } = useFilesModalContext(); - const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false; - - // Helper to add glow to multiple elements - const addGlowToElements = (selectors: string[]) => { - selectors.forEach(selector => { - const element = document.querySelector(selector); - if (element) { - if (selector === '[data-tour="settings-content-area"]') { - element.classList.add('tour-content-glow'); - } else { - element.classList.add('tour-nav-glow'); - } - } - }); - }; - - // Helper to remove all glows - const removeAllGlows = () => { - document.querySelectorAll('.tour-content-glow').forEach(el => el.classList.remove('tour-content-glow')); - document.querySelectorAll('.tour-nav-glow').forEach(el => el.classList.remove('tour-nav-glow')); - }; - - // Cleanup glows when tour closes - React.useEffect(() => { - if (!isOpen) { - removeAllGlows(); - } - return () => removeAllGlows(); - }, [isOpen]); const { saveWorkbenchState, restoreWorkbenchState, @@ -122,258 +46,78 @@ export default function OnboardingTour() { scrollNavToSection, } = useAdminTourOrchestration(); - // Define steps as object keyed by enum - TypeScript ensures all keys are present - const stepsConfig: Record = useMemo(() => ({ - [TourStep.ALL_TOOLS]: { - selector: '[data-tour="tool-panel"]', - content: t('onboarding.allTools', 'This is the Tools panel, where you can browse and select from all available PDF tools.'), - position: 'center', - padding: 0, - action: () => { - saveWorkbenchState(); - closeFilesModal(); - backToAllTools(); - }, - }, - [TourStep.SELECT_CROP_TOOL]: { - selector: '[data-tour="tool-button-crop"]', - content: t('onboarding.selectCropTool', "Let's select the Crop tool to demonstrate how to use one of the tools."), - position: 'right', - padding: 0, - actionAfter: () => selectCropTool(), - }, - [TourStep.TOOL_INTERFACE]: { - selector: '[data-tour="tool-panel"]', - content: t('onboarding.toolInterface', "This is the Crop tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet."), - position: 'center', - padding: 0, - }, - [TourStep.FILES_BUTTON]: { - selector: '[data-tour="files-button"]', - content: t('onboarding.filesButton', "The Files button on the Quick Access bar allows you to upload PDFs to use the tools on."), - position: 'right', - padding: 10, - action: () => openFilesModal(), - }, - [TourStep.FILE_SOURCES]: { - selector: '[data-tour="file-sources"]', - content: t('onboarding.fileSources', "You can upload new files or access recent files from here. For the tour, we'll just use a sample file."), - position: 'right', - padding: 0, - actionAfter: () => { - loadSampleFile(); - closeFilesModal(); - } - }, - [TourStep.WORKBENCH]: { - selector: '[data-tour="workbench"]', - content: t('onboarding.workbench', 'This is the Workbench - the main area where you view and edit your PDFs.'), - position: 'center', - padding: 0, - }, - [TourStep.VIEW_SWITCHER]: { - selector: '[data-tour="view-switcher"]', - content: t('onboarding.viewSwitcher', 'Use these controls to select how you want to view your PDFs.'), - position: 'bottom', - padding: 0, - }, - [TourStep.VIEWER]: { - selector: '[data-tour="workbench"]', - content: t('onboarding.viewer', "The Viewer lets you read and annotate your PDFs."), - position: 'center', - padding: 0, - action: () => switchToViewer(), - }, - [TourStep.PAGE_EDITOR]: { - selector: '[data-tour="workbench"]', - content: t('onboarding.pageEditor', "The Page Editor allows you to do various operations on the pages within your PDFs, such as reordering, rotating and deleting."), - position: 'center', - padding: 0, - action: () => switchToPageEditor(), - }, - [TourStep.ACTIVE_FILES]: { - selector: '[data-tour="workbench"]', - content: t('onboarding.activeFiles', "The Active Files view shows all of the PDFs you have loaded into the tool, and allows you to select which ones to process."), - position: 'center', - padding: 0, - action: () => switchToActiveFiles(), - }, - [TourStep.FILE_CHECKBOX]: { - selector: '[data-tour="file-card-checkbox"]', - content: t('onboarding.fileCheckbox', "Clicking one of the files selects it for processing. You can select multiple files for batch operations."), - position: 'top', - padding: 10, - }, - [TourStep.SELECT_CONTROLS]: { - selector: '[data-tour="right-rail-controls"]', - highlightedSelectors: ['[data-tour="right-rail-controls"]', '[data-tour="right-rail-settings"]'], - content: t('onboarding.selectControls', "The Right Rail contains buttons to quickly select/deselect all of your active PDFs, along with buttons to change the app's theme or language."), - position: 'left', - padding: 5, - action: () => selectFirstFile(), - }, - [TourStep.CROP_SETTINGS]: { - selector: '[data-tour="crop-settings"]', - content: t('onboarding.cropSettings', "Now that we've selected the file we want crop, we can configure the Crop tool to choose the area that we want to crop the PDF to."), - position: 'left', - padding: 10, - action: () => modifyCropSettings(), - }, - [TourStep.RUN_BUTTON]: { - selector: '[data-tour="run-button"]', - content: t('onboarding.runButton', "Once the tool has been configured, this button allows you to run the tool on all the selected PDFs."), - position: 'top', - padding: 10, - actionAfter: () => executeTool(), - }, - [TourStep.RESULTS]: { - selector: '[data-tour="tool-panel"]', - content: t('onboarding.results', "After the tool has finished running, the Review step will show a preview of the results in this panel, and allow you to undo the operation or download the file. "), - position: 'center', - padding: 0, - }, - [TourStep.FILE_REPLACEMENT]: { - selector: '[data-tour="file-card-checkbox"]', - content: t('onboarding.fileReplacement', "The modified file will replace the original file in the Workbench automatically, allowing you to easily run it through more tools."), - position: 'left', - padding: 10, - }, - [TourStep.PIN_BUTTON]: { - selector: '[data-tour="file-card-pin"]', - content: t('onboarding.pinButton', "You can use the Pin button if you'd rather your files stay active after running tools on them."), - position: 'left', - padding: 10, - action: () => pinFile(), - }, - [TourStep.WRAP_UP]: { - selector: '[data-tour="help-button"]', - content: t('onboarding.wrapUp', "You're all set! You've learnt about the main areas of the app and how to use them. Click the Help button whenever you like to see this tour again."), - position: 'right', - padding: 10, - }, - }), [t]); + const isRTL = typeof document !== 'undefined' ? document.documentElement.dir === 'rtl' : false; - // Define admin tour steps - const adminStepsConfig: Record = useMemo(() => ({ - [AdminTourStep.WELCOME]: { - selector: '[data-tour="config-button"]', - content: t('adminOnboarding.welcome', "Welcome to the Admin Tour! Let's explore the powerful enterprise features and settings available to system administrators."), - position: 'right', - padding: 10, - action: () => { - saveAdminState(); - }, - }, - [AdminTourStep.CONFIG_BUTTON]: { - selector: '[data-tour="config-button"]', - content: t('adminOnboarding.configButton', "Click the Config button to access all system settings and administrative controls."), - position: 'right', - padding: 10, - actionAfter: () => { - openConfigModal(); - }, - }, - [AdminTourStep.SETTINGS_OVERVIEW]: { - selector: '.modal-nav', - content: t('adminOnboarding.settingsOverview', "This is the Settings Panel. Admin settings are organised by category for easy navigation."), - position: 'right', - padding: 0, - action: () => { - removeAllGlows(); - }, - }, - [AdminTourStep.TEAMS_AND_USERS]: { - selector: '[data-tour="admin-people-nav"]', - highlightedSelectors: ['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]'], - content: t('adminOnboarding.teamsAndUsers', "Manage Teams and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."), - position: 'right', - padding: 10, - action: () => { - removeAllGlows(); - navigateToSection('people'); - setTimeout(() => { - addGlowToElements(['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]']); - }, 100); - }, - }, - [AdminTourStep.SYSTEM_CUSTOMIZATION]: { - selector: '[data-tour="admin-adminGeneral-nav"]', - highlightedSelectors: ['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]'], - content: t('adminOnboarding.systemCustomization', "We have extensive ways to customise the UI: System Settings let you change the app name and languages, Features allows server certificate management, and Endpoints lets you enable or disable specific tools for your users."), - position: 'right', - padding: 10, - action: () => { - removeAllGlows(); - navigateToSection('adminGeneral'); - setTimeout(() => { - addGlowToElements(['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]']); - }, 100); - }, - }, - [AdminTourStep.DATABASE_SECTION]: { - selector: '[data-tour="admin-adminDatabase-nav"]', - highlightedSelectors: ['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]'], - content: t('adminOnboarding.databaseSection', "For advanced production environments, we have settings to allow external database hookups so you can integrate with your existing infrastructure."), - position: 'right', - padding: 10, - action: () => { - removeAllGlows(); - navigateToSection('adminDatabase'); - setTimeout(() => { - addGlowToElements(['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]']); - }, 100); - }, - }, - [AdminTourStep.CONNECTIONS_SECTION]: { - selector: '[data-tour="admin-adminConnections-nav"]', - highlightedSelectors: ['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]'], - content: t('adminOnboarding.connectionsSection', "The Connections section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications."), - position: 'right', - padding: 10, - action: () => { - removeAllGlows(); - navigateToSection('adminConnections'); - setTimeout(() => { - addGlowToElements(['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]']); - }, 100); - }, - actionAfter: async () => { - // Scroll for the NEXT step before it shows - await scrollNavToSection('adminAudit'); - }, - }, - [AdminTourStep.ADMIN_TOOLS]: { - selector: '[data-tour="admin-adminAudit-nav"]', - highlightedSelectors: ['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]'], - content: t('adminOnboarding.adminTools', "Finally, we have advanced administration tools like Auditing to track system activity and Usage Analytics to monitor how your users interact with the platform."), - position: 'right', - padding: 10, - action: () => { - // Just navigate, scroll already happened in previous step - removeAllGlows(); - navigateToSection('adminAudit'); - setTimeout(() => { - addGlowToElements(['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]']); - }, 100); - }, - }, - [AdminTourStep.WRAP_UP]: { - selector: '[data-tour="help-button"]', - content: t('adminOnboarding.wrapUp', "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the Help menu."), - position: 'right', - padding: 10, - action: () => { - removeAllGlows(); - }, - }, - }), [t]); + useEffect(() => { + if (!flow.isTourOpen) { + removeAllGlows(); + } + return () => removeAllGlows(); + }, [flow.isTourOpen]); - // Select steps based on tour type - const steps = tourType === 'admin' - ? Object.values(adminStepsConfig) - : Object.values(stepsConfig); + const userStepsConfig = useMemo( + () => + createUserStepsConfig({ + t, + actions: { + saveWorkbenchState, + closeFilesModal, + backToAllTools, + selectCropTool, + loadSampleFile, + switchToViewer, + switchToPageEditor, + switchToActiveFiles, + selectFirstFile, + pinFile, + modifyCropSettings, + executeTool, + openFilesModal, + }, + }), + [ + t, + backToAllTools, + closeFilesModal, + executeTool, + loadSampleFile, + modifyCropSettings, + openFilesModal, + pinFile, + saveWorkbenchState, + selectCropTool, + selectFirstFile, + switchToActiveFiles, + switchToPageEditor, + switchToViewer, + ], + ); - const advanceTour = ({ setCurrentStep, currentStep, steps, setIsOpen }: { + const adminStepsConfig = useMemo( + () => + createAdminStepsConfig({ + t, + actions: { + saveAdminState, + openConfigModal, + navigateToSection, + scrollNavToSection, + }, + }), + [navigateToSection, openConfigModal, saveAdminState, scrollNavToSection, t], + ); + + const steps = useMemo(() => { + const config = flow.tourType === 'admin' ? adminStepsConfig : userStepsConfig; + return Object.values(config); + }, [adminStepsConfig, flow.tourType, userStepsConfig]); + + const advanceTour = ({ + setCurrentStep, + currentStep, + steps, + setIsOpen, + }: { setCurrentStep: (value: number | ((prev: number) => number)) => void; currentStep: number; steps?: StepType[]; @@ -381,12 +125,12 @@ export default function OnboardingTour() { }) => { if (steps && currentStep === steps.length - 1) { setIsOpen(false); - if (tourType === 'admin') { + if (flow.tourType === 'admin') { restoreAdminState(); } else { restoreWorkbenchState(); } - completeTour(); + flow.handleTourCompletion(); } else if (steps) { setCurrentStep((s) => (s === steps.length - 1 ? 0 : s + 1)); } @@ -394,34 +138,22 @@ export default function OnboardingTour() { const handleCloseTour = ({ setIsOpen }: { setIsOpen: (value: boolean) => void }) => { setIsOpen(false); - if (tourType === 'admin') { + if (flow.tourType === 'admin') { restoreAdminState(); } else { restoreWorkbenchState(); } - completeTour(); + flow.handleTourCompletion(); }; return ( <> - { - setShowWelcomeModal(false); - startTour(); - }} - onMaybeLater={() => { - setShowWelcomeModal(false); - }} - onDontShowAgain={() => { - setShowWelcomeModal(false); - completeTour(); - }} - /> + + { @@ -429,13 +161,10 @@ export default function OnboardingTour() { advanceTour(clickProps); }} keyboardHandler={(e, clickProps, status) => { - // Handle right arrow key to advance tour if (e.key === 'ArrowRight' && !status?.isRightDisabled && clickProps) { e.preventDefault(); advanceTour(clickProps); - } - // Handle escape key to close tour - else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) { + } else if (e.key === 'Escape' && !status?.isEscDisabled && clickProps) { e.preventDefault(); handleCloseTour(clickProps); } @@ -487,22 +216,16 @@ export default function OnboardingTour() { }} components={{ Close: ({ onClick }) => ( - + ), - Content: ({ content } : {content: string}) => ( -
+ Content: ({ content }: { content: string }) => ( +
), }} > + ); } diff --git a/frontend/src/core/components/onboarding/ServerLicenseModal.tsx b/frontend/src/core/components/onboarding/ServerLicenseModal.tsx new file mode 100644 index 0000000000..9c53628cb6 --- /dev/null +++ b/frontend/src/core/components/onboarding/ServerLicenseModal.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import { Modal, Button, Group, Stack } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground'; +import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide'; +import { LicenseNotice } from '@app/types/types'; +import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex'; +import { BASE_PATH } from '@app/constants/app'; +import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css'; + +interface ServerLicenseModalProps { + opened: boolean; + onClose: () => void; + onSeePlans?: () => void; + licenseNotice: LicenseNotice; +} + +export default function ServerLicenseModal({ + opened, + onClose, + onSeePlans, + licenseNotice, +}: ServerLicenseModalProps) { + const { t } = useTranslation(); + const slide = React.useMemo(() => ServerLicenseSlide({ licenseNotice }), [licenseNotice]); + const primaryLabel = licenseNotice.isOverLimit + ? t('onboarding.serverLicense.upgrade', 'Upgrade now →') + : t('onboarding.serverLicense.seePlans', 'See Plans →'); + const secondaryLabel = t('onboarding.serverLicense.skip', 'Skip for now'); + + const handleSeePlans = () => { + onSeePlans?.(); + onClose(); + }; + + const secondaryStyles = { + root: { + background: 'var(--onboarding-secondary-button-bg)', + border: '1px solid var(--onboarding-secondary-button-border)', + color: 'var(--onboarding-secondary-button-text)', + }, + }; + + const primaryStyles = { + root: { + background: 'var(--onboarding-primary-button-bg)', + color: 'var(--onboarding-primary-button-text)', + }, + }; + + return ( + + +
+ +
+
+ Stirling logo +
+
+
+ +
+ +
+ {slide.title} +
+
+ {slide.body} +
+ + + + +
+
+
+
+ ); +} + diff --git a/frontend/src/core/components/onboarding/TourContent.tsx b/frontend/src/core/components/onboarding/TourContent.tsx new file mode 100644 index 0000000000..c0f787ec9f --- /dev/null +++ b/frontend/src/core/components/onboarding/TourContent.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import { useTour } from '@reactour/tour'; +import { useOnboarding } from '@app/contexts/OnboardingContext'; + +export default function TourContent() { + const { isOpen } = useOnboarding(); + const { setIsOpen, setCurrentStep } = useTour(); + const previousIsOpenRef = React.useRef(isOpen); + + React.useEffect(() => { + const wasClosedNowOpen = !previousIsOpenRef.current && isOpen; + previousIsOpenRef.current = isOpen; + + if (wasClosedNowOpen) { + setCurrentStep(0); + } + setIsOpen(isOpen); + }, [isOpen, setIsOpen, setCurrentStep]); + + return null; +} + diff --git a/frontend/src/core/components/onboarding/adminStepsConfig.ts b/frontend/src/core/components/onboarding/adminStepsConfig.ts new file mode 100644 index 0000000000..5eb993cc91 --- /dev/null +++ b/frontend/src/core/components/onboarding/adminStepsConfig.ts @@ -0,0 +1,133 @@ +import type { StepType } from '@reactour/tour'; +import type { TFunction } from 'i18next'; +import { AdminTourStep } from '@app/components/onboarding/tourSteps'; +import { addGlowToElements, removeAllGlows } from '@app/components/onboarding/tourGlow'; + +interface AdminStepActions { + saveAdminState: () => void; + openConfigModal: () => void; + navigateToSection: (section: string) => void; + scrollNavToSection: (section: string) => Promise | void; +} + +interface CreateAdminStepsConfigArgs { + t: TFunction; + actions: AdminStepActions; +} + +export function createAdminStepsConfig({ t, actions }: CreateAdminStepsConfigArgs): Record { + const { saveAdminState, openConfigModal, navigateToSection, scrollNavToSection } = actions; + + return { + [AdminTourStep.WELCOME]: { + selector: '[data-tour="config-button"]', + content: t('adminOnboarding.welcome', "Welcome to the Admin Tour! Let's explore the powerful enterprise features and settings available to system administrators."), + position: 'right', + padding: 10, + action: () => { + saveAdminState(); + }, + }, + [AdminTourStep.CONFIG_BUTTON]: { + selector: '[data-tour="config-button"]', + content: t('adminOnboarding.configButton', "Click the Config button to access all system settings and administrative controls."), + position: 'right', + padding: 10, + actionAfter: () => { + openConfigModal(); + }, + }, + [AdminTourStep.SETTINGS_OVERVIEW]: { + selector: '.modal-nav', + content: t('adminOnboarding.settingsOverview', "This is the Settings Panel. Admin settings are organised by category for easy navigation."), + position: 'right', + padding: 0, + action: () => { + removeAllGlows(); + }, + }, + [AdminTourStep.TEAMS_AND_USERS]: { + selector: '[data-tour="admin-people-nav"]', + highlightedSelectors: ['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]'], + content: t('adminOnboarding.teamsAndUsers', "Manage Teams and individual users here. You can invite new users via email, shareable links, or create custom accounts for them yourself."), + position: 'right', + padding: 10, + action: () => { + removeAllGlows(); + navigateToSection('people'); + setTimeout(() => { + addGlowToElements(['[data-tour="admin-people-nav"]', '[data-tour="admin-teams-nav"]', '[data-tour="settings-content-area"]']); + }, 100); + }, + }, + [AdminTourStep.SYSTEM_CUSTOMIZATION]: { + selector: '[data-tour="admin-adminGeneral-nav"]', + highlightedSelectors: ['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]'], + content: t('adminOnboarding.systemCustomization', "We have extensive ways to customise the UI: System Settings let you change the app name and languages, Features allows server certificate management, and Endpoints lets you enable or disable specific tools for your users."), + position: 'right', + padding: 10, + action: () => { + removeAllGlows(); + navigateToSection('adminGeneral'); + setTimeout(() => { + addGlowToElements(['[data-tour="admin-adminGeneral-nav"]', '[data-tour="admin-adminFeatures-nav"]', '[data-tour="admin-adminEndpoints-nav"]', '[data-tour="settings-content-area"]']); + }, 100); + }, + }, + [AdminTourStep.DATABASE_SECTION]: { + selector: '[data-tour="admin-adminDatabase-nav"]', + highlightedSelectors: ['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]'], + content: t('adminOnboarding.databaseSection', "For advanced production environments, we have settings to allow external database hookups so you can integrate with your existing infrastructure."), + position: 'right', + padding: 10, + action: () => { + removeAllGlows(); + navigateToSection('adminDatabase'); + setTimeout(() => { + addGlowToElements(['[data-tour="admin-adminDatabase-nav"]', '[data-tour="settings-content-area"]']); + }, 100); + }, + }, + [AdminTourStep.CONNECTIONS_SECTION]: { + selector: '[data-tour="admin-adminConnections-nav"]', + highlightedSelectors: ['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]'], + content: t('adminOnboarding.connectionsSection', "The Connections section supports various login methods including custom SSO and SAML providers like Google and GitHub, plus email integrations for notifications and communications."), + position: 'right', + padding: 10, + action: () => { + removeAllGlows(); + navigateToSection('adminConnections'); + setTimeout(() => { + addGlowToElements(['[data-tour="admin-adminConnections-nav"]', '[data-tour="settings-content-area"]']); + }, 100); + }, + actionAfter: async () => { + await scrollNavToSection('adminAudit'); + }, + }, + [AdminTourStep.ADMIN_TOOLS]: { + selector: '[data-tour="admin-adminAudit-nav"]', + highlightedSelectors: ['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]'], + content: t('adminOnboarding.adminTools', "Finally, we have advanced administration tools like Auditing to track system activity and Usage Analytics to monitor how your users interact with the platform."), + position: 'right', + padding: 10, + action: () => { + removeAllGlows(); + navigateToSection('adminAudit'); + setTimeout(() => { + addGlowToElements(['[data-tour="admin-adminAudit-nav"]', '[data-tour="admin-adminUsage-nav"]', '[data-tour="settings-content-area"]']); + }, 100); + }, + }, + [AdminTourStep.WRAP_UP]: { + selector: '[data-tour="help-button"]', + content: t('adminOnboarding.wrapUp', "That's the admin tour! You've seen the enterprise features that make Stirling PDF a powerful, customisable solution for organisations. Access this tour anytime from the Help menu."), + position: 'right', + padding: 10, + action: () => { + removeAllGlows(); + }, + }, + }; +} + diff --git a/frontend/src/core/components/onboarding/hooks/useOnboardingFlow.ts b/frontend/src/core/components/onboarding/hooks/useOnboardingFlow.ts new file mode 100644 index 0000000000..18dec59fc7 --- /dev/null +++ b/frontend/src/core/components/onboarding/hooks/useOnboardingFlow.ts @@ -0,0 +1,324 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { usePreferences } from '@app/contexts/PreferencesContext'; +import { useAppConfig } from '@app/contexts/AppConfigContext'; +import { useCookieConsentContext } from '@app/contexts/CookieConsentContext'; +import { useOnboarding } from '@app/contexts/OnboardingContext'; +import type { LicenseNotice } from '@app/types/types'; +import { useNavigate } from 'react-router-dom'; +import { + ONBOARDING_SESSION_BLOCK_KEY, + ONBOARDING_SESSION_EVENT, + SERVER_LICENSE_REQUEST_EVENT, + type ServerLicenseRequestPayload, +} from '@app/constants/events'; +import { useServerExperience } from '@app/hooks/useServerExperience'; + +interface InitialModalHandlers { + opened: boolean; + onLicenseNoticeUpdate: (notice: LicenseNotice) => void; + onRequestServerLicense: (options?: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean }) => void; + onClose: () => void; +} + +interface ServerLicenseModalHandlers { + opened: boolean; + licenseNotice: LicenseNotice; + onClose: () => void; + onSeePlans: () => void; +} + +export function useOnboardingFlow() { + const { preferences, updatePreference } = usePreferences(); + const { config } = useAppConfig(); + const { showCookieConsent, isReady: isCookieConsentReady } = useCookieConsentContext(); + const { completeTour, tourType, isOpen } = useOnboarding(); + + const shouldShowIntro = !preferences.hasSeenIntroOnboarding; + const isAdminUser = !!config?.isAdmin; + const { hasPaidLicense } = useServerExperience(); + + const [licenseNotice, setLicenseNotice] = useState({ + totalUsers: null, + freeTierLimit: 5, + isOverLimit: false, + requiresLicense: false, + }); + const [cookieBannerPending, setCookieBannerPending] = useState(false); + const [serverLicenseIntent, setServerLicenseIntent] = useState<'idle' | 'pending' | 'deferred'>('idle'); + const [serverLicenseSource, setServerLicenseSource] = useState<'config' | 'self-reported' | null>(null); + const [isServerLicenseOpen, setIsServerLicenseOpen] = useState(false); + const [hasShownServerLicense, setHasShownServerLicense] = useState(false); + const [toolPromptCompleted, setToolPromptCompleted] = useState( + preferences.toolPanelModePromptSeen || preferences.hasSelectedToolPanelMode, + ); + const introWasOpenRef = useRef(false); + const navigate = useNavigate(); + const onboardingSessionMarkedRef = useRef(false); + + const handleInitialModalClose = useCallback(() => { + if (!preferences.hasSeenIntroOnboarding) { + updatePreference('hasSeenIntroOnboarding', true); + } + }, [preferences.hasSeenIntroOnboarding, updatePreference]); + + const handleLicenseNoticeUpdate = useCallback((notice: LicenseNotice) => { + setLicenseNotice(notice); + }, []); + + const handleToolPromptComplete = useCallback(() => { + setToolPromptCompleted(true); + }, []); + + const maybeShowCookieBanner = useCallback(() => { + if (preferences.hasSeenCookieBanner) { + return; + } + + if (!isCookieConsentReady || isServerLicenseOpen || serverLicenseIntent !== 'idle' || !toolPromptCompleted) { + setCookieBannerPending(true); + return; + } + + setCookieBannerPending(false); + showCookieConsent(); + updatePreference('hasSeenCookieBanner', true); + }, [ + isCookieConsentReady, + isServerLicenseOpen, + preferences.hasSeenCookieBanner, + serverLicenseIntent, + showCookieConsent, + toolPromptCompleted, + updatePreference, + ]); + + const requestServerLicense = useCallback( + ({ + deferUntilTourComplete = false, + selfReportedAdmin = false, + }: { deferUntilTourComplete?: boolean; selfReportedAdmin?: boolean } = {}) => { + const qualifies = isAdminUser || selfReportedAdmin; + if (!qualifies) { + return; + } + if (hasPaidLicense || !licenseNotice.requiresLicense) { + return; + } + setServerLicenseSource(isAdminUser ? 'config' : 'self-reported'); + setServerLicenseIntent((prev) => { + if (prev === 'pending') { + return prev; + } + if (prev === 'deferred') { + return deferUntilTourComplete ? prev : 'pending'; + } + if (prev === 'idle') { + return deferUntilTourComplete ? 'deferred' : 'pending'; + } + return prev; + }); + }, + [hasPaidLicense, isAdminUser, licenseNotice.requiresLicense], + ); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + + const handleServerLicenseRequested = (event: Event) => { + const { detail } = event as CustomEvent; + + if (detail?.licenseNotice) { + setLicenseNotice((prev) => ({ + ...prev, + ...detail.licenseNotice, + totalUsers: + detail.licenseNotice?.totalUsers ?? prev.totalUsers, + freeTierLimit: + detail.licenseNotice?.freeTierLimit ?? prev.freeTierLimit, + isOverLimit: + detail.licenseNotice?.isOverLimit ?? prev.isOverLimit, + requiresLicense: + detail.licenseNotice?.requiresLicense ?? prev.requiresLicense, + })); + } + + requestServerLicense({ + deferUntilTourComplete: detail?.deferUntilTourComplete ?? false, + selfReportedAdmin: detail?.selfReportedAdmin ?? false, + }); + }; + + window.addEventListener( + SERVER_LICENSE_REQUEST_EVENT, + handleServerLicenseRequested as EventListener, + ); + + return () => { + window.removeEventListener( + SERVER_LICENSE_REQUEST_EVENT, + handleServerLicenseRequested as EventListener, + ); + }; + }, [requestServerLicense]); + + useEffect(() => { + if ( + cookieBannerPending && + isCookieConsentReady && + serverLicenseIntent === 'idle' && + !isServerLicenseOpen && + toolPromptCompleted + ) { + maybeShowCookieBanner(); + } + }, [ + cookieBannerPending, + isCookieConsentReady, + isServerLicenseOpen, + serverLicenseIntent, + toolPromptCompleted, + maybeShowCookieBanner, + ]); + + useEffect(() => { + const isEligibleAdmin = + isAdminUser || serverLicenseSource === 'self-reported' || licenseNotice.requiresLicense; + if ( + introWasOpenRef.current && + !shouldShowIntro && + isEligibleAdmin && + toolPromptCompleted && + !hasShownServerLicense && + licenseNotice.requiresLicense && + serverLicenseIntent === 'idle' + ) { + if (!serverLicenseSource) { + setServerLicenseSource(isAdminUser ? 'config' : 'self-reported'); + } + setServerLicenseIntent('pending'); + } + introWasOpenRef.current = shouldShowIntro; + }, [ + hasShownServerLicense, + isAdminUser, + serverLicenseIntent, + shouldShowIntro, + serverLicenseSource, + toolPromptCompleted, + licenseNotice.requiresLicense, + ]); + + useEffect(() => { + const isEligibleAdmin = + isAdminUser || serverLicenseSource === 'self-reported' || licenseNotice.requiresLicense; + if ( + serverLicenseIntent !== 'idle' && + !shouldShowIntro && + !isOpen && + !isServerLicenseOpen && + isEligibleAdmin && + toolPromptCompleted && + licenseNotice.requiresLicense + ) { + setIsServerLicenseOpen(true); + setServerLicenseIntent(serverLicenseIntent === 'deferred' ? 'pending' : 'idle'); + } + }, [ + isAdminUser, + isOpen, + isServerLicenseOpen, + serverLicenseIntent, + shouldShowIntro, + serverLicenseSource, + toolPromptCompleted, + licenseNotice.requiresLicense, + ]); + + const handleServerLicenseClose = useCallback(() => { + setIsServerLicenseOpen(false); + setHasShownServerLicense(true); + setServerLicenseIntent('idle'); + setServerLicenseSource(null); + maybeShowCookieBanner(); + }, [maybeShowCookieBanner]); + + useEffect(() => { + if (onboardingSessionMarkedRef.current) { + return; + } + if (typeof window === 'undefined') { + return; + } + if (shouldShowIntro || isOpen) { + onboardingSessionMarkedRef.current = true; + window.sessionStorage.setItem(ONBOARDING_SESSION_BLOCK_KEY, 'true'); + window.dispatchEvent(new CustomEvent(ONBOARDING_SESSION_EVENT)); + } + }, [isOpen, shouldShowIntro]); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + if (!shouldShowIntro && !isOpen) { + window.sessionStorage.removeItem(ONBOARDING_SESSION_BLOCK_KEY); + window.dispatchEvent(new CustomEvent(ONBOARDING_SESSION_EVENT)); + } + }, [isOpen, shouldShowIntro]); + + const handleServerLicenseSeePlans = useCallback(() => { + handleServerLicenseClose(); + navigate('/settings/adminPlan'); + }, [handleServerLicenseClose, navigate]); + + const handleTourCompletion = useCallback(() => { + completeTour(); + if (serverLicenseIntent === 'deferred') { + setServerLicenseIntent('pending'); + } else if (tourType === 'admin' && (isAdminUser || serverLicenseSource === 'self-reported')) { + setServerLicenseSource((prev) => prev ?? (isAdminUser ? 'config' : 'self-reported')); + setServerLicenseIntent((prev) => (prev === 'pending' ? prev : 'pending')); + } + maybeShowCookieBanner(); + }, [ + completeTour, + isAdminUser, + maybeShowCookieBanner, + serverLicenseIntent, + serverLicenseSource, + tourType, + ]); + + const initialModalProps: InitialModalHandlers = useMemo( + () => ({ + opened: shouldShowIntro, + onLicenseNoticeUpdate: handleLicenseNoticeUpdate, + onRequestServerLicense: requestServerLicense, + onClose: handleInitialModalClose, + }), + [handleInitialModalClose, handleLicenseNoticeUpdate, requestServerLicense, shouldShowIntro], + ); + + const serverLicenseModalProps: ServerLicenseModalHandlers = useMemo( + () => ({ + opened: isServerLicenseOpen, + licenseNotice, + onClose: handleServerLicenseClose, + onSeePlans: handleServerLicenseSeePlans, + }), + [handleServerLicenseClose, handleServerLicenseSeePlans, isServerLicenseOpen, licenseNotice], + ); + + return { + tourType, + isTourOpen: isOpen, + maskClassName: tourType === 'admin' ? 'admin-tour-mask' : undefined, + initialModalProps, + handleToolPromptComplete, + serverLicenseModalProps, + handleTourCompletion, + }; +} + diff --git a/frontend/src/core/components/onboarding/onboardingFlowConfig.ts b/frontend/src/core/components/onboarding/onboardingFlowConfig.ts new file mode 100644 index 0000000000..f9c08eb600 --- /dev/null +++ b/frontend/src/core/components/onboarding/onboardingFlowConfig.ts @@ -0,0 +1,207 @@ +import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide'; +import DesktopInstallSlide from '@app/components/onboarding/slides/DesktopInstallSlide'; +import SecurityCheckSlide from '@app/components/onboarding/slides/SecurityCheckSlide'; +import PlanOverviewSlide from '@app/components/onboarding/slides/PlanOverviewSlide'; +import ServerLicenseSlide from '@app/components/onboarding/slides/ServerLicenseSlide'; +import { SlideConfig, LicenseNotice } from '@app/types/types'; + +export type SlideId = + | 'welcome' + | 'desktop-install' + | 'security-check' + | 'admin-overview' + | 'server-license'; + +export type HeroType = 'rocket' | 'dual-icon' | 'shield' | 'diamond' | 'logo'; + +export type ButtonAction = + | 'next' + | 'prev' + | 'close' + | 'complete-close' + | 'download-selected' + | 'security-next' + | 'launch-admin' + | 'launch-tools' + | 'launch-auto' + | 'see-plans' + | 'skip-to-license'; + +export interface FlowState { + selectedRole: 'admin' | 'user' | null; +} + +export interface OSOption { + label: string; + url: string; + value: string; +} + +export interface SlideFactoryParams { + osLabel: string; + osUrl: string; + osOptions?: OSOption[]; + onDownloadUrlChange?: (url: string) => void; + selectedRole: 'admin' | 'user' | null; + onRoleSelect: (role: 'admin' | 'user' | null) => void; + licenseNotice?: LicenseNotice; + loginEnabled?: boolean; +} + +export interface HeroDefinition { + type: HeroType; +} + +export interface ButtonDefinition { + key: string; + type: 'button' | 'icon'; + label?: string; + icon?: 'chevron-left'; + variant?: 'primary' | 'secondary' | 'default'; + group: 'left' | 'right'; + action: ButtonAction; + disabledWhen?: (state: FlowState) => boolean; +} + +export interface SlideDefinition { + id: SlideId; + createSlide: (params: SlideFactoryParams) => SlideConfig; + hero: HeroDefinition; + buttons: ButtonDefinition[]; +} + +export const SLIDE_DEFINITIONS: Record = { + 'welcome': { + id: 'welcome', + createSlide: () => WelcomeSlide(), + hero: { type: 'rocket' }, + buttons: [ + { + key: 'welcome-next', + type: 'button', + label: 'onboarding.buttons.next', + variant: 'primary', + group: 'right', + action: 'next', + }, + ], + }, + 'desktop-install': { + id: 'desktop-install', + createSlide: ({ osLabel, osUrl, osOptions, onDownloadUrlChange }) => DesktopInstallSlide({ osLabel, osUrl, osOptions, onDownloadUrlChange }), + hero: { type: 'dual-icon' }, + buttons: [ + { + key: 'desktop-back', + type: 'icon', + icon: 'chevron-left', + group: 'left', + action: 'prev', + }, + { + key: 'desktop-skip', + type: 'button', + label: 'onboarding.buttons.skipForNow', + variant: 'secondary', + group: 'left', + action: 'next', + }, + { + key: 'desktop-download', + type: 'button', + label: 'onboarding.buttons.download', + variant: 'primary', + group: 'right', + action: 'download-selected', + }, + ], + }, + 'security-check': { + id: 'security-check', + createSlide: ({ selectedRole, onRoleSelect }) => + SecurityCheckSlide({ selectedRole, onRoleSelect }), + hero: { type: 'shield' }, + buttons: [ + { + key: 'security-back', + type: 'button', + label: 'onboarding.buttons.back', + variant: 'secondary', + group: 'left', + action: 'prev', + }, + { + key: 'security-next', + type: 'button', + label: 'onboarding.buttons.next', + variant: 'primary', + group: 'right', + action: 'security-next', + disabledWhen: (state) => !state.selectedRole, + }, + ], + }, + 'admin-overview': { + id: 'admin-overview', + createSlide: ({ licenseNotice, loginEnabled }) => + PlanOverviewSlide({ isAdmin: true, licenseNotice, loginEnabled }), + hero: { type: 'diamond' }, + buttons: [ + { + key: 'admin-back', + type: 'icon', + icon: 'chevron-left', + group: 'left', + action: 'prev', + }, + { + key: 'admin-show', + type: 'button', + label: 'onboarding.buttons.showMeAround', + variant: 'primary', + group: 'right', + action: 'launch-admin', + }, + { + key: 'admin-skip', + type: 'button', + label: 'onboarding.buttons.skipTheTour', + variant: 'secondary', + group: 'left', + action: 'skip-to-license', + }, + ], + }, + 'server-license': { + id: 'server-license', + createSlide: ({ licenseNotice }) => ServerLicenseSlide({ licenseNotice }), + hero: { type: 'logo' }, + buttons: [ + { + key: 'license-close', + type: 'button', + label: 'onboarding.buttons.skipForNow', + variant: 'secondary', + group: 'left', + action: 'close', + }, + { + key: 'license-see-plans', + type: 'button', + label: 'onboarding.serverLicense.seePlans', + variant: 'primary', + group: 'right', + action: 'see-plans', + }, + ], + }, +}; + +export const FLOW_SEQUENCES = { + loginAdmin: ['welcome', 'desktop-install', 'admin-overview'] as SlideId[], + loginUser: ['welcome', 'desktop-install'] as SlideId[], + noLoginBase: ['welcome', 'desktop-install', 'security-check'] as SlideId[], + noLoginAdmin: ['admin-overview'] as SlideId[], +}; + + diff --git a/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.module.css b/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.module.css new file mode 100644 index 0000000000..5e5799ec2d --- /dev/null +++ b/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.module.css @@ -0,0 +1,70 @@ +.hero { + position: relative; + width: 100%; + height: 220px; + overflow: hidden; + border-radius: 0; +} + +.gradientLayer { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-size: 180% 180%; + opacity: 0; + transition: opacity 0.8s ease-in-out; + z-index: 1; +} + +.gradientLayerActive { + opacity: 1; + animation: gradientShift 18s ease-in-out infinite alternate; + z-index: 2; +} + +.gradientLayerPrev { + opacity: 1; + z-index: 3; + transition: opacity 0.8s ease-in-out; +} + +.gradientLayerPrevFadeOut { + opacity: 0; + z-index: 3; + transition: opacity 0.8s ease-in-out; +} + +.circle { + position: absolute; + border-radius: 50%; + pointer-events: none; + box-shadow: 0 18px 36px rgba(15, 23, 42, 0.12); + animation-name: circleSway; + animation-timing-function: ease-in-out; + animation-iteration-count: infinite; + animation-direction: alternate; + animation-duration: var(--circle-duration, 15s); + animation-delay: var(--circle-delay, 0s); + will-change: transform; + z-index: 10; +} + +@keyframes gradientShift { + 0% { + background-position: 0% 50%; + } + 100% { + background-position: 100% 50%; + } +} + +@keyframes circleSway { + 0% { + transform: translate3d(0, 0, 0); + } + 100% { + transform: translate3d(var(--circle-move-x, 40px), var(--circle-move-y, 24px), 0); + } +} diff --git a/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.tsx b/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.tsx new file mode 100644 index 0000000000..289b07b6c4 --- /dev/null +++ b/frontend/src/core/components/onboarding/slides/AnimatedSlideBackground.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import styles from '@app/components/onboarding/slides/AnimatedSlideBackground.module.css'; +import { AnimatedSlideBackgroundProps } from '@app/types/types'; + +type CircleStyles = React.CSSProperties & { + '--circle-move-x'?: string; + '--circle-move-y'?: string; + '--circle-duration'?: string; + '--circle-delay'?: string; +}; + +interface AnimatedSlideBackgroundComponentProps extends AnimatedSlideBackgroundProps { + isActive: boolean; + slideKey: string; +} + +export default function AnimatedSlideBackground({ + gradientStops, + circles, + isActive, +}: AnimatedSlideBackgroundComponentProps) { + const [prevGradient, setPrevGradient] = React.useState<[string, string] | null>(null); + const [currentGradient, setCurrentGradient] = React.useState<[string, string]>(gradientStops); + const [isTransitioning, setIsTransitioning] = React.useState(false); + const isFirstMount = React.useRef(true); + + React.useEffect(() => { + // Skip transition on first mount + if (isFirstMount.current) { + isFirstMount.current = false; + setCurrentGradient(gradientStops); + return; + } + + // Only transition if gradient actually changed + if (currentGradient[0] !== gradientStops[0] || currentGradient[1] !== gradientStops[1]) { + // Store previous gradient and start transition + setPrevGradient(currentGradient); + setIsTransitioning(true); + + // Update to new gradient (will fade in) + setCurrentGradient(gradientStops); + } + }, [gradientStops]); + + const currentGradientStyle = React.useMemo( + () => ({ + backgroundImage: `linear-gradient(135deg, ${currentGradient[0]}, ${currentGradient[1]})`, + }), + [currentGradient], + ); + + const prevGradientStyle = prevGradient + ? { + backgroundImage: `linear-gradient(135deg, ${prevGradient[0]}, ${prevGradient[1]})`, + } + : null; + + return ( +
+ {prevGradientStyle && isTransitioning && ( +
{ + setPrevGradient(null); + setIsTransitioning(false); + }} + /> + )} +
+ {circles.map((circle, index) => { + const { position, size, color, opacity, blur, amplitude = 48, duration = 15, delay = 0 } = circle; + + const moveX = position === 'bottom-left' ? amplitude : -amplitude; + const moveY = position === 'bottom-left' ? -amplitude * 0.6 : amplitude * 0.6; + + const circleStyle: CircleStyles = { + width: size, + height: size, + background: color, + opacity: opacity ?? 0.9, + filter: blur ? `blur(${blur}px)` : undefined, + '--circle-move-x': `${moveX}px`, + '--circle-move-y': `${moveY}px`, + '--circle-duration': `${duration}s`, + '--circle-delay': `${delay}s`, + }; + + const defaultOffset = -size / 2; + const offsetX = circle.offsetX ?? 0; + const offsetY = circle.offsetY ?? 0; + + if (position === 'bottom-left') { + circleStyle.left = `${defaultOffset + offsetX}px`; + circleStyle.bottom = `${defaultOffset + offsetY}px`; + } else { + circleStyle.right = `${defaultOffset + offsetX}px`; + circleStyle.top = `${defaultOffset + offsetY}px`; + } + + return ( +
+ ); + })} +
+ ); +} diff --git a/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx b/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx new file mode 100644 index 0000000000..9cebfbb9cd --- /dev/null +++ b/frontend/src/core/components/onboarding/slides/DesktopInstallSlide.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { SlideConfig } from '@app/types/types'; +import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig'; +import { DesktopInstallTitle, type OSOption } from '@app/components/onboarding/slides/DesktopInstallTitle'; + +export type { OSOption }; + +interface DesktopInstallSlideProps { + osLabel: string; + osUrl: string; + osOptions?: OSOption[]; + onDownloadUrlChange?: (url: string) => void; +} + +const DesktopInstallBody = () => { + const { t } = useTranslation(); + + return ( + + {t( + 'onboarding.desktopInstall.body', + 'Stirling works best as a desktop app. You can use it offline, access documents faster, and make edits locally on your computer.', + )} + + ); +}; + +export default function DesktopInstallSlide({ + osLabel, + osUrl, + osOptions = [], + onDownloadUrlChange, +}: DesktopInstallSlideProps): SlideConfig { + + return { + key: 'desktop-install', + title: ( + + ), + body: , + downloadUrl: osUrl, + background: { + gradientStops: ['#2563EB', '#0EA5E9'], + circles: UNIFIED_CIRCLE_CONFIG, + }, + }; +} + diff --git a/frontend/src/core/components/onboarding/slides/DesktopInstallTitle.tsx b/frontend/src/core/components/onboarding/slides/DesktopInstallTitle.tsx new file mode 100644 index 0000000000..ac42b518b3 --- /dev/null +++ b/frontend/src/core/components/onboarding/slides/DesktopInstallTitle.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Menu, ActionIcon } from '@mantine/core'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; + +export interface OSOption { + label: string; + url: string; + value: string; +} + +interface DesktopInstallTitleProps { + osLabel: string; + osUrl: string; + osOptions: OSOption[]; + onDownloadUrlChange?: (url: string) => void; +} + +export const DesktopInstallTitle: React.FC = ({ + osLabel, + osUrl, + osOptions, + onDownloadUrlChange +}) => { + const { t } = useTranslation(); + const [selectedOsUrl, setSelectedOsUrl] = React.useState(osUrl); + + React.useEffect(() => { + setSelectedOsUrl(osUrl); + }, [osUrl]); + + const handleOsSelect = React.useCallback((option: OSOption) => { + setSelectedOsUrl(option.url); + onDownloadUrlChange?.(option.url); + }, [onDownloadUrlChange]); + + const currentOsOption = osOptions.find(opt => opt.url === selectedOsUrl) || + (osOptions.length > 0 ? osOptions[0] : { label: osLabel, url: osUrl }); + + const displayLabel = currentOsOption.label || osLabel; + const title = displayLabel + ? t('onboarding.desktopInstall.titleWithOs', 'Download for {{osLabel}}', { osLabel: displayLabel }) + : t('onboarding.desktopInstall.title', 'Download'); + + // If only one option or no options, don't show dropdown + if (osOptions.length <= 1) { + return
{title}
; + } + + return ( +
+ {title} + + + + + + + + {osOptions.map((option) => { + const isSelected = option.url === selectedOsUrl; + return ( + handleOsSelect(option)} + style={{ + backgroundColor: isSelected + ? 'light-dark(var(--mantine-color-blue-1), var(--mantine-color-blue-8))' + : 'transparent', + color: isSelected + ? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))' + : 'inherit', + }} + > + {option.label} + + ); + })} + + +
+ ); +}; + diff --git a/frontend/src/core/components/onboarding/slides/PlanOverviewSlide.tsx b/frontend/src/core/components/onboarding/slides/PlanOverviewSlide.tsx new file mode 100644 index 0000000000..3b8d4bfb0c --- /dev/null +++ b/frontend/src/core/components/onboarding/slides/PlanOverviewSlide.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { Trans, useTranslation } from 'react-i18next'; +import { SlideConfig, LicenseNotice } from '@app/types/types'; +import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig'; + +interface PlanOverviewSlideProps { + isAdmin: boolean; + licenseNotice?: LicenseNotice; + loginEnabled?: boolean; +} + +const DEFAULT_FREE_TIER_LIMIT = 5; + +const PlanOverviewTitle: React.FC<{ isAdmin: boolean }> = ({ isAdmin }) => { + const { t } = useTranslation(); + return ( + <> + {isAdmin + ? t('onboarding.planOverview.adminTitle', 'Admin Overview') + : t('onboarding.planOverview.userTitle', 'Plan Overview')} + + ); +}; + +const AdminOverviewBody: React.FC<{ freeTierLimit: number; loginEnabled: boolean }> = ({ + freeTierLimit, + loginEnabled, +}) => { + const adminBodyKey = loginEnabled + ? 'onboarding.planOverview.adminBodyLoginEnabled' + : 'onboarding.planOverview.adminBodyLoginDisabled'; + + const defaultValue = loginEnabled + ? 'As an admin, you can manage users, configure settings, and monitor server health. The first {{freeTierLimit}} people on your server get to use Stirling free of charge.' + : 'Once you enable login mode, you can manage users, configure settings, and monitor server health. The first {{freeTierLimit}} people on your server get to use Stirling free of charge.'; + + return ( + }} + defaults={defaultValue} + /> + ); +}; + +const UserOverviewBody: React.FC = () => { + const { t } = useTranslation(); + return ( + + {t( + 'onboarding.planOverview.userBody', + "Invite teammates, assign roles, and keep your documents organized in one secure workspace. Enable login mode whenever you're ready to grow beyond solo use.", + )} + + ); +}; + +const PlanOverviewBody: React.FC<{ isAdmin: boolean; freeTierLimit: number; loginEnabled: boolean }> = ({ + isAdmin, + freeTierLimit, + loginEnabled, +}) => + isAdmin ? : ; + +export default function PlanOverviewSlide({ + isAdmin, + licenseNotice, + loginEnabled = false, +}: PlanOverviewSlideProps): SlideConfig { + const freeTierLimit = licenseNotice?.freeTierLimit ?? DEFAULT_FREE_TIER_LIMIT; + + return { + key: isAdmin ? 'admin-overview' : 'plan-overview', + title: , + body: , + background: { + gradientStops: isAdmin ? ['#4F46E5', '#0EA5E9'] : ['#F97316', '#EF4444'], + circles: UNIFIED_CIRCLE_CONFIG, + }, + }; +} + diff --git a/frontend/src/core/components/onboarding/slides/SecurityCheckSlide.tsx b/frontend/src/core/components/onboarding/slides/SecurityCheckSlide.tsx new file mode 100644 index 0000000000..0efb2f591d --- /dev/null +++ b/frontend/src/core/components/onboarding/slides/SecurityCheckSlide.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { Select } from '@mantine/core'; +import { SlideConfig } from '@app/types/types'; +import LocalIcon from '@app/components/shared/LocalIcon'; +import { UNIFIED_CIRCLE_CONFIG } from '@app/components/onboarding/slides/unifiedBackgroundConfig'; +import i18n from '@app/i18n'; +import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css'; + +interface SecurityCheckSlideProps { + selectedRole: 'admin' | 'user' | null; + onRoleSelect: (role: 'admin' | 'user' | null) => void; +} + +export default function SecurityCheckSlide({ + selectedRole, + onRoleSelect, +}: SecurityCheckSlideProps): SlideConfig { + return { + key: 'security-check', + title: 'Security Check', + body: ( +
+
+
+ + {i18n.t('onboarding.securityCheck.message', 'The application has undergone significant changes recently. Your server admin\'s attention may be required. Please confirm your role to continue.')} +
+ +