Compare commits

..
Author SHA1 Message Date
Anthony StirlingandGitHub c07189a357 Merge branch 'V2' into settingsDisplay 2025-11-14 12:52:00 +00:00
Anthony Stirling b92efc4463 translations 2025-11-14 11:46:58 +00:00
Anthony Stirling 884f250d00 lint 2025-11-14 11:41:04 +00:00
Anthony Stirling 5f2114fb1a lint 2025-11-14 11:34:51 +00:00
Anthony Stirling a9bf677384 again 2025-11-14 11:29:16 +00:00
Anthony Stirling 9ba4238fe3 typecheck 2025-11-14 11:28:37 +00:00
Anthony Stirling 1db931c94b login expired 2025-11-13 18:28:34 +00:00
Anthony Stirling 73dcbce95a Merge remote-tracking branch 'origin/V2' into settingsDisplay 2025-11-13 17:39:47 +00:00
Anthony Stirling cde8f4ca6a logo on loading 2025-11-13 14:49:51 +00:00
Anthony Stirling abbe209c38 logo cleanups 2025-11-13 14:41:18 +00:00
Anthony Stirling 537eed1714 Backend:
- Make /api/v1/proprietary/ui-data/login endpoint public
  - Fix enableLogin to check both config flag AND proprietary module
availability
  - Add enableLogin field to login endpoint response

  Frontend:
  - Dynamically show/hide SSO providers based on backend configuration
  - Redirect to home when login is disabled (anonymous mode)
  - Suppress 401 authentication errors on auth pages
  - Fix carousel image reset on input typing (memoize component)
  - Remove forgot password and signup links from login page
  - Conditionally render email form and dividers based on SSO
availability

  Other:
  - Add .dockerignore for faster Docker builds
  - Configure nginx to run as non-root user
  - Bump version to 2.0.0
2025-11-13 14:18:43 +00:00
Anthony Stirling 2239e9cc2d move to propietary for settings 2025-11-12 17:18:35 +00:00
Anthony Stirling 0da1ae06d9 init 2025-11-12 15:05:27 +00:00
69 changed files with 1070 additions and 2008 deletions
+10 -3
View File
@@ -17,11 +17,18 @@ on:
branches: [main, V2]
paths:
- 'frontend/src-tauri/**'
- 'frontend/src/desktop/**'
- 'frontend/tsconfig.desktop.json'
- 'frontend/src/**'
- 'frontend/package.json'
- 'frontend/package-lock.json'
- '.github/workflows/tauri-build.yml'
push:
branches: [main, V2]
paths:
- 'frontend/src-tauri/**'
- 'frontend/src/**'
- 'frontend/package.json'
- 'frontend/package-lock.json'
- '.github/workflows/tauri-build.yml'
permissions:
contents: read
@@ -337,4 +344,4 @@ jobs:
echo "❌ Some Tauri builds failed."
echo "Please check the logs and fix any issues."
exit 1
fi
fi
@@ -1,37 +0,0 @@
package stirling.software.common.annotations.api;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
* Combined annotation for Invite management controllers.
* Includes @RestController, @RequestMapping("/api/v1/invite"), and OpenAPI @Tag.
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/invite")
@Tag(
name = "Invite",
description =
"""
Invite-link generation and acceptance endpoints for onboarding new users.
Provides the ability to issue invitation tokens, send optional email invites,
validate and accept invite links, and manage pending invitations for teams.
Typical use cases include:
• Admin workflows for issuing time-limited invitations to external users
• Self-service invite acceptance and team assignment
• License limit enforcement when provisioning new accounts
Target users: administrators and automation scripts orchestrating user onboarding.
""")
public @interface InviteApi {}
@@ -129,53 +129,61 @@ public class SecurityConfiguration {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
List<String> configuredOrigins = null;
if (applicationProperties.getSystem() != null) {
configuredOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
}
// Read CORS allowed origins from settings
if (applicationProperties.getSystem() != null
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) {
CorsConfiguration cfg = new CorsConfiguration();
if (configuredOrigins != null && !configuredOrigins.isEmpty()) {
cfg.setAllowedOriginPatterns(configuredOrigins);
List<String> allowedOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
CorsConfiguration cfg = new CorsConfiguration();
// Use setAllowedOriginPatterns for better wildcard and port support
cfg.setAllowedOriginPatterns(allowedOrigins);
log.debug(
"CORS configured with allowed origin patterns from settings.yml: {}",
configuredOrigins);
allowedOrigins);
// Set allowed methods explicitly (including OPTIONS for preflight)
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
// Set allowed headers explicitly
cfg.setAllowedHeaders(
List.of(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN"));
// Set exposed headers (headers that the browser can access)
cfg.setExposedHeaders(
List.of(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type"));
// Allow credentials (cookies, authorization headers)
cfg.setAllowCredentials(true);
// Set max age for preflight cache
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", cfg);
return source;
} else {
// Default to allowing all origins when nothing is configured
cfg.setAllowedOriginPatterns(List.of("*"));
// No CORS origins configured - return null to disable CORS processing entirely
// This avoids empty CORS policy that unexpectedly rejects preflights
log.info(
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins.");
"CORS is disabled - no allowed origins configured in settings.yml (system.corsAllowedOrigins)");
return null;
}
// Explicitly configure supported HTTP methods (include OPTIONS for preflight)
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
cfg.setAllowedHeaders(
List.of(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN"));
cfg.setExposedHeaders(
List.of(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", cfg);
return source;
}
@Bean
@@ -15,7 +15,7 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.InviteApi;
import stirling.software.common.annotations.api.UserApi;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Team;
@@ -26,9 +26,11 @@ import stirling.software.proprietary.security.service.EmailService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
@InviteApi
@UserApi
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/v1/invite")
public class InviteLinkController {
private final InviteTokenRepository inviteTokenRepository;
+4 -40
View File
@@ -41,30 +41,6 @@
"cancel": "Cancel",
"confirm": "Extract"
},
"defaultApp": {
"title": "Set as Default PDF App",
"message": "Would you like to set Stirling PDF as your default PDF editor?",
"description": "You can change this later in your system settings.",
"notNow": "Not Now",
"setDefault": "Set Default",
"dismiss": "Dismiss",
"prompt": {
"title": "Set as Default PDF Editor",
"message": "Make Stirling PDF your default application for opening PDF files."
},
"success": {
"title": "Default App Set",
"message": "Stirling PDF is now your default PDF editor"
},
"settingsOpened": {
"title": "Settings Opened",
"message": "Please select Stirling PDF in your system settings"
},
"error": {
"title": "Error",
"message": "Failed to set default PDF handler"
}
},
"language": {
"direction": "ltr"
},
@@ -353,18 +329,10 @@
"autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.",
"defaultToolPickerMode": "Default tool picker mode",
"defaultToolPickerModeDescription": "Choose whether the tool picker opens in fullscreen or sidebar by default",
"rememberInputs": "Remember tool inputs",
"rememberInputsDescription": "Store the last settings you used for each tool on this device",
"mode": {
"fullscreen": "Fullscreen",
"sidebar": "Sidebar"
},
"defaultPdfEditor": "Default PDF editor",
"defaultPdfEditorActive": "Stirling PDF is your default PDF editor",
"defaultPdfEditorInactive": "Another application is set as default",
"defaultPdfEditorChecking": "Checking...",
"defaultPdfEditorSet": "Already Default",
"setAsDefault": "Set as Default"
}
},
"hotkeys": {
"title": "Keyboard Shortcuts",
@@ -3687,10 +3655,8 @@
"help": "Help",
"account": "Account",
"config": "Config",
"settings": "Settings",
"adminSettings": "Admin Settings",
"allTools": "Tools",
"reader": "Reader",
"allTools": "All Tools",
"helpMenu": {
"toolsTour": "Tools Tour",
"toolsTourDesc": "Learn what the tools can do",
@@ -4831,7 +4797,7 @@
"maybeLater": "Maybe Later",
"dontShowAgain": "Don't Show Again"
},
"allTools": "This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.",
"allTools": "This is the <strong>All Tools</strong> panel, where you can browse and select from all available PDF tools.",
"selectCropTool": "Let's select the <strong>Crop</strong> tool to demonstrate how to use one of the tools.",
"toolInterface": "This is the <strong>Crop</strong> tool interface. As you can see, there's not much there because we haven't added any PDF files to work with yet.",
"filesButton": "The <strong>Files</strong> button on the Quick Access bar allows you to upload PDFs to use the tools on.",
@@ -4891,8 +4857,7 @@
"admin": "Admin",
"roleDescriptions": {
"admin": "Can manage settings and invite members, with full administrative access.",
"member": "Can view and edit shared files, but cannot manage workspace settings or members.",
"user": "User"
"member": "Can view and edit shared files, but cannot manage workspace settings or members."
},
"editRole": "Edit Role",
"enable": "Enable",
@@ -4973,7 +4938,6 @@
"copied": "Link copied to clipboard",
"success": "Invite link generated successfully",
"successWithEmail": "Invite link generated and sent via email",
"emailSent": "Invite link generated and sent via email",
"emailFailed": "Invite link generated, but email failed",
"emailFailedDetails": "Error: {0}. Please share the invite link manually.",
"error": "Failed to generate invite link",
@@ -323,8 +323,6 @@
"autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.",
"defaultToolPickerMode": "Default tool picker mode",
"defaultToolPickerModeDescription": "Choose whether the tool picker opens in fullscreen or sidebar by default",
"rememberInputs": "Remember tool inputs",
"rememberInputsDescription": "Store the last settings you used for each tool on this device",
"mode": {
"fullscreen": "Fullscreen",
"sidebar": "Sidebar"
@@ -3969,7 +3967,7 @@
"account": "Account",
"config": "Config",
"adminSettings": "Admin Settings",
"allTools": "Tools"
"allTools": "All Tools"
},
"admin": {
"error": "Error",
-11
View File
@@ -643,15 +643,6 @@ dependencies = [
"libc",
]
[[package]]
name = "core-services"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0aa845ab21b847ee46954be761815f18f16469b29ef3ba250241b1b8bab659a"
dependencies = [
"core-foundation 0.10.1",
]
[[package]]
name = "cpufeatures"
version = "0.2.17"
@@ -3950,8 +3941,6 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
name = "stirling-pdf"
version = "0.1.0"
dependencies = [
"core-foundation 0.10.1",
"core-services",
"log",
"reqwest 0.11.27",
"serde",
-4
View File
@@ -31,7 +31,3 @@ tauri-plugin-fs = "2.4.4"
tauri-plugin-single-instance = "2.0.1"
tokio = { version = "1.0", features = ["time"] }
reqwest = { version = "0.11", features = ["json"] }
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"
core-services = "1.0"
@@ -1,217 +0,0 @@
use crate::utils::add_log;
#[cfg(any(target_os = "windows", target_os = "linux"))]
use std::process::Command;
/// Check if Stirling PDF is the default PDF handler
#[tauri::command]
pub fn is_default_pdf_handler() -> Result<bool, String> {
add_log("🔍 Checking if app is default PDF handler".to_string());
#[cfg(target_os = "windows")]
{
check_default_windows()
}
#[cfg(target_os = "macos")]
{
check_default_macos()
}
#[cfg(target_os = "linux")]
{
check_default_linux()
}
}
/// Attempt to set/prompt for Stirling PDF as default PDF handler
#[tauri::command]
pub fn set_as_default_pdf_handler() -> Result<String, String> {
add_log("⚙️ Attempting to set as default PDF handler".to_string());
#[cfg(target_os = "windows")]
{
set_default_windows()
}
#[cfg(target_os = "macos")]
{
set_default_macos()
}
#[cfg(target_os = "linux")]
{
set_default_linux()
}
}
// ============================================================================
// Windows Implementation
// ============================================================================
#[cfg(target_os = "windows")]
fn check_default_windows() -> Result<bool, String> {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
// Query the default handler for .pdf extension
let output = Command::new("cmd")
.args(["/C", "assoc .pdf"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| format!("Failed to check default app: {}", e))?;
let assoc = String::from_utf8_lossy(&output.stdout);
add_log(format!("Windows PDF association: {}", assoc.trim()));
// Get the ProgID for .pdf files
if let Some(prog_id) = assoc.trim().strip_prefix(".pdf=") {
// Query what application handles this ProgID
let output = Command::new("cmd")
.args(["/C", &format!("ftype {}", prog_id)])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| format!("Failed to query file type: {}", e))?;
let ftype = String::from_utf8_lossy(&output.stdout);
add_log(format!("Windows file type: {}", ftype.trim()));
// Check if it contains "Stirling" or our app name
let is_default = ftype.to_lowercase().contains("stirling");
Ok(is_default)
} else {
Ok(false)
}
}
#[cfg(target_os = "windows")]
fn set_default_windows() -> Result<String, String> {
// On Windows 10+, we need to open the Default Apps settings
// as programmatic setting requires a signed installer
Command::new("cmd")
.args(["/C", "start", "ms-settings:defaultapps"])
.spawn()
.map_err(|e| format!("Failed to open default apps settings: {}", e))?;
add_log("Opened Windows Default Apps settings".to_string());
Ok("opened_settings".to_string())
}
// ============================================================================
// macOS Implementation (using LaunchServices framework)
// ============================================================================
#[cfg(target_os = "macos")]
fn check_default_macos() -> Result<bool, String> {
use core_foundation::base::TCFType;
use core_foundation::string::{CFString, CFStringRef};
use std::os::raw::c_int;
// Define the LSCopyDefaultRoleHandlerForContentType function
#[link(name = "CoreServices", kind = "framework")]
extern "C" {
fn LSCopyDefaultRoleHandlerForContentType(
content_type: CFStringRef,
role: c_int,
) -> CFStringRef;
}
const K_LS_ROLES_ALL: c_int = 0xFFFFFFFF_u32 as c_int;
unsafe {
// Query the default handler for "com.adobe.pdf" (PDF UTI - standard macOS identifier)
let pdf_uti = CFString::new("com.adobe.pdf");
let handler_ref = LSCopyDefaultRoleHandlerForContentType(pdf_uti.as_concrete_TypeRef(), K_LS_ROLES_ALL);
if handler_ref.is_null() {
add_log("No default PDF handler found".to_string());
return Ok(false);
}
let handler = CFString::wrap_under_create_rule(handler_ref);
let handler_str = handler.to_string();
add_log(format!("macOS PDF handler: {}", handler_str));
// Check if it's our bundle identifier
let is_default = handler_str == "stirling.pdf.dev";
Ok(is_default)
}
}
#[cfg(target_os = "macos")]
fn set_default_macos() -> Result<String, String> {
use core_foundation::base::TCFType;
use core_foundation::string::{CFString, CFStringRef};
use std::os::raw::c_int;
// Define the LSSetDefaultRoleHandlerForContentType function
#[link(name = "CoreServices", kind = "framework")]
extern "C" {
fn LSSetDefaultRoleHandlerForContentType(
content_type: CFStringRef,
role: c_int,
handler_bundle_id: CFStringRef,
) -> c_int; // OSStatus
}
const K_LS_ROLES_ALL: c_int = 0xFFFFFFFF_u32 as c_int;
unsafe {
// Set our app as the default handler for PDF files
let pdf_uti = CFString::new("com.adobe.pdf");
let our_bundle_id = CFString::new("stirling.pdf.dev");
let status = LSSetDefaultRoleHandlerForContentType(
pdf_uti.as_concrete_TypeRef(),
K_LS_ROLES_ALL,
our_bundle_id.as_concrete_TypeRef(),
);
if status == 0 {
add_log("Successfully triggered default app dialog".to_string());
Ok("set_successfully".to_string())
} else {
let error_msg = format!("LaunchServices returned status: {}", status);
add_log(error_msg.clone());
Err(error_msg)
}
}
}
// ============================================================================
// Linux Implementation
// ============================================================================
#[cfg(target_os = "linux")]
fn check_default_linux() -> Result<bool, String> {
// Use xdg-mime to check the default application for PDF files
let output = Command::new("xdg-mime")
.args(["query", "default", "application/pdf"])
.output()
.map_err(|e| format!("Failed to check default app: {}", e))?;
let handler = String::from_utf8_lossy(&output.stdout);
add_log(format!("Linux PDF handler: {}", handler.trim()));
// Check if it's our .desktop file
let is_default = handler.trim() == "stirling-pdf.desktop";
Ok(is_default)
}
#[cfg(target_os = "linux")]
fn set_default_linux() -> Result<String, String> {
// Use xdg-mime to set the default application for PDF files
let result = Command::new("xdg-mime")
.args(["default", "stirling-pdf.desktop", "application/pdf"])
.output()
.map_err(|e| format!("Failed to set default app: {}", e))?;
if result.status.success() {
add_log("Set as default PDF handler on Linux".to_string());
Ok("set_successfully".to_string())
} else {
let error = String::from_utf8_lossy(&result.stderr);
add_log(format!("Failed to set default: {}", error));
Err(format!("Failed to set as default: {}", error))
}
}
+17 -5
View File
@@ -14,11 +14,23 @@ pub fn add_opened_file(file_path: String) {
// Command to get opened file paths (if app was launched with files)
#[tauri::command]
pub async fn get_opened_files() -> Result<Vec<String>, String> {
// Get all files from the OPENED_FILES store
// Command line args are processed in setup() callback and added to this store
// Additional files from second instances or events are also added here
let opened_files = OPENED_FILES.lock().unwrap();
let all_files = opened_files.clone();
let mut all_files: Vec<String> = Vec::new();
// Get files from command line arguments (Windows/Linux 'Open With Stirling' behaviour)
let args: Vec<String> = std::env::args().collect();
let pdf_files: Vec<String> = args.iter()
.skip(1)
.filter(|arg| std::path::Path::new(arg).exists())
.cloned()
.collect();
all_files.extend(pdf_files);
// Add any files sent via events or other instances (macOS 'Open With Stirling' behaviour, also Windows/Linux extra files)
{
let opened_files = OPENED_FILES.lock().unwrap();
all_files.extend(opened_files.clone());
}
add_log(format!("📂 Returning {} opened file(s)", all_files.len()));
Ok(all_files)
-2
View File
@@ -1,9 +1,7 @@
pub mod backend;
pub mod health;
pub mod files;
pub mod default_app;
pub use backend::{start_backend, cleanup_backend};
pub use health::check_backend_health;
pub use files::{get_opened_files, clear_opened_files, add_opened_file};
pub use default_app::{is_default_pdf_handler, set_as_default_pdf_handler};
+7 -38
View File
@@ -3,16 +3,7 @@ use tauri::{RunEvent, WindowEvent, Emitter, Manager};
mod utils;
mod commands;
use commands::{
start_backend,
check_backend_health,
get_opened_files,
clear_opened_files,
cleanup_backend,
add_opened_file,
is_default_pdf_handler,
set_as_default_pdf_handler,
};
use commands::{start_backend, check_backend_health, get_opened_files, clear_opened_files, cleanup_backend, add_opened_file};
use utils::{add_log, get_tauri_logs};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -32,6 +23,9 @@ pub fn run() {
// Store file for later retrieval (in case frontend isn't ready yet)
add_opened_file(arg.clone());
// Also emit event for immediate handling if frontend is ready
let _ = app.emit("file-opened", arg.clone());
// Bring the existing window to front
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_focus();
@@ -39,34 +33,13 @@ pub fn run() {
}
}
}
// Emit a generic notification that files were added (frontend will re-read storage)
let _ = app.emit("files-changed", ());
}))
.setup(|_app| {
add_log("🚀 Tauri app setup started".to_string());
// Process command line arguments on first launch
let args: Vec<String> = std::env::args().collect();
for arg in args.iter().skip(1) {
if std::path::Path::new(arg).exists() {
add_log(format!("📂 Initial file from command line: {}", arg));
add_opened_file(arg.clone());
}
}
add_log("🔍 DEBUG: Setup completed".to_string());
Ok(())
})
.invoke_handler(tauri::generate_handler![
start_backend,
check_backend_health,
get_opened_files,
clear_opened_files,
get_tauri_logs,
is_default_pdf_handler,
set_as_default_pdf_handler,
])
.invoke_handler(tauri::generate_handler![start_backend, check_backend_health, get_opened_files, clear_opened_files, get_tauri_logs])
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|app_handle, event| {
@@ -85,7 +58,6 @@ pub fn run() {
#[cfg(target_os = "macos")]
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://") {
@@ -93,14 +65,11 @@ pub fn run() {
if file_path.ends_with(".pdf") {
add_log(format!("📂 Processing opened PDF: {}", file_path));
add_opened_file(file_path.to_string());
added_files = true;
// Use unified event name for consistency across platforms
let _ = app_handle.emit("file-opened", file_path.to_string());
}
}
}
// Emit a generic notification that files were added (frontend will re-read storage)
if added_files {
let _ = app_handle.emit("files-changed", ());
}
}
_ => {
// Only log unhandled events in debug mode to reduce noise
+2 -5
View File
@@ -1,6 +1,5 @@
import { Suspense } from "react";
import { AppProviders } from "@app/components/AppProviders";
import { AppLayout } from "@app/components/AppLayout";
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
import HomePage from "@app/pages/HomePage";
import OnboardingTour from "@app/components/onboarding/OnboardingTour";
@@ -17,10 +16,8 @@ export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
<AppProviders>
<AppLayout>
<HomePage />
<OnboardingTour />
</AppLayout>
<HomePage />
<OnboardingTour />
</AppProviders>
</Suspense>
);
@@ -1,31 +0,0 @@
import { ReactNode } from 'react';
import { useBanner } from '@app/contexts/BannerContext';
interface AppLayoutProps {
children: ReactNode;
}
/**
* App layout wrapper that handles banner rendering and viewport sizing
* Automatically adjusts child components to fit remaining space after banner
*/
export function AppLayout({ children }: AppLayoutProps) {
const { banner } = useBanner();
return (
<>
<style>{`
.h-screen,
.right-rail {
height: 100% !important;
}
`}</style>
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
{banner}
<div style={{ flex: 1, minHeight: 0, height: 0 }}>
{children}
</div>
</div>
</>
);
}
+31 -34
View File
@@ -16,7 +16,6 @@ import { OnboardingProvider } from "@app/contexts/OnboardingContext";
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
import { BannerProvider } from "@app/contexts/BannerContext";
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
import { useScarfTracking } from "@app/hooks/useScarfTracking";
import { useAppInitialization } from "@app/hooks/useAppInitialization";
@@ -51,44 +50,42 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
<PreferencesProvider>
<RainbowThemeProvider>
<ErrorBoundary>
<BannerProvider>
<OnboardingProvider>
<AppConfigProvider
retryOptions={appConfigRetryOptions}
{...appConfigProviderProps}
>
<ScarfTrackingInitializer />
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<AppInitializer />
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<PageEditorProvider>
<OnboardingProvider>
<AppConfigProvider
retryOptions={appConfigRetryOptions}
{...appConfigProviderProps}
>
<ScarfTrackingInitializer />
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<AppInitializer />
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<PageEditorProvider>
<SignatureProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</RightRailProvider>
</SignatureProvider>
</PageEditorProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
</AppConfigProvider>
</OnboardingProvider>
</BannerProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
</AppConfigProvider>
</OnboardingProvider>
</ErrorBoundary>
</RainbowThemeProvider>
</PreferencesProvider>
@@ -123,7 +123,7 @@ export default function OnboardingTour() {
const stepsConfig: Record<TourStep, StepType> = {
[TourStep.ALL_TOOLS]: {
selector: '[data-tour="tool-panel"]',
content: t('onboarding.allTools', 'This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.'),
content: t('onboarding.allTools', 'This is the <strong>All Tools</strong> panel, where you can browse and select from all available PDF tools.'),
position: 'center',
padding: 0,
action: () => {
@@ -35,20 +35,20 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
const iconNode = (
<span className="iconContainer">
<AppsIcon sx={{ fontSize: isActive ? '1.875rem' : '1.5rem' }} />
<AppsIcon sx={{ fontSize: '2rem' }} />
</span>
);
return (
<Tooltip content={t("quickAccess.allTools", "Tools")} position="right" arrow containerStyle={{ marginTop: "-1rem" }} maxWidth={200}>
<Tooltip content={t("quickAccess.allTools", "All Tools")} position="right" arrow containerStyle={{ marginTop: "-1rem" }} maxWidth={200}>
<div className="flex flex-col items-center gap-1 mt-4 mb-2">
<ActionIcon
component="a"
href={navProps.href}
onClick={handleNavClick}
size={isActive ? 'lg' : 'md'}
size={'lg'}
variant="subtle"
aria-label={t("quickAccess.allTools", "Tools")}
aria-label={t("quickAccess.allTools", "All Tools")}
style={{
backgroundColor: isActive ? 'var(--icon-tools-bg)' : 'var(--icon-inactive-bg)',
color: isActive ? 'var(--icon-tools-color)' : 'var(--icon-inactive-color)',
@@ -61,7 +61,7 @@ const AllToolsNavButton: React.FC<AllToolsNavButtonProps> = ({ activeButton, set
{iconNode}
</ActionIcon>
<span className={`all-tools-text ${isActive ? 'active' : 'inactive'}`}>
{t("quickAccess.allTools", "Tools")}
{t("quickAccess.allTools", "All Tools")}
</span>
</div>
</Tooltip>
@@ -1,72 +0,0 @@
import React from 'react';
import { Paper, Group, Text, Button, ActionIcon } from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
interface InfoBannerProps {
icon: string;
message: string;
buttonText: string;
buttonIcon?: string;
onButtonClick: () => void;
onDismiss: () => void;
loading?: boolean;
show?: boolean;
}
/**
* Generic info banner component for displaying dismissible messages at the top of the app
*/
export const InfoBanner: React.FC<InfoBannerProps> = ({
icon,
message,
buttonText,
buttonIcon = 'check-circle-rounded',
onButtonClick,
onDismiss,
loading = false,
show = true,
}) => {
if (!show) {
return null;
}
return (
<Paper
p="sm"
radius={0}
style={{
background: 'var(--mantine-color-blue-0)',
borderBottom: '1px solid var(--mantine-color-blue-2)',
position: 'relative',
}}
>
<Group gap="sm" align="center" wrap="nowrap">
<LocalIcon icon={icon} width="1.2rem" height="1.2rem" style={{ color: 'var(--mantine-color-blue-6)', flexShrink: 0 }} />
<Text fw={500} size="sm" style={{ color: 'var(--mantine-color-blue-9)' }}>
{message}
</Text>
<Button
variant="light"
color="blue"
size="xs"
onClick={onButtonClick}
loading={loading}
leftSection={<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />}
style={{ flexShrink: 0 }}
>
{buttonText}
</Button>
</Group>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={onDismiss}
aria-label="Dismiss"
style={{ position: 'absolute', top: '50%', right: '0.5rem', transform: 'translateY(-50%)' }}
>
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
</ActionIcon>
</Paper>
);
};
@@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React from 'react';
import { Container, Button, Group, useMantineColorScheme } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import LocalIcon from '@app/components/shared/LocalIcon';
@@ -7,7 +7,6 @@ import { useFileHandler } from '@app/hooks/useFileHandler';
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';
const LandingPage = () => {
const { addFiles } = useFileHandler();
@@ -17,8 +16,6 @@ const LandingPage = () => {
const { openFilesModal } = useFilesModalContext();
const [isUploadHover, setIsUploadHover] = React.useState(false);
const logoPath = useLogoPath();
const { loadRecentFiles } = useFileManager();
const [hasRecents, setHasRecents] = React.useState<boolean>(false);
const handleFileDrop = async (files: File[]) => {
await addFiles(files);
@@ -41,22 +38,6 @@ const LandingPage = () => {
event.target.value = '';
};
// Determine if the user has any recent files (same source as File Manager)
useEffect(() => {
let isMounted = true;
(async () => {
try {
const files = await loadRecentFiles();
if (isMounted) {
setHasRecents((files?.length || 0) > 0);
}
} catch (_err) {
if (isMounted) setHasRecents(false);
}
})();
return () => { isMounted = false; };
}, [loadRecentFiles]);
return (
<Container size="70rem" p={0} h="100%" className="flex items-center justify-center" style={{ position: 'relative' }}>
{/* White PDF Page Background */}
@@ -138,89 +119,59 @@ const LandingPage = () => {
}}
onMouseLeave={() => setIsUploadHover(false)}
>
{/* Show both buttons only when recents exist; otherwise show a single Upload button */}
{hasRecents && (
<>
<Button
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '2rem',
height: '38px',
paddingLeft: isUploadHover ? 0 : '1rem',
paddingRight: isUploadHover ? 0 : '1rem',
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
minWidth: isUploadHover ? '58px' : undefined,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
}}
onClick={handleOpenFilesModal}
onMouseEnter={() => setIsUploadHover(false)}
>
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
{!isUploadHover && (
<span>
{t('landing.addFiles', 'Add Files')}
</span>
)}
</Button>
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
height: '38px',
width: isUploadHover ? 'calc(100% - 50px)' : '58px',
minWidth: '58px',
paddingLeft: isUploadHover ? '1rem' : 0,
paddingRight: isUploadHover ? '1rem' : 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
}}
onClick={handleNativeUploadClick}
onMouseEnter={() => setIsUploadHover(true)}
>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
{isUploadHover && (
<span style={{ marginLeft: '.5rem' }}>
{t('landing.uploadFromComputer', 'Upload from computer')}
</span>
)}
</Button>
</>
)}
{!hasRecents && (
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
height: '38px',
width: '100%',
minWidth: '58px',
paddingLeft: '1rem',
paddingRight: '1rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
onClick={handleNativeUploadClick}
>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
<Button
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '2rem',
height: '38px',
paddingLeft: isUploadHover ? 0 : '1rem',
paddingRight: isUploadHover ? 0 : '1rem',
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
minWidth: isUploadHover ? '58px' : undefined,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
}}
onClick={handleOpenFilesModal}
onMouseEnter={() => setIsUploadHover(false)}
>
<LocalIcon icon="add" width="1.5rem" height="1.5rem" className="text-[var(--accent-interactive)]" />
{!isUploadHover && (
<span>
{t('landing.addFiles', 'Add Files')}
</span>
)}
</Button>
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
height: '38px',
width: isUploadHover ? 'calc(100% - 50px)' : '58px',
minWidth: '58px',
paddingLeft: isUploadHover ? '1rem' : 0,
paddingRight: isUploadHover ? '1rem' : 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
}}
onClick={handleNativeUploadClick}
onMouseEnter={() => setIsUploadHover(true)}
>
<LocalIcon icon="upload" width="1.25rem" height="1.25rem" style={{ color: 'var(--accent-interactive)' }} />
{isUploadHover && (
<span style={{ marginLeft: '.5rem' }}>
{t('landing.uploadFromComputer', 'Upload from computer')}
</span>
</Button>
)}
)}
</Button>
</div>
{/* Hidden file input for native file picker */}
@@ -16,7 +16,6 @@ import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolBu
import AppConfigModal from '@app/components/shared/AppConfigModal';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import {
isNavButtonActive,
getNavButtonStyle,
@@ -89,7 +88,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
onClick: () => handleClick(),
'aria-label': config.name
})}
size={isActive ? 'lg' : 'md'}
size={isActive ? (config.size || 'lg') : 'lg'}
variant="subtle"
style={getNavButtonStyle(config, activeButton, isFilesModalOpen, configModalOpen, selectedToolKey, leftPanelView)}
className={isActive ? 'activeIconScale' : ''}
@@ -109,9 +108,9 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const mainButtons: ButtonConfig[] = [
{
id: 'read',
name: t("quickAccess.reader", "Reader"),
icon: <LocalIcon icon="menu-book-rounded" width="1.25rem" height="1.25rem" />,
size: 'md',
name: t("quickAccess.read", "Read"),
icon: <LocalIcon icon="menu-book-rounded" width="1.5rem" height="1.5rem" />,
size: 'lg',
isRound: false,
type: 'navigation',
onClick: () => {
@@ -119,11 +118,23 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
handleReaderToggle();
}
},
// {
// id: 'sign',
// name: t("quickAccess.sign", "Sign"),
// icon: <LocalIcon icon="signature-rounded" width="1.25rem" height="1.25rem" />,
// size: 'lg',
// isRound: false,
// type: 'navigation',
// onClick: () => {
// setActiveButton('sign');
// handleToolSelect('sign');
// }
// },
{
id: 'automate',
name: t("quickAccess.automate", "Automate"),
icon: <LocalIcon icon="automation-outline" width="1.25rem" height="1.25rem" />,
size: 'md',
icon: <LocalIcon icon="automation-outline" width="1.6rem" height="1.6rem" />,
size: 'lg',
isRound: false,
type: 'navigation',
onClick: () => {
@@ -136,36 +147,37 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
}
}
},
];
const middleButtons: ButtonConfig[] = [
{
id: 'files',
name: t("quickAccess.files", "Files"),
icon: <LocalIcon icon="folder-rounded" width="1.25rem" height="1.25rem" />,
icon: <LocalIcon icon="folder-rounded" width="1.6rem" height="1.6rem" />,
isRound: true,
size: 'md',
size: 'lg',
type: 'modal',
onClick: handleFilesButtonClick
},
//TODO: Activity
//{
// id: 'activity',
// name: t("quickAccess.activity", "Activity"),
// icon: <LocalIcon icon="vital-signs-rounded" width="1.25rem" height="1.25rem" />,
// isRound: true,
// size: 'lg',
// type: 'navigation',
// onClick: () => setActiveButton('activity')
//},
];
const middleButtons: ButtonConfig[] = [];
//TODO: Activity
//{
// id: 'activity',
// name: t("quickAccess.activity", "Activity"),
// icon: <LocalIcon icon="vital-signs-rounded" width="1.25rem" height="1.25rem" />,
// isRound: true,
// size: 'lg',
// type: 'navigation',
// onClick: () => setActiveButton('activity')
//},
const bottomButtons: ButtonConfig[] = [
{
id: 'help',
name: t("quickAccess.help", "Help"),
icon: <LocalIcon icon="help-rounded" width="1.25rem" height="1.25rem" />,
icon: <LocalIcon icon="help-rounded" width="1.5rem" height="1.5rem" />,
isRound: true,
size: 'md',
size: 'lg',
type: 'action',
onClick: () => {
// This will be overridden by the wrapper logic
@@ -173,9 +185,9 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
},
{
id: 'config',
name: t("quickAccess.settings", "Settings"),
icon: <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
size: 'md',
name: config?.enableLogin ? t("quickAccess.account", "Account") : t("quickAccess.config", "Config"),
icon: config?.enableLogin ? <LocalIcon icon="person-rounded" width="1.25rem" height="1.25rem" /> : <LocalIcon icon="settings-rounded" width="1.25rem" height="1.25rem" />,
size: 'lg',
type: 'modal',
onClick: () => {
navigate('/settings/overview');
@@ -188,7 +200,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
<div
ref={ref}
data-sidebar="quick-access"
className={`h-screen flex flex-col w-16 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
className={`h-screen flex flex-col w-20 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
style={{
borderRight: '1px solid var(--border-default)'
}}
@@ -227,30 +239,20 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
))}
</Stack>
{/* Divider after main buttons (creates gap) */}
{middleButtons.length === 0 && (
<Divider
size="xs"
className="content-divider"
/>
)}
{/* Divider after main buttons */}
<Divider
size="xs"
className="content-divider"
/>
{/* Middle section */}
{middleButtons.length > 0 && (
<>
<Divider
size="xs"
className="content-divider"
/>
<Stack gap="lg" align="center">
{middleButtons.map((config, index) => (
<React.Fragment key={config.id}>
{renderNavButton(config, index)}
</React.Fragment>
))}
</Stack>
</>
)}
<Stack gap="lg" align="center">
{middleButtons.map((config, index) => (
<React.Fragment key={config.id}>
{renderNavButton(config, index)}
</React.Fragment>
))}
</Stack>
{/* Spacer to push bottom buttons to bottom */}
<div className="spacer" />
@@ -14,8 +14,6 @@ import { ViewerContext } from '@app/contexts/ViewerContext';
import { useSignature } from '@app/contexts/SignatureContext';
import LocalIcon from '@app/components/shared/LocalIcon';
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
import DarkModeIcon from '@mui/icons-material/DarkMode';
import LightModeIcon from '@mui/icons-material/LightMode';
import { useSidebarContext } from '@app/contexts/SidebarContext';
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail';
@@ -41,7 +39,7 @@ export default function RightRail() {
const { sidebarRefs } = useSidebarContext();
const { t } = useTranslation();
const viewerContext = React.useContext(ViewerContext);
const { toggleTheme, themeMode } = useRainbowThemeContext();
const { toggleTheme } = useRainbowThemeContext();
const { buttons, actions, allButtonsDisabled } = useRightRail();
const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow();
@@ -197,11 +195,7 @@ export default function RightRail() {
className="right-rail-icon"
onClick={toggleTheme}
>
{themeMode === 'dark' ? (
<LightModeIcon sx={{ fontSize: '1.5rem' }} />
) : (
<DarkModeIcon sx={{ fontSize: '1.5rem' }} />
)}
<LocalIcon icon="contrast" width="1.5rem" height="1.5rem" />
</ActionIcon>,
t('rightRail.toggleTheme', 'Toggle Theme')
)}
@@ -2,10 +2,10 @@ import React, { useState, useCallback, useMemo } from "react";
import { SegmentedControl, Loader } from "@mantine/core";
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
import rainbowStyles from '@app/styles/rainbow.module.css';
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
import GridViewIcon from "@mui/icons-material/GridView";
import VisibilityIcon from "@mui/icons-material/Visibility";
import FolderIcon from "@mui/icons-material/Folder";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import { LocalIcon } from '@app/components/shared/LocalIcon';
import { WorkbenchType, isValidWorkbench } from '@app/types/workbench';
import { PageEditorFileDropdown } from '@app/components/shared/PageEditorFileDropdown';
import type { CustomWorkbenchViewInstance } from '@app/contexts/ToolWorkflowContext';
@@ -55,7 +55,7 @@ const createViewOptions = (
{switchingTo === "viewer" ? (
<Loader size="sm" />
) : (
<InsertDriveFileIcon fontSize="medium" />
<VisibilityIcon fontSize="medium" />
)}
</div>
),
@@ -84,7 +84,7 @@ const createViewOptions = (
{switchingTo === "pageEditor" ? (
<Loader size="sm" />
) : (
<GridViewIcon fontSize="medium" />
<LocalIcon icon="dashboard-customize-rounded" width="1.5rem" height="1.5rem" />
)}
</div>
),
@@ -114,23 +114,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
]}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.rememberInputs', 'Remember tool inputs')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t(
'settings.general.rememberInputsDescription',
'Store the last settings you used for each tool on this device'
)}
</Text>
</div>
<Switch
checked={preferences.rememberFormInputs}
onChange={(event) => updatePreference('rememberFormInputs', event.currentTarget.checked)}
/>
</div>
<Tooltip
label={t('settings.general.autoUnzipTooltip', 'Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.')}
multiline
@@ -1,13 +1,13 @@
/**
* ActiveToolButton - Shows the currently selected tool at the top of the Quick Access Bar
*
* When a user selects a tool from the Tools list, this component displays the tool's
* When a user selects a tool from the All Tools list, this component displays the tool's
* icon and name at the top of the navigation bar. It provides a quick way to see which
* tool is currently active and offers a back button to return to the Tools list.
* tool is currently active and offers a back button to return to the All Tools list.
*
* Features:
* - Shows tool icon and name when a tool is selected
* - Hover to reveal back arrow for returning to Tools
* - Hover to reveal back arrow for returning to All Tools
* - Smooth slide-down/slide-up animations
* - Only appears for tools that don't have dedicated nav buttons (read, sign, automate)
*/
@@ -149,7 +149,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
handleBackToTools();
});
}}
size={'lg'}
size={'xl'}
variant="subtle"
onMouseEnter={() => setIsBackHover(true)}
onMouseLeave={() => setIsBackHover(false)}
@@ -165,7 +165,7 @@ const ActiveToolButton: React.FC<ActiveToolButtonProps> = ({ setActiveButton })
>
<span className="iconContainer">
{isBackHover ? (
<ArrowBackRoundedIcon sx={{ fontSize: '1.875rem' }} />
<ArrowBackRoundedIcon sx={{ fontSize: '1.5rem' }} />
) : (
indicatorTool.icon
)}
@@ -1,29 +1,15 @@
.activeIconScale {
transform: scale(1);
transform: scale(1.3);
transition: transform 0.2s;
z-index: 1;
width: calc(1.75rem + 10px) !important;
height: calc(1.75rem + 10px) !important;
}
.activeIconScale .iconContainer {
width: calc(1.5rem + 10px);
height: calc(1.5rem + 10px);
}
.activeIconScale .iconContainer svg,
.activeIconScale .iconContainer img {
width: calc(1.25rem + 10px) !important;
height: calc(1.25rem + 10px) !important;
}
.iconContainer {
display: flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
transition: width 0.2s, height 0.2s;
width: 2rem;
height: 2rem;
}
/* Action icon styles */
@@ -38,9 +24,9 @@
/* Main container styles */
.quick-access-bar-main {
background-color: var(--bg-muted);
width: 4rem;
min-width: 4rem;
max-width: 4rem;
width: 5rem;
min-width: 5rem;
max-width: 5rem;
position: relative;
z-index: 10;
}
@@ -48,9 +34,9 @@
/* Rainbow mode container */
.quick-access-bar-main.rainbow-mode {
background-color: var(--bg-muted);
width: 4rem;
min-width: 4rem;
max-width: 4rem;
width: 5rem;
min-width: 5rem;
max-width: 5rem;
position: relative;
z-index: 10;
}
@@ -71,7 +57,7 @@
/* Nav header divider */
.nav-header-divider {
width: 3rem;
width: 3.75rem;
border-color: var(--color-gray-300);
margin-top: 0.5rem;
margin-bottom: 1rem;
@@ -99,7 +85,7 @@
/* Overflow divider */
.overflow-divider {
width: 3rem;
width: 3.75rem;
border-color: var(--color-gray-300);
margin: 0 0.5rem;
}
@@ -157,7 +143,7 @@
/* Content divider */
.content-divider {
width: 3rem;
width: 3.75rem;
border-color: var(--color-gray-300);
margin: 1rem 0;
}
@@ -255,7 +241,7 @@
/* Divider that animates growing from top */
.current-tool-divider {
width: 3rem;
width: 3.75rem;
border-color: var(--color-gray-300);
margin: 0.5rem auto 0.5rem auto;
transform-origin: top;
+162 -59
View File
@@ -1,4 +1,4 @@
import React, { useMemo, useRef } from "react";
import React, { useMemo, useRef, useLayoutEffect, useState } from "react";
import { Box, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
@@ -8,10 +8,11 @@ import type { SubcategoryGroup } from "@app/hooks/useToolSections";
import { useFavoriteToolItems } from "@app/hooks/tools/useFavoriteToolItems";
import NoToolsFound from "@app/components/tools/shared/NoToolsFound";
import { renderToolButtons } from "@app/components/tools/shared/renderToolButtons";
import Badge from "@app/components/shared/Badge";
import SubcategoryHeader from "@app/components/tools/shared/SubcategoryHeader";
import ToolButton from "@app/components/tools/toolPicker/ToolButton";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { ToolId } from "@app/types/toolId";
import { getSubcategoryLabel } from "@app/data/toolsTaxonomy";
interface ToolPickerProps {
selectedToolKey: string | null;
@@ -22,8 +23,49 @@ interface ToolPickerProps {
const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = false }: ToolPickerProps) => {
const { t } = useTranslation();
const [quickHeaderHeight, setQuickHeaderHeight] = useState(0);
const [allHeaderHeight, setAllHeaderHeight] = useState(0);
const scrollableRef = useRef<HTMLDivElement>(null);
const quickHeaderRef = useRef<HTMLDivElement>(null);
const allHeaderRef = useRef<HTMLDivElement>(null);
const quickAccessRef = useRef<HTMLDivElement>(null);
const allToolsRef = useRef<HTMLDivElement>(null);
// Keep header heights in sync with any dynamic size changes
useLayoutEffect(() => {
const update = () => {
if (quickHeaderRef.current) {
setQuickHeaderHeight(quickHeaderRef.current.offsetHeight || 0);
}
if (allHeaderRef.current) {
setAllHeaderHeight(allHeaderRef.current.offsetHeight || 0);
}
};
update();
// Update on window resize
window.addEventListener("resize", update);
// Update on element resize (e.g., font load, badge count change, zoom)
const observers: ResizeObserver[] = [];
if (typeof ResizeObserver !== "undefined") {
const observe = (el: HTMLDivElement | null, cb: () => void) => {
if (!el) return;
const ro = new ResizeObserver(() => cb());
ro.observe(el);
observers.push(ro);
};
observe(quickHeaderRef.current, update);
observe(allHeaderRef.current, update);
}
return () => {
window.removeEventListener("resize", update);
observers.forEach(o => o.disconnect());
};
}, []);
const { sections: visibleSections } = useToolSections(filteredTools);
const { favoriteTools, toolRegistry } = useToolWorkflow();
@@ -42,25 +84,31 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
return items;
}, [quickSection]);
const recommendedCount = useMemo(() => favoriteToolItems.length + recommendedItems.length, [favoriteToolItems.length, recommendedItems.length]);
const allSection = useMemo(
() => visibleSections.find(s => s.key === 'all'),
[visibleSections]
);
const scrollTo = (ref: React.RefObject<HTMLDivElement | null>) => {
const container = scrollableRef.current;
const target = ref.current;
if (container && target) {
const stackedOffset = ref === allToolsRef
? (quickHeaderHeight + allHeaderHeight)
: quickHeaderHeight;
const top = target.offsetTop - container.offsetTop - (stackedOffset || 0);
container.scrollTo({
top: Math.max(0, top),
behavior: "smooth"
});
}
};
// Build flat list by subcategory for search mode
const emptyFilteredTools: ToolPickerProps['filteredTools'] = [];
const effectiveFilteredForSearch: ToolPickerProps['filteredTools'] = isSearching ? filteredTools : emptyFilteredTools;
const { searchGroups } = useToolSections(effectiveFilteredForSearch);
const headerTextStyle: React.CSSProperties = {
fontSize: "0.75rem",
fontWeight: 500,
padding: "0.5rem 0 0.25rem 0.5rem",
textTransform: "none",
color: "var(--text-secondary, rgba(0, 0, 0, 0.6))",
opacity: 0.7
};
const toTitleCase = (s: string) =>
s.replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase());
return (
<Box
@@ -93,55 +141,110 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
</Stack>
) : (
<>
{/* Flat list: favorites and recommended first, then all subcategories */}
<Stack p="sm" gap="xs">
{favoriteToolItems.length > 0 && (
<Box w="100%">
<div style={headerTextStyle}>
{t('toolPanel.fullscreen.favorites', 'Favourites')}
</div>
<div>
{favoriteToolItems.map(({ id, tool }) => (
<ToolButton
key={`fav-${id}`}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
hasStars
/>
))}
</div>
{quickSection && (
<>
<div
ref={quickHeaderRef}
style={{
position: "sticky",
top: 0,
zIndex: 2,
borderTop: `0.0625rem solid var(--tool-header-border)`,
borderBottom: `0.0625rem solid var(--tool-header-border)`,
padding: "0.5rem 1rem",
fontWeight: 600,
background: "var(--tool-header-bg)",
color: "var(--tool-header-text)",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
onClick={() => scrollTo(quickAccessRef)}
>
<span style={{ fontSize: "1rem" }}>{t("toolPicker.quickAccess", "QUICK ACCESS")}</span>
<Badge>
{recommendedCount}
</Badge>
</div>
<Box ref={quickAccessRef} w="100%" my="sm">
<Stack p="sm" gap="xs">
{favoriteToolItems.length > 0 && (
<Box w="100%">
<SubcategoryHeader label={t('toolPanel.fullscreen.favorites', 'Favourites')} mt={0} />
<div>
{favoriteToolItems.map(({ id, tool }) => (
<ToolButton
key={`fav-${id}`}
id={id}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
hasStars
/>
))}
</div>
</Box>
)}
{recommendedItems.length > 0 && (
<Box w="100%">
<SubcategoryHeader label={t('toolPanel.fullscreen.recommended', 'Recommended')} />
<div>
{recommendedItems.map(({ id, tool }) => (
<ToolButton
key={`rec-${id}`}
id={id as ToolId}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
hasStars
/>
))}
</div>
</Box>
)}
</Stack>
</Box>
)}
{recommendedItems.length > 0 && (
<Box w="100%">
<div style={headerTextStyle}>
{t('toolPanel.fullscreen.recommended', 'Recommended')}
</div>
<div>
{recommendedItems.map(({ id, tool }) => (
<ToolButton
key={`rec-${id}`}
id={id as ToolId}
tool={tool}
isSelected={selectedToolKey === id}
onSelect={onSelect}
hasStars
/>
))}
</div>
</>
)}
{allSection && (
<>
<div
ref={allHeaderRef}
style={{
position: "sticky",
top: quickSection ? quickHeaderHeight -1 : 0,
zIndex: 2,
borderTop: `0.0625rem solid var(--tool-header-border)`,
borderBottom: `0.0625rem solid var(--tool-header-border)`,
padding: "0.5rem 1rem",
fontWeight: 600,
background: "var(--tool-header-bg)",
color: "var(--tool-header-text)",
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "space-between"
}}
onClick={() => scrollTo(allToolsRef)}
>
<span style={{ fontSize: "1rem" }}>{t("toolPicker.allTools", "ALL TOOLS")}</span>
<Badge>
{allSection?.subcategories.reduce((acc, sc) => acc + sc.tools.length, 0)}
</Badge>
</div>
<Box ref={allToolsRef} w="100%">
<Stack p="sm" gap="xs">
{allSection?.subcategories.map((sc: SubcategoryGroup) =>
renderToolButtons(t, sc, selectedToolKey, onSelect, true, false, undefined, true)
)}
</Stack>
</Box>
)}
{allSection && allSection.subcategories.map((sc: SubcategoryGroup) => (
<Box key={sc.subcategoryId} w="100%">
<div style={headerTextStyle}>
{toTitleCase(getSubcategoryLabel(t, sc.subcategoryId))}
</div>
{renderToolButtons(t, sc, selectedToolKey, onSelect, false, false, undefined, true)}
</Box>
))}
</Stack>
</>
)}
{!quickSection && !allSection && <NoToolsFound />}
@@ -80,7 +80,7 @@ export default function ToolSelector({
// If no sections, create a simple group from filtered tools
if (baseFilteredTools.length > 0) {
return [{
name: 'Tools',
name: 'All Tools',
subcategoryId: 'all' as any,
tools: baseFilteredTools.map(([key, tool]) => ({ id: key, tool }))
}];
@@ -1,26 +0,0 @@
import { createContext, useContext, useState, ReactNode } from 'react';
interface BannerContextType {
banner: ReactNode;
setBanner: (banner: ReactNode) => void;
}
const BannerContext = createContext<BannerContextType | undefined>(undefined);
export function BannerProvider({ children }: { children: ReactNode }) {
const [banner, setBanner] = useState<ReactNode>(null);
return (
<BannerContext.Provider value={{ banner, setBanner }}>
{children}
</BannerContext.Provider>
);
}
export function useBanner() {
const context = useContext(BannerContext);
if (!context) {
throw new Error('useBanner must be used within BannerProvider');
}
return context;
}
@@ -68,7 +68,7 @@ export const TourOrchestrationProvider: React.FC<{ children: React.ReactNode }>
const restoreWorkbenchState = useCallback(async () => {
console.log('Restoring workbench state, saved files count:', savedFilesRef.current.length);
// Go back to Tools
// Go back to All Tools
handleBackToTools();
// Clear all files (including tour sample)
@@ -2,13 +2,10 @@ import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useAddPasswordParameters, defaultParameters, AddPasswordParametersHook } from '@app/hooks/tools/addPassword/useAddPasswordParameters';
import { defaultParameters as defaultChangePermissionsParameters, ChangePermissionsParameters } from '@app/hooks/tools/changePermissions/useChangePermissionsParameters';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderAddPasswordHook = () => renderHook(() => useAddPasswordParameters(), { wrapper: PreferencesTestWrapper });
describe('useAddPasswordParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderAddPasswordHook();
const { result } = renderHook(() => useAddPasswordParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
@@ -18,7 +15,7 @@ describe('useAddPasswordParameters', () => {
{ paramName: 'ownerPassword' as const, value: 'owner-password' },
{ paramName: 'keyLength' as const, value: 256 }
])('should update parameter $paramName', ({ paramName, value }) => {
const { result } = renderAddPasswordHook();
const { result } = renderHook(() => useAddPasswordParameters());
act(() => {
result.current.updateParameter(paramName, value);
@@ -31,7 +28,7 @@ describe('useAddPasswordParameters', () => {
{ paramName: 'preventAssembly' as const },
{ paramName: 'preventPrinting' as const }
])('should update boolean permission parameter $paramName', ({ paramName }) => {
const { result } = renderAddPasswordHook();
const { result } = renderHook(() => useAddPasswordParameters());
act(() => {
result.current.permissions.updateParameter(paramName, true);
@@ -41,7 +38,7 @@ describe('useAddPasswordParameters', () => {
});
test('should reset parameters to defaults', () => {
const { result } = renderAddPasswordHook();
const { result } = renderHook(() => useAddPasswordParameters());
// First, change some parameters
act(() => {
@@ -63,7 +60,7 @@ describe('useAddPasswordParameters', () => {
});
test('should return correct endpoint name', () => {
const { result } = renderAddPasswordHook();
const { result } = renderHook(() => useAddPasswordParameters());
expect(result.current.getEndpointName()).toBe('add-password');
});
@@ -115,7 +112,7 @@ describe('useAddPasswordParameters', () => {
}
}
])('should validate parameters correctly $description', ({ setup }) => {
const { result } = renderAddPasswordHook();
const { result } = renderHook(() => useAddPasswordParameters());
// Default state should be valid
expect(result.current.validateParameters()).toBe(true);
@@ -129,7 +126,7 @@ describe('useAddPasswordParameters', () => {
});
test.each(Object.keys(defaultChangePermissionsParameters) as Array<keyof ChangePermissionsParameters>)('should handle boolean restriction parameter %s', (param) => {
const { result } = renderAddPasswordHook();
const { result } = renderHook(() => useAddPasswordParameters());
act(() => {
result.current.resetParameters();
@@ -140,7 +137,7 @@ describe('useAddPasswordParameters', () => {
});
test('should handle mixed parameter types in updateParameter', () => {
const { result } = renderAddPasswordHook();
const { result } = renderHook(() => useAddPasswordParameters());
act(() => {
result.current.updateParameter('password', 'test-string');
@@ -1,13 +1,10 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useAdjustPageScaleParameters, defaultParameters, PageSize, AdjustPageScaleParametersHook } from '@app/hooks/tools/adjustPageScale/useAdjustPageScaleParameters';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderAdjustPageScaleHook = () => renderHook(() => useAdjustPageScaleParameters(), { wrapper: PreferencesTestWrapper });
describe('useAdjustPageScaleParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderAdjustPageScaleHook();
const { result } = renderHook(() => useAdjustPageScaleParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
expect(result.current.parameters.scaleFactor).toBe(1.0);
@@ -22,7 +19,7 @@ describe('useAdjustPageScaleParameters', () => {
{ paramName: 'pageSize' as const, value: PageSize.LETTER },
{ paramName: 'pageSize' as const, value: PageSize.LEGAL },
])('should update parameter $paramName to $value', ({ paramName, value }) => {
const { result } = renderAdjustPageScaleHook();
const { result } = renderHook(() => useAdjustPageScaleParameters());
act(() => {
result.current.updateParameter(paramName, value);
@@ -32,7 +29,7 @@ describe('useAdjustPageScaleParameters', () => {
});
test('should reset parameters to defaults', () => {
const { result } = renderAdjustPageScaleHook();
const { result } = renderHook(() => useAdjustPageScaleParameters());
// First, change some parameters
act(() => {
@@ -52,7 +49,7 @@ describe('useAdjustPageScaleParameters', () => {
});
test('should return correct endpoint name', () => {
const { result } = renderAdjustPageScaleHook();
const { result } = renderHook(() => useAdjustPageScaleParameters());
expect(result.current.getEndpointName()).toBe('scale-pages');
});
@@ -99,7 +96,7 @@ describe('useAdjustPageScaleParameters', () => {
expected: false
}
])('should validate parameters correctly $description', ({ setup, expected }) => {
const { result } = renderAdjustPageScaleHook();
const { result } = renderHook(() => useAdjustPageScaleParameters());
act(() => {
setup(result.current);
@@ -109,7 +106,7 @@ describe('useAdjustPageScaleParameters', () => {
});
test('should handle all PageSize enum values', () => {
const { result } = renderAdjustPageScaleHook();
const { result } = renderHook(() => useAdjustPageScaleParameters());
Object.values(PageSize).forEach(pageSize => {
act(() => {
@@ -122,7 +119,7 @@ describe('useAdjustPageScaleParameters', () => {
});
test('should handle scale factor edge cases', () => {
const { result } = renderAdjustPageScaleHook();
const { result } = renderHook(() => useAdjustPageScaleParameters());
// Test very small valid scale factor
act(() => {
@@ -2,13 +2,10 @@ import { renderHook, act } from '@testing-library/react';
import { defaultParameters, useChangeMetadataParameters } from '@app/hooks/tools/changeMetadata/useChangeMetadataParameters';
import { TrappedStatus } from '@app/types/metadata';
import { describe, expect, test } from 'vitest';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderChangeMetadataHook = () => renderHook(() => useChangeMetadataParameters(), { wrapper: PreferencesTestWrapper });
describe('useChangeMetadataParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderChangeMetadataHook();
const { result } = renderHook(() => useChangeMetadataParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
@@ -26,7 +23,7 @@ describe('useChangeMetadataParameters', () => {
{ paramName: 'trapped', value: TrappedStatus.TRUE },
{ paramName: 'deleteAll', value: true },
] as const)('should update $paramName parameter', ({ paramName, value }) => {
const { result } = renderChangeMetadataHook();
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
result.current.updateParameter(paramName, value);
@@ -51,7 +48,7 @@ describe('useChangeMetadataParameters', () => {
{ description: 'no meaningful content', updates: {}, expected: false },
{ description: 'whitespace only', updates: { title: ' ', author: ' ' }, expected: false },
])('should validate correctly when $description', ({ updates, expected }) => {
const { result } = renderChangeMetadataHook();
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
Object.entries(updates).forEach(([key, value]) => {
@@ -67,7 +64,7 @@ describe('useChangeMetadataParameters', () => {
{ description: 'valid modification date', updates: { title: 'Test', modificationDate: new Date('2025/01/17 14:30:00') }, expected: true },
{ description: 'empty dates are valid', updates: { title: 'Test', creationDate: null, modificationDate: null }, expected: true },
])('should validate dates correctly with $description', ({ updates, expected }) => {
const { result } = renderChangeMetadataHook();
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
Object.entries(updates).forEach(([key, value]) => {
@@ -81,7 +78,7 @@ describe('useChangeMetadataParameters', () => {
describe('custom metadata', () => {
test('should add custom metadata with sequential IDs', () => {
const { result } = renderChangeMetadataHook();
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
result.current.addCustomMetadata();
@@ -96,7 +93,7 @@ describe('useChangeMetadataParameters', () => {
});
test('should remove custom metadata by ID', () => {
const { result } = renderChangeMetadataHook();
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
result.current.addCustomMetadata();
@@ -112,7 +109,7 @@ describe('useChangeMetadataParameters', () => {
});
test('should update custom metadata by ID', () => {
const { result } = renderChangeMetadataHook();
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
result.current.addCustomMetadata();
@@ -132,7 +129,7 @@ describe('useChangeMetadataParameters', () => {
});
test('should validate with custom metadata', () => {
const { result } = renderChangeMetadataHook();
const { result } = renderHook(() => useChangeMetadataParameters());
act(() => {
result.current.addCustomMetadata();
@@ -148,7 +145,7 @@ describe('useChangeMetadataParameters', () => {
});
test('should generate unique IDs for multiple custom entries', () => {
const { result } = renderChangeMetadataHook();
const { result } = renderHook(() => useChangeMetadataParameters());
for (let i = 0; i < 3; i++) {
act(() => {
@@ -164,7 +161,7 @@ describe('useChangeMetadataParameters', () => {
});
test('should return correct endpoint name', () => {
const { result } = renderChangeMetadataHook();
const { result } = renderHook(() => useChangeMetadataParameters());
expect(result.current.getEndpointName()).toBe('update-metadata');
});
@@ -1,19 +1,16 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useChangePermissionsParameters, defaultParameters, ChangePermissionsParameters } from '@app/hooks/tools/changePermissions/useChangePermissionsParameters';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderChangePermissionsHook = () => renderHook(() => useChangePermissionsParameters(), { wrapper: PreferencesTestWrapper });
describe('useChangePermissionsParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderChangePermissionsHook();
const { result } = renderHook(() => useChangePermissionsParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should update individual boolean parameters', () => {
const { result } = renderChangePermissionsHook();
const { result } = renderHook(() => useChangePermissionsParameters());
act(() => {
result.current.updateParameter('preventAssembly', true);
@@ -31,7 +28,7 @@ describe('useChangePermissionsParameters', () => {
});
test('should update all permission parameters', () => {
const { result } = renderChangePermissionsHook();
const { result } = renderHook(() => useChangePermissionsParameters());
const permissionKeys = Object.keys(defaultParameters) as Array<keyof ChangePermissionsParameters>;
@@ -59,7 +56,7 @@ describe('useChangePermissionsParameters', () => {
});
test('should reset parameters to defaults', () => {
const { result } = renderChangePermissionsHook();
const { result } = renderHook(() => useChangePermissionsParameters());
// First, change some parameters
act(() => {
@@ -81,13 +78,13 @@ describe('useChangePermissionsParameters', () => {
});
test('should return correct endpoint name', () => {
const { result } = renderChangePermissionsHook();
const { result } = renderHook(() => useChangePermissionsParameters());
expect(result.current.getEndpointName()).toBe('add-password');
});
test('should always validate as true', () => {
const { result } = renderChangePermissionsHook();
const { result } = renderHook(() => useChangePermissionsParameters());
// Default state should be valid
expect(result.current.validateParameters()).toBe(true);
@@ -6,16 +6,13 @@ import { describe, test, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useConvertParameters } from '@app/hooks/tools/convert/useConvertParameters';
import { FIT_OPTIONS } from '@app/constants/convertConstants';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderConvertHook = () => renderHook(() => useConvertParameters(), { wrapper: PreferencesTestWrapper });
describe('useConvertParameters', () => {
describe('Parameter Management', () => {
test('should initialize with default parameters', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
expect(result.current.parameters.fromExtension).toBe('');
expect(result.current.parameters.toExtension).toBe('');
@@ -31,7 +28,7 @@ describe('useConvertParameters', () => {
});
test('should update individual parameters', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('fromExtension', 'pdf');
@@ -42,7 +39,7 @@ describe('useConvertParameters', () => {
});
test('should update nested image options', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('imageOptions', {
@@ -64,7 +61,7 @@ describe('useConvertParameters', () => {
});
test('should update nested HTML options', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('htmlOptions', {
@@ -76,7 +73,7 @@ describe('useConvertParameters', () => {
});
test('should update nested email options', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('emailOptions', {
@@ -94,7 +91,7 @@ describe('useConvertParameters', () => {
});
test('should update nested PDF/A options', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('pdfaOptions', {
@@ -106,7 +103,7 @@ describe('useConvertParameters', () => {
});
test('should reset parameters to defaults', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('fromExtension', 'pdf');
@@ -127,7 +124,7 @@ describe('useConvertParameters', () => {
describe('Parameter Validation', () => {
test('should validate parameters correctly', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
// No parameters - should be invalid
expect(result.current.validateParameters()).toBe(false);
@@ -146,7 +143,7 @@ describe('useConvertParameters', () => {
});
test('should validate unsupported conversions', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('fromExtension', 'pdf');
@@ -161,7 +158,7 @@ describe('useConvertParameters', () => {
describe('Endpoint Generation', () => {
test('should generate correct endpoint names', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('fromExtension', 'pdf');
@@ -173,7 +170,7 @@ describe('useConvertParameters', () => {
});
test('should generate correct endpoint URLs', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('fromExtension', 'pdf');
@@ -185,7 +182,7 @@ describe('useConvertParameters', () => {
});
test('should return empty strings for invalid conversions', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
act(() => {
result.current.updateParameter('fromExtension', 'invalid');
@@ -200,7 +197,7 @@ describe('useConvertParameters', () => {
describe('Available Extensions', () => {
test('should return available extensions for valid source format', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
const availableExtensions = result.current.getAvailableToExtensions('pdf');
@@ -210,7 +207,7 @@ describe('useConvertParameters', () => {
});
test('should return empty array for invalid source format', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
const availableExtensions = result.current.getAvailableToExtensions('invalid');
@@ -222,7 +219,7 @@ describe('useConvertParameters', () => {
});
test('should return empty array for empty source format', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
const availableExtensions = result.current.getAvailableToExtensions('');
@@ -137,7 +137,6 @@ export const useConvertParameters = (): ConvertParametersHook => {
defaultParameters,
endpointName: getEndpointName,
validateFn: validateParameters,
storageKey: 'convert',
}), []);
const baseHook = useBaseParameters(config);
@@ -6,16 +6,13 @@
import { describe, test, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useConvertParameters } from '@app/hooks/tools/convert/useConvertParameters';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderConvertHook = () => renderHook(() => useConvertParameters(), { wrapper: PreferencesTestWrapper });
describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
describe('Single File Detection', () => {
test('should detect single file extension and set auto-target', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
const pdfFile = [{ name: 'document.pdf' }];
@@ -30,7 +27,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should handle unknown file types with file-to-pdf fallback', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
const unknownFile = [{ name: 'document.xyz' }, { name: 'image.jpggg' }];
@@ -44,7 +41,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should handle files without extensions', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
const noExtFile = [{ name: 'document' }];
@@ -62,7 +59,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
describe('Multiple Identical Files', () => {
test('should detect multiple PDF files and set auto-target', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
const pdfFiles = [
{ name: 'doc1.pdf' },
@@ -81,7 +78,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should handle multiple unknown file types with fallback', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
const unknownFiles = [
{ name: 'file1.xyz' },
@@ -101,7 +98,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
describe('Smart Detection - All Images', () => {
test('should detect all image files and enable smart detection', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
const imageFiles = [
{ name: 'photo1.jpg' },
@@ -120,7 +117,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should handle mixed case image extensions', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
const imageFiles = [
{ name: 'photo1.JPG' },
@@ -139,7 +136,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
describe('Smart Detection - All Web Files', () => {
test('should detect all web files and enable web smart detection', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
const webFiles = [
{ name: 'page1.html' },
@@ -157,7 +154,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should handle mixed case web extensions', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
const webFiles = [
{ name: 'page1.HTML' },
@@ -173,7 +170,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should detect multiple web files and enable web smart detection', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
const zipFiles = [
{ name: 'site1.zip' },
@@ -194,7 +191,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
describe('Smart Detection - Mixed File Types', () => {
test('should detect mixed file types and enable smart detection', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
const mixedFiles = [
{ name: 'document.pdf' },
@@ -213,7 +210,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should detect mixed images and documents as mixed type', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
const mixedFiles = [
{ name: 'photo.jpg' },
@@ -230,7 +227,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should handle mixed with unknown file types', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
const mixedFiles = [
{ name: 'document.pdf' },
@@ -250,7 +247,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
describe('Smart Detection Endpoint Resolution', () => {
test('should return correct endpoint for image smart detection', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
const imageFiles = [
{ name: 'photo1.jpg' },
@@ -266,7 +263,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should return correct endpoint for web smart detection', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
const webFiles = [
{ name: 'page1.html' },
@@ -282,7 +279,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should return correct endpoint for mixed smart detection', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
const mixedFiles = [
{ name: 'document.pdf' },
@@ -301,7 +298,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
describe('Auto-Target Selection Logic', () => {
test('should select single available target automatically', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
// Markdown has only one conversion target (PDF)
const mdFile = [{ name: 'readme.md' }];
@@ -315,7 +312,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should not auto-select when multiple targets available', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
// PDF has multiple conversion targets, so no auto-selection
const pdfFile = [{ name: 'document.pdf' }];
@@ -333,7 +330,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
describe('Edge Cases', () => {
test('should handle empty file names', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
const emptyFiles = [{ name: '' }];
@@ -346,7 +343,7 @@ describe('useConvertParameters - Auto Detection & Smart Conversion', () => {
});
test('should handle malformed file objects', () => {
const { result } = renderConvertHook();
const { result } = renderHook(() => useConvertParameters());
const malformedFiles: Array<{name: string}> = [
{ name: 'valid.pdf' },
@@ -1,13 +1,10 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useMergeParameters, defaultParameters } from '@app/hooks/tools/merge/useMergeParameters';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderMergeHook = () => renderHook(() => useMergeParameters(), { wrapper: PreferencesTestWrapper });
describe('useMergeParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderMergeHook();
const { result } = renderHook(() => useMergeParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
@@ -18,7 +15,7 @@ describe('useMergeParameters', () => {
{ paramName: 'generateTableOfContents' as const, value: true },
{ paramName: 'generateTableOfContents' as const, value: false }
])('should update parameter $paramName to $value', ({ paramName, value }) => {
const { result } = renderMergeHook();
const { result } = renderHook(() => useMergeParameters());
act(() => {
result.current.updateParameter(paramName, value);
@@ -28,7 +25,7 @@ describe('useMergeParameters', () => {
});
test('should reset parameters to defaults', () => {
const { result } = renderMergeHook();
const { result } = renderHook(() => useMergeParameters());
// First, change some parameters
act(() => {
@@ -48,7 +45,7 @@ describe('useMergeParameters', () => {
});
test('should validate parameters correctly - always returns true', () => {
const { result } = renderMergeHook();
const { result } = renderHook(() => useMergeParameters());
// Default state should be valid
expect(result.current.validateParameters()).toBe(true);
@@ -1,13 +1,10 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useRedactParameters, defaultParameters } from '@app/hooks/tools/redact/useRedactParameters';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderRedactHook = () => renderHook(() => useRedactParameters(), { wrapper: PreferencesTestWrapper });
describe('useRedactParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderRedactHook();
const { result } = renderHook(() => useRedactParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
@@ -21,7 +18,7 @@ describe('useRedactParameters', () => {
{ paramName: 'customPadding' as const, value: 0.5 },
{ paramName: 'convertPDFToImage' as const, value: false }
])('should update parameter $paramName', ({ paramName, value }) => {
const { result } = renderRedactHook();
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter(paramName, value);
@@ -31,7 +28,7 @@ describe('useRedactParameters', () => {
});
test('should reset parameters to defaults', () => {
const { result } = renderRedactHook();
const { result } = renderHook(() => useRedactParameters());
// Modify some parameters
act(() => {
@@ -55,7 +52,7 @@ describe('useRedactParameters', () => {
{ description: 'not validate when wordsToRedact contains only empty strings in automatic mode', wordsToRedact: ['', ' ', ''], expected: false },
{ description: 'validate when wordsToRedact contains at least one non-empty word in automatic mode', wordsToRedact: ['', 'valid', ' '], expected: true },
])('should $description', ({ wordsToRedact, expected }) => {
const { result } = renderRedactHook();
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('mode', 'automatic');
@@ -66,7 +63,7 @@ describe('useRedactParameters', () => {
});
test('should not validate in manual mode (not implemented)', () => {
const { result } = renderRedactHook();
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('mode', 'manual');
@@ -78,7 +75,7 @@ describe('useRedactParameters', () => {
describe('endpoint handling', () => {
test('should return correct endpoint for automatic mode', () => {
const { result } = renderRedactHook();
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('mode', 'automatic');
@@ -88,7 +85,7 @@ describe('useRedactParameters', () => {
});
test('should throw error for manual mode (not implemented)', () => {
const { result } = renderRedactHook();
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('mode', 'manual');
@@ -99,7 +96,7 @@ describe('useRedactParameters', () => {
});
test('should maintain parameter state across updates', () => {
const { result } = renderRedactHook();
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('redactColor', '#FF0000');
@@ -120,7 +117,7 @@ describe('useRedactParameters', () => {
});
test('should handle array parameter updates correctly', () => {
const { result } = renderRedactHook();
const { result } = renderHook(() => useRedactParameters());
act(() => {
result.current.updateParameter('wordsToRedact', ['initial']);
@@ -43,7 +43,6 @@ export const useRedactParameters = (): RedactParametersHook => {
}
// Manual mode validation would go here when implemented
return false;
},
storageKey: 'redact'
}
});
};
@@ -1,19 +1,16 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useRemovePasswordParameters, defaultParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderRemovePasswordHook = () => renderHook(() => useRemovePasswordParameters(), { wrapper: PreferencesTestWrapper });
describe('useRemovePasswordParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderRemovePasswordHook();
const { result } = renderHook(() => useRemovePasswordParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should update password parameter', () => {
const { result } = renderRemovePasswordHook();
const { result } = renderHook(() => useRemovePasswordParameters());
act(() => {
result.current.updateParameter('password', 'test-password');
@@ -23,7 +20,7 @@ describe('useRemovePasswordParameters', () => {
});
test('should reset parameters to defaults', () => {
const { result } = renderRemovePasswordHook();
const { result } = renderHook(() => useRemovePasswordParameters());
// First, change the password
act(() => {
@@ -41,7 +38,7 @@ describe('useRemovePasswordParameters', () => {
});
test('should return correct endpoint name', () => {
const { result } = renderRemovePasswordHook();
const { result } = renderHook(() => useRemovePasswordParameters());
expect(result.current.getEndpointName()).toBe('remove-password');
});
@@ -73,7 +70,7 @@ describe('useRemovePasswordParameters', () => {
expectedValid: true
}
])('should validate parameters correctly $description', ({ password, expectedValid }) => {
const { result } = renderRemovePasswordHook();
const { result } = renderHook(() => useRemovePasswordParameters());
act(() => {
result.current.updateParameter('password', password);
@@ -1,13 +1,10 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useRotateParameters, defaultParameters, normalizeAngle } from '@app/hooks/tools/rotate/useRotateParameters';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderRotateHook = () => renderHook(() => useRotateParameters(), { wrapper: PreferencesTestWrapper });
describe('useRotateParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderRotateHook();
const { result } = renderHook(() => useRotateParameters());
expect(result.current.parameters).toEqual(defaultParameters);
expect(result.current.parameters.angle).toBe(0);
@@ -15,7 +12,7 @@ describe('useRotateParameters', () => {
});
test('should validate parameters correctly', () => {
const { result } = renderRotateHook();
const { result } = renderHook(() => useRotateParameters());
// Default should be valid
expect(result.current.validateParameters()).toBe(true);
@@ -34,7 +31,7 @@ describe('useRotateParameters', () => {
});
test('should rotate clockwise correctly', () => {
const { result } = renderRotateHook();
const { result } = renderHook(() => useRotateParameters());
act(() => {
result.current.rotateClockwise();
@@ -61,7 +58,7 @@ describe('useRotateParameters', () => {
});
test('should rotate anticlockwise correctly', () => {
const { result } = renderRotateHook();
const { result } = renderHook(() => useRotateParameters());
act(() => {
result.current.rotateAnticlockwise();
@@ -88,7 +85,7 @@ describe('useRotateParameters', () => {
});
test('should normalize angles correctly', () => {
const { result } = renderRotateHook();
const { result } = renderHook(() => useRotateParameters());
expect(result.current.normalizeAngle(360)).toBe(0);
expect(result.current.normalizeAngle(450)).toBe(90);
@@ -97,7 +94,7 @@ describe('useRotateParameters', () => {
});
test('should reset parameters correctly', () => {
const { result } = renderRotateHook();
const { result } = renderHook(() => useRotateParameters());
// Set some rotation
act(() => {
@@ -119,7 +116,7 @@ describe('useRotateParameters', () => {
});
test('should update parameters', () => {
const { result } = renderRotateHook();
const { result } = renderHook(() => useRotateParameters());
act(() => {
result.current.updateParameter('angle', 450);
@@ -135,13 +132,13 @@ describe('useRotateParameters', () => {
});
test('should return correct endpoint name', () => {
const { result } = renderRotateHook();
const { result } = renderHook(() => useRotateParameters());
expect(result.current.getEndpointName()).toBe('rotate-pdf');
});
test('should detect rotation state correctly', () => {
const { result } = renderRotateHook();
const { result } = renderHook(() => useRotateParameters());
// Initially no rotation
expect(result.current.hasRotation).toBe(false);
@@ -1,19 +1,16 @@
import { describe, expect, test } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { defaultParameters, useSanitizeParameters } from '@app/hooks/tools/sanitize/useSanitizeParameters';
import { PreferencesTestWrapper } from '@testing/preferencesTestWrapper';
const renderSanitizeHook = () => renderHook(() => useSanitizeParameters(), { wrapper: PreferencesTestWrapper });
describe('useSanitizeParameters', () => {
test('should initialize with default parameters', () => {
const { result } = renderSanitizeHook();
const { result } = renderHook(() => useSanitizeParameters());
expect(result.current.parameters).toStrictEqual(defaultParameters);
});
test('should update individual parameters', () => {
const { result } = renderSanitizeHook();
const { result } = renderHook(() => useSanitizeParameters());
act(() => {
result.current.updateParameter('removeXMPMetadata', true);
@@ -26,7 +23,7 @@ describe('useSanitizeParameters', () => {
});
test('should reset parameters to defaults', () => {
const { result } = renderSanitizeHook();
const { result } = renderHook(() => useSanitizeParameters());
// First, change some parameters
act(() => {
@@ -46,13 +43,13 @@ describe('useSanitizeParameters', () => {
});
test('should return correct endpoint name', () => {
const { result } = renderSanitizeHook();
const { result } = renderHook(() => useSanitizeParameters());
expect(result.current.getEndpointName()).toBe('sanitize-pdf');
});
test('should validate parameters correctly', () => {
const { result } = renderSanitizeHook();
const { result } = renderHook(() => useSanitizeParameters());
// Default state should be valid (has removeJavaScript and removeEmbeddedFiles enabled)
expect(result.current.validateParameters()).toBe(true);
@@ -74,7 +71,7 @@ describe('useSanitizeParameters', () => {
});
test('should handle all parameter types correctly', () => {
const { result } = renderSanitizeHook();
const { result } = renderHook(() => useSanitizeParameters());
const allParameters = Object.keys(defaultParameters) as (keyof typeof defaultParameters)[];
@@ -1,6 +1,4 @@
import { useState, useCallback, Dispatch, SetStateAction, useRef, useEffect } from 'react';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { loadToolParameters, saveToolParameters } from '@app/services/toolParameterStorage';
import { useState, useCallback, Dispatch, SetStateAction } from 'react';
export interface BaseParametersHook<T> {
parameters: T;
@@ -15,31 +13,10 @@ export interface BaseParametersConfig<T> {
defaultParameters: T;
endpointName: string | ((params: T) => string);
validateFn?: (params: T) => boolean;
storageKey?: string;
}
export function useBaseParameters<T>(config: BaseParametersConfig<T>): BaseParametersHook<T> {
const { preferences } = usePreferences();
const storageKey = config.storageKey ?? (typeof config.endpointName === 'string' ? config.endpointName : undefined);
const shouldPersist = Boolean(storageKey && preferences.rememberFormInputs);
const hasHydratedFromStorage = useRef(false);
const mergeWithDefaults = useCallback((values?: Partial<T>): T => ({
...(config.defaultParameters as T),
...(values ?? {}),
}), [config.defaultParameters]);
const [parameters, setParameters] = useState<T>(() => {
if (shouldPersist && storageKey) {
const stored = loadToolParameters<T>(storageKey);
if (stored) {
hasHydratedFromStorage.current = true;
return mergeWithDefaults(stored);
}
}
return mergeWithDefaults();
});
const [parameters, setParameters] = useState<T>(config.defaultParameters);
const updateParameter = useCallback(<K extends keyof T>(parameter: K, value: T[K]) => {
setParameters(prev => ({
@@ -56,34 +33,6 @@ export function useBaseParameters<T>(config: BaseParametersConfig<T>): BaseParam
return config.validateFn ? config.validateFn(parameters) : true;
}, [parameters, config.validateFn]);
useEffect(() => {
if (!storageKey) {
return;
}
if (!preferences.rememberFormInputs) {
hasHydratedFromStorage.current = false;
return;
}
if (hasHydratedFromStorage.current) {
return;
}
const stored = loadToolParameters<T>(storageKey);
if (stored) {
setParameters(mergeWithDefaults(stored));
}
hasHydratedFromStorage.current = true;
}, [mergeWithDefaults, preferences.rememberFormInputs, storageKey]);
useEffect(() => {
if (!storageKey || !preferences.rememberFormInputs) {
return;
}
saveToolParameters(storageKey, parameters);
}, [parameters, preferences.rememberFormInputs, storageKey]);
const endpointName = config.endpointName;
let getEndpointName: () => string;
if (typeof endpointName === "string") {
@@ -57,6 +57,5 @@ export const useSplitParameters = (): SplitParametersHook => {
return false;
}
},
storageKey: 'split',
});
};
+2 -6
View File
@@ -64,12 +64,8 @@ export function useToolSections(
Object.entries(subs).forEach(([s, tools]) => {
const subcategoryId = s as SubcategoryId;
// Build the 'all' collection without duplicating recommended tools
// Recommended tools are shown in the Quick section only
if (categoryId !== ToolCategoryId.RECOMMENDED_TOOLS) {
if (!all[subcategoryId]) all[subcategoryId] = [];
all[subcategoryId].push(...tools);
}
if (!all[subcategoryId]) all[subcategoryId] = [];
all[subcategoryId].push(...tools);
});
if (categoryId === ToolCategoryId.RECOMMENDED_TOOLS) {
+2 -2
View File
@@ -218,7 +218,7 @@ export default function HomePage() {
<div className="mobile-bottom-bar">
<button
className="mobile-bottom-button"
aria-label={t('quickAccess.allTools', 'Tools')}
aria-label={t('quickAccess.allTools', 'All Tools')}
onClick={() => {
handleBackToTools();
if (isMobile) {
@@ -227,7 +227,7 @@ export default function HomePage() {
}}
>
<AppsIcon sx={{ fontSize: '1.5rem' }} />
<span className="mobile-bottom-button-label">{t('quickAccess.allTools', 'Tools')}</span>
<span className="mobile-bottom-button-label">{t('quickAccess.allTools', 'All Tools')}</span>
</button>
<button
className="mobile-bottom-button"
-1
View File
@@ -7,7 +7,6 @@ import { getApiBaseUrl } from '@app/services/apiClientConfig';
const apiClient = axios.create({
baseURL: getApiBaseUrl(),
responseType: 'json',
withCredentials: true,
});
// Setup interceptors (core does nothing, proprietary adds JWT auth)
@@ -9,7 +9,6 @@ export interface UserPreferences {
toolPanelModePromptSeen: boolean;
showLegacyToolDescriptions: boolean;
hasCompletedOnboarding: boolean;
rememberFormInputs: boolean;
}
export const DEFAULT_PREFERENCES: UserPreferences = {
@@ -20,7 +19,6 @@ export const DEFAULT_PREFERENCES: UserPreferences = {
toolPanelModePromptSeen: false,
showLegacyToolDescriptions: false,
hasCompletedOnboarding: false,
rememberFormInputs: false,
};
const STORAGE_KEY = 'stirlingpdf_preferences';
@@ -1,114 +0,0 @@
const STORAGE_KEY = 'stirlingpdf_tool_parameters';
type SerializableValue = string | number | boolean | null | SerializableValue[] | { [key: string]: SerializableValue };
const isFile = (value: unknown): value is File => typeof File !== 'undefined' && value instanceof File;
const isBlob = (value: unknown): value is Blob => typeof Blob !== 'undefined' && value instanceof Blob;
function sanitizeValue(value: unknown): SerializableValue | undefined {
if (value === null) {
return null;
}
const valueType = typeof value;
if (valueType === 'string' || valueType === 'number' || valueType === 'boolean') {
if (valueType === 'number' && Number.isNaN(value)) {
return null;
}
return value as SerializableValue;
}
if (valueType === 'undefined' || valueType === 'function' || valueType === 'symbol' || valueType === 'bigint') {
return undefined;
}
if (Array.isArray(value)) {
const sanitizedArray = value
.map((item) => sanitizeValue(item))
.filter((item) => item !== undefined) as SerializableValue[];
return sanitizedArray;
}
if (isFile(value) || isBlob(value)) {
return undefined;
}
if (valueType === 'object' && value !== null) {
const entries = Object.entries(value as Record<string, unknown>);
const sanitizedEntries: Record<string, SerializableValue> = {};
for (const [key, entryValue] of entries) {
const sanitized = sanitizeValue(entryValue);
if (sanitized !== undefined) {
sanitizedEntries[key] = sanitized;
}
}
return sanitizedEntries;
}
return undefined;
}
function readStorage(): Record<string, SerializableValue> {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return {};
}
const parsed = JSON.parse(raw);
if (typeof parsed === 'object' && parsed !== null) {
return parsed as Record<string, SerializableValue>;
}
} catch (error) {
console.error('[toolParameterStorage] Failed to read stored parameters', error);
}
return {};
}
function writeStorage(map: Record<string, SerializableValue>): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
} catch (error) {
console.error('[toolParameterStorage] Failed to write stored parameters', error);
}
}
export function loadToolParameters<T>(toolKey: string): Partial<T> | null {
const storage = readStorage();
const stored = storage[toolKey];
if (!stored || typeof stored !== 'object') {
return null;
}
return stored as Partial<T>;
}
export function saveToolParameters<T>(toolKey: string, parameters: T): void {
const sanitized = sanitizeValue(parameters);
if (sanitized === undefined) {
return;
}
const storage = readStorage();
storage[toolKey] = sanitized;
writeStorage(storage);
}
export function clearToolParameters(toolKey: string): void {
const storage = readStorage();
if (Object.prototype.hasOwnProperty.call(storage, toolKey)) {
delete storage[toolKey];
writeStorage(storage);
}
}
export function clearAllToolParameters(): void {
try {
localStorage.removeItem(STORAGE_KEY);
} catch (error) {
console.error('[toolParameterStorage] Failed to clear stored parameters', error);
}
}
@@ -1,7 +1,6 @@
import { ReactNode } from "react";
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
import { DesktopConfigSync } from '@app/components/DesktopConfigSync';
import { DesktopBannerInitializer } from '@app/components/DesktopBannerInitializer';
import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig';
/**
@@ -23,7 +22,6 @@ export function AppProviders({ children }: { children: ReactNode }) {
}}
>
<DesktopConfigSync />
<DesktopBannerInitializer />
{children}
</ProprietaryAppProviders>
);
@@ -1,13 +0,0 @@
import { useEffect } from 'react';
import { useBanner } from '@app/contexts/BannerContext';
import { DefaultAppBanner } from '@app/components/shared/DefaultAppBanner';
export function DesktopBannerInitializer() {
const { setBanner } = useBanner();
useEffect(() => {
setBanner(<DefaultAppBanner />);
}, [setBanner]);
return null;
}
@@ -1,27 +0,0 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { InfoBanner } from '@app/components/shared/InfoBanner';
import { useDefaultApp } from '@app/hooks/useDefaultApp';
export const DefaultAppBanner: React.FC = () => {
const { t } = useTranslation();
const { isDefault, isLoading, handleSetDefault } = useDefaultApp();
const [dismissed, setDismissed] = useState(false);
const handleDismissPrompt = () => {
setDismissed(true);
};
return (
<InfoBanner
icon="picture-as-pdf-rounded"
message={t('defaultApp.prompt.message', 'Make Stirling PDF your default application for opening PDF files.')}
buttonText={t('defaultApp.setDefault', 'Set Default')}
buttonIcon="check-circle-rounded"
onButtonClick={handleSetDefault}
onDismiss={handleDismissPrompt}
loading={isLoading}
show={!dismissed && isDefault === false}
/>
);
};
@@ -1,40 +0,0 @@
import React from 'react';
import { Paper, Text, Button, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useDefaultApp } from '@app/hooks/useDefaultApp';
export const DefaultAppSettings: React.FC = () => {
const { t } = useTranslation();
const { isDefault, isLoading, handleSetDefault } = useDefaultApp();
return (
<Paper withBorder p="md" radius="md">
<Group justify="space-between" align="center">
<div>
<Text fw={500} size="sm">
{t('settings.general.defaultPdfEditor', 'Default PDF editor')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{isDefault === true
? t('settings.general.defaultPdfEditorActive', 'Stirling PDF is your default PDF editor')
: isDefault === false
? t('settings.general.defaultPdfEditorInactive', 'Another application is set as default')
: t('settings.general.defaultPdfEditorChecking', 'Checking...')}
</Text>
</div>
<Button
variant={isDefault ? 'light' : 'filled'}
color="blue"
size="sm"
onClick={handleSetDefault}
loading={isLoading}
disabled={isDefault === true}
>
{isDefault
? t('settings.general.defaultPdfEditorSet', 'Already Default')
: t('settings.general.setAsDefault', 'Set as Default')}
</Button>
</Group>
</Paper>
);
};
@@ -1,18 +0,0 @@
import React from 'react';
import { Stack } from '@mantine/core';
import CoreGeneralSection from '@core/components/shared/config/configSections/GeneralSection';
import { DefaultAppSettings } from '@app/components/shared/config/configSections/DefaultAppSettings';
/**
* Desktop extension of GeneralSection that adds default PDF editor settings
*/
const GeneralSection: React.FC = () => {
return (
<Stack gap="lg">
<DefaultAppSettings />
<CoreGeneralSection />
</Stack>
);
};
export default GeneralSection;
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useRef } from 'react';
import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
import { useOpenedFile } from '@app/hooks/useOpenedFile';
import { fileOpenService } from '@app/services/fileOpenService';
@@ -20,42 +20,75 @@ export function useAppInitialization(): void {
// Handle files opened with app (Tauri mode)
const { openedFilePaths, loading: openedFileLoading } = useOpenedFile();
// Track if we've already loaded the initial files to prevent duplicate loads
const initialFilesLoadedRef = useRef(false);
// Load opened files and add directly to FileContext
useEffect(() => {
if (openedFilePaths.length === 0 || openedFileLoading) {
return;
}
if (openedFilePaths.length > 0 && !openedFileLoading && !initialFilesLoadedRef.current) {
initialFilesLoadedRef.current = true;
const loadOpenedFiles = async () => {
try {
const filesArray: File[] = [];
const loadOpenedFiles = async () => {
try {
const filesArray: File[] = [];
await Promise.all(
openedFilePaths.map(async (filePath) => {
try {
const fileData = await fileOpenService.readFileAsArrayBuffer(filePath);
if (fileData) {
const file = new File([fileData.arrayBuffer], fileData.fileName, {
type: 'application/pdf'
});
filesArray.push(file);
console.log('[Desktop] Loaded file:', fileData.fileName);
// Load all files in parallel
await Promise.all(
openedFilePaths.map(async (filePath) => {
try {
const fileData = await fileOpenService.readFileAsArrayBuffer(filePath);
if (fileData) {
const file = new File([fileData.arrayBuffer], fileData.fileName, {
type: 'application/pdf'
});
filesArray.push(file);
console.log('[Desktop] Loaded file:', fileData.fileName);
}
} catch (error) {
console.error('[Desktop] Failed to load file:', filePath, error);
}
} catch (error) {
console.error('[Desktop] Failed to load file:', filePath, error);
}
})
);
})
);
if (filesArray.length > 0) {
await addFiles(filesArray);
console.log(`[Desktop] ${filesArray.length} opened file(s) added to FileContext`);
if (filesArray.length > 0) {
// Add all files to FileContext at once
await addFiles(filesArray);
console.log(`[Desktop] ${filesArray.length} opened file(s) added to FileContext`);
}
} catch (error) {
console.error('[Desktop] Failed to load opened files:', error);
}
};
loadOpenedFiles();
}
}, [openedFilePaths, openedFileLoading, addFiles]);
// Listen for runtime file-opened events (from second instances on Windows/Linux)
useEffect(() => {
const handleRuntimeFileOpen = async (filePath: string) => {
try {
console.log('[Desktop] Runtime file-opened event received:', filePath);
const fileData = await fileOpenService.readFileAsArrayBuffer(filePath);
if (fileData) {
// Create a File object from the ArrayBuffer
const file = new File([fileData.arrayBuffer], fileData.fileName, {
type: 'application/pdf'
});
// Add directly to FileContext
await addFiles([file]);
console.log('[Desktop] Runtime opened file added to FileContext:', fileData.fileName);
}
} catch (error) {
console.error('[Desktop] Failed to load opened files:', error);
console.error('[Desktop] Failed to load runtime opened file:', error);
}
};
loadOpenedFiles();
}, [openedFilePaths, openedFileLoading, addFiles]);
// Set up event listener and get cleanup function
const unlisten = fileOpenService.onFileOpened(handleRuntimeFileOpen);
// Clean up listener on unmount
return unlisten;
}, [addFiles]);
}
@@ -1,61 +0,0 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { defaultAppService } from '@app/services/defaultAppService';
import { alert } from '@app/components/toast';
export const useDefaultApp = () => {
const { t } = useTranslation();
const [isDefault, setIsDefault] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
checkDefaultStatus();
}, []);
const checkDefaultStatus = async () => {
try {
const status = await defaultAppService.isDefaultPdfHandler();
setIsDefault(status);
} catch (error) {
console.error('Failed to check default status:', error);
}
};
const handleSetDefault = async () => {
setIsLoading(true);
try {
const result = await defaultAppService.setAsDefaultPdfHandler();
if (result === 'set_successfully') {
alert({
alertType: 'success',
title: t('defaultApp.success.title', 'Default App Set'),
body: t('defaultApp.success.message', 'Stirling PDF is now your default PDF editor'),
});
setIsDefault(true);
} else if (result === 'opened_settings') {
alert({
alertType: 'neutral',
title: t('defaultApp.settingsOpened.title', 'Settings Opened'),
body: t('defaultApp.settingsOpened.message', 'Please select Stirling PDF in your system settings'),
});
}
} catch (error) {
console.error('Failed to set default:', error);
alert({
alertType: 'error',
title: t('defaultApp.error.title', 'Error'),
body: t('defaultApp.error.message', 'Failed to set default PDF handler'),
});
} finally {
setIsLoading(false);
}
};
return {
isDefault,
isLoading,
checkDefaultStatus,
handleSetDefault,
};
};
+15 -16
View File
@@ -1,46 +1,45 @@
import { useState, useEffect } from 'react';
import { fileOpenService } from '@app/services/fileOpenService';
import { listen } from '@tauri-apps/api/event';
export function useOpenedFile() {
const [openedFilePaths, setOpenedFilePaths] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Function to read and process files from storage
const readFilesFromStorage = async () => {
console.log('🔍 Reading files from storage...');
const checkForOpenedFile = async () => {
console.log('🔍 Checking for opened file(s)...');
try {
const filePaths = await fileOpenService.getOpenedFiles();
console.log('🔍 fileOpenService.getOpenedFiles() returned:', filePaths);
if (filePaths.length > 0) {
console.log(`✅ Found ${filePaths.length} file(s) in storage:`, filePaths);
console.log(`✅ App opened with ${filePaths.length} file(s):`, filePaths);
setOpenedFilePaths(filePaths);
// Clear the files from service state after consuming them
await fileOpenService.clearOpenedFiles();
} else {
console.log('ℹ️ No files were opened with the app');
}
} catch (error) {
console.error('❌ Failed to read files from storage:', error);
console.error('❌ Failed to check for opened files:', error);
} finally {
setLoading(false);
}
};
// Read files on mount
readFilesFromStorage();
checkForOpenedFile();
// Listen for files-changed events (when new files are added to storage)
let unlisten: (() => void) | undefined;
listen('files-changed', async () => {
console.log('📂 files-changed event received, re-reading storage...');
await readFilesFromStorage();
}).then(unlistenFn => {
unlisten = unlistenFn;
// Listen for runtime file open events (abstracted through service)
const unlistenRuntimeEvents = fileOpenService.onFileOpened((filePath: string) => {
console.log('📂 Runtime file open event:', filePath);
setOpenedFilePaths(prev => [...prev, filePath]);
});
// Cleanup function
return () => {
if (unlisten) unlisten();
unlistenRuntimeEvents();
};
}, []);
@@ -1,70 +0,0 @@
import { invoke } from '@tauri-apps/api/core';
/**
* Service for managing default PDF handler settings
* Note: Uses localStorage for machine-specific preferences (not synced to server)
*/
export const defaultAppService = {
/**
* Check if Stirling PDF is the default PDF handler
*/
async isDefaultPdfHandler(): Promise<boolean> {
try {
const result = await invoke<boolean>('is_default_pdf_handler');
return result;
} catch (error) {
console.error('[DefaultApp] Failed to check default handler:', error);
return false;
}
},
/**
* Set or prompt to set Stirling PDF as default PDF handler
* Returns a status string indicating what happened
*/
async setAsDefaultPdfHandler(): Promise<'set_successfully' | 'opened_settings' | 'error'> {
try {
const result = await invoke<string>('set_as_default_pdf_handler');
return result as 'set_successfully' | 'opened_settings';
} catch (error) {
console.error('[DefaultApp] Failed to set default handler:', error);
return 'error';
}
},
/**
* Check if user has dismissed the default app prompt (machine-specific)
*/
hasUserDismissedPrompt(): boolean {
try {
const dismissed = localStorage.getItem('stirlingpdf_default_app_prompt_dismissed');
return dismissed === 'true';
} catch {
return false;
}
},
/**
* Mark that user has dismissed the default app prompt (machine-specific)
*/
setPromptDismissed(dismissed: boolean): void {
try {
localStorage.setItem('stirlingpdf_default_app_prompt_dismissed', dismissed ? 'true' : 'false');
} catch (error) {
console.error('[DefaultApp] Failed to save prompt preference:', error);
}
},
/**
* Check if we should show the default app prompt
* Returns true if: user hasn't dismissed it AND app is not default handler
*/
async shouldShowPrompt(): Promise<boolean> {
if (this.hasUserDismissedPrompt()) {
return false;
}
const isDefault = await this.isDefaultPdfHandler();
return !isDefault;
},
};
+10 -13
View File
@@ -1,7 +1,6 @@
import { Suspense } from "react";
import { Routes, Route } from "react-router-dom";
import { AppProviders } from "@app/components/AppProviders";
import { AppLayout } from "@app/components/AppLayout";
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
import Landing from "@app/routes/Landing";
import Login from "@app/routes/Login";
@@ -23,19 +22,17 @@ export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
<AppProviders>
<AppLayout>
<Routes>
{/* Auth routes - no nested providers needed */}
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
<Routes>
{/* Auth routes - no nested providers needed */}
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
<OnboardingTour />
</AppLayout>
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
<OnboardingTour />
</AppProviders>
</Suspense>
);
@@ -107,7 +107,7 @@ class SpringAuthClient {
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'XSRF-TOKEN') {
return decodeURIComponent(value);
return value;
}
}
return null;
@@ -278,7 +278,7 @@ class SpringAuthClient {
try {
const response = await apiClient.post('/api/v1/auth/logout', null, {
headers: {
'X-XSRF-TOKEN': this.getCsrfToken() || '',
'X-CSRF-TOKEN': this.getCsrfToken() || '',
},
withCredentials: true,
});
@@ -311,7 +311,7 @@ class SpringAuthClient {
try {
const response = await apiClient.post('/api/v1/auth/refresh', null, {
headers: {
'X-XSRF-TOKEN': this.getCsrfToken() || '',
'X-CSRF-TOKEN': this.getCsrfToken() || '',
},
withCredentials: true,
});
@@ -1,515 +0,0 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import {
Modal,
Stack,
Text,
Button,
TextInput,
Select,
Paper,
Checkbox,
Textarea,
SegmentedControl,
Tooltip,
CloseButton,
Box,
Group,
} from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
import { userManagementService } from '@app/services/userManagementService';
import { teamService, Team } from '@app/services/teamService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useAppConfig } from '@app/contexts/AppConfigContext';
interface InviteMembersModalProps {
opened: boolean;
onClose: () => void;
}
export default function InviteMembersModal({ opened, onClose }: InviteMembersModalProps) {
const { t } = useTranslation();
const { config } = useAppConfig();
const [teams, setTeams] = useState<Team[]>([]);
const [processing, setProcessing] = useState(false);
const [inviteMode, setInviteMode] = useState<'email' | 'direct' | 'link'>('direct');
const [generatedInviteLink, setGeneratedInviteLink] = useState<string | null>(null);
// License information
const [licenseInfo, setLicenseInfo] = useState<{
maxAllowedUsers: number;
availableSlots: number;
grandfatheredUserCount: number;
licenseMaxUsers: number;
premiumEnabled: boolean;
totalUsers: number;
} | null>(null);
// Form state for direct invite
const [inviteForm, setInviteForm] = useState({
username: '',
password: '',
role: 'ROLE_USER',
teamId: undefined as number | undefined,
forceChange: false,
});
// Form state for email invite
const [emailInviteForm, setEmailInviteForm] = useState({
emails: '',
role: 'ROLE_USER',
teamId: undefined as number | undefined,
});
// Form state for invite link
const [inviteLinkForm, setInviteLinkForm] = useState({
email: '',
role: 'ROLE_USER',
teamId: undefined as number | undefined,
expiryHours: 72,
sendEmail: false,
});
// Fetch teams and license info
useEffect(() => {
if (opened) {
const fetchData = async () => {
try {
const [adminData, teamsData] = await Promise.all([
userManagementService.getUsers(),
teamService.getTeams(),
]);
setTeams(teamsData);
setLicenseInfo({
maxAllowedUsers: adminData.maxAllowedUsers,
availableSlots: adminData.availableSlots,
grandfatheredUserCount: adminData.grandfatheredUserCount,
licenseMaxUsers: adminData.licenseMaxUsers,
premiumEnabled: adminData.premiumEnabled,
totalUsers: adminData.totalUsers,
});
} catch (error) {
console.error('Failed to fetch data:', error);
}
};
fetchData();
}
}, [opened]);
const roleOptions = [
{
value: 'ROLE_USER',
label: t('workspace.people.roleDescriptions.user', 'User'),
},
{
value: 'ROLE_ADMIN',
label: t('workspace.people.roleDescriptions.admin', 'Admin'),
},
];
const teamOptions = teams.map((team) => ({
value: team.id.toString(),
label: team.name,
}));
const handleInviteUser = async () => {
if (!inviteForm.username || !inviteForm.password) {
alert({ alertType: 'error', title: t('workspace.people.addMember.usernameRequired') });
return;
}
try {
setProcessing(true);
await userManagementService.createUser({
username: inviteForm.username,
password: inviteForm.password,
role: inviteForm.role,
teamId: inviteForm.teamId,
authType: 'password',
forceChange: inviteForm.forceChange,
});
alert({ alertType: 'success', title: t('workspace.people.addMember.success') });
onClose();
// Reset form
setInviteForm({
username: '',
password: '',
role: 'ROLE_USER',
teamId: undefined,
forceChange: false,
});
} catch (error: any) {
console.error('Failed to invite user:', error);
const errorMessage = error.response?.data?.message || error.response?.data?.error || error.message || t('workspace.people.addMember.error');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleEmailInvite = async () => {
if (!emailInviteForm.emails.trim()) {
alert({ alertType: 'error', title: t('workspace.people.emailInvite.emailsRequired', 'Email addresses are required') });
return;
}
try {
setProcessing(true);
const response = await userManagementService.inviteUsers({
emails: emailInviteForm.emails, // comma-separated string as required by API
role: emailInviteForm.role,
teamId: emailInviteForm.teamId,
});
if (response.successCount > 0) {
alert({
alertType: 'success',
title: t('workspace.people.emailInvite.success', { count: response.successCount, defaultValue: `Successfully invited ${response.successCount} user(s)` })
});
onClose();
setEmailInviteForm({
emails: '',
role: 'ROLE_USER',
teamId: undefined,
});
} else {
alert({
alertType: 'error',
title: t('workspace.people.emailInvite.allFailed', 'Failed to invite users'),
body: response.errors || response.error
});
}
} catch (error: any) {
console.error('Failed to invite users:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.people.emailInvite.error', 'Failed to send invites');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleGenerateInviteLink = async () => {
try {
setProcessing(true);
const response = await userManagementService.generateInviteLink({
email: inviteLinkForm.email || undefined,
role: inviteLinkForm.role,
teamId: inviteLinkForm.teamId,
expiryHours: inviteLinkForm.expiryHours,
sendEmail: inviteLinkForm.sendEmail,
});
setGeneratedInviteLink(response.inviteUrl);
if (inviteLinkForm.sendEmail && inviteLinkForm.email) {
alert({ alertType: 'success', title: t('workspace.people.inviteLink.emailSent', 'Invite link generated and sent via email') });
}
} catch (error: any) {
console.error('Failed to generate invite link:', error);
const errorMessage = error.response?.data?.message || error.response?.data?.error || error.message || t('workspace.people.inviteLink.error', 'Failed to generate invite link');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleClose = () => {
setGeneratedInviteLink(null);
setInviteMode('direct');
setInviteForm({
username: '',
password: '',
role: 'ROLE_USER',
teamId: undefined,
forceChange: false,
});
setEmailInviteForm({
emails: '',
role: 'ROLE_USER',
teamId: undefined,
});
setInviteLinkForm({
email: '',
role: 'ROLE_USER',
teamId: undefined,
expiryHours: 72,
sendEmail: false,
});
onClose();
};
return (
<Modal
opened={opened}
onClose={handleClose}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<Box pos="relative">
<CloseButton
onClick={handleClose}
size="lg"
style={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="person-add" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<Text size="xl" fw={600} ta="center">
{t('workspace.people.inviteMembers.label', 'Invite Members')}
</Text>
{inviteMode === 'email' && (
<Text size="sm" c="dimmed" ta="center" px="md">
{t('workspace.people.inviteMembers.subtitle', 'Type or paste in emails below, separated by commas. Your workspace will be billed by members.')}
</Text>
)}
</Stack>
{/* License Warning/Info */}
{licenseInfo && (
<Paper withBorder p="sm" bg={licenseInfo.availableSlots === 0 ? 'var(--mantine-color-red-light)' : 'var(--mantine-color-blue-light)'}>
<Stack gap="xs">
<Group gap="xs">
<LocalIcon icon={licenseInfo.availableSlots > 0 ? 'info' : 'warning'} width="1rem" height="1rem" />
<Text size="sm" fw={500}>
{licenseInfo.availableSlots > 0
? t('workspace.people.license.slotsAvailable', {
count: licenseInfo.availableSlots,
defaultValue: `${licenseInfo.availableSlots} user slot(s) available`
})
: t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
</Text>
</Group>
<Text size="xs" c="dimmed">
{t('workspace.people.license.currentUsage', {
current: licenseInfo.totalUsers,
max: licenseInfo.maxAllowedUsers,
defaultValue: `Currently using ${licenseInfo.totalUsers} of ${licenseInfo.maxAllowedUsers} user licenses`
})}
</Text>
</Stack>
</Paper>
)}
{/* Mode Toggle */}
<Tooltip
label={t('workspace.people.inviteMode.emailDisabled', 'Email invites require SMTP configuration and mail.enableInvites=true in settings')}
disabled={!!config?.enableEmailInvites}
position="bottom"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 1}
>
<div>
<SegmentedControl
value={inviteMode}
onChange={(value) => {
setInviteMode(value as 'email' | 'direct' | 'link');
setGeneratedInviteLink(null);
}}
data={[
{
label: t('workspace.people.inviteMode.username', 'Username'),
value: 'direct',
},
{
label: t('workspace.people.inviteMode.link', 'Link'),
value: 'link',
},
{
label: t('workspace.people.inviteMode.email', 'Email'),
value: 'email',
disabled: !config?.enableEmailInvites,
},
]}
fullWidth
/>
</div>
</Tooltip>
{/* Link Mode */}
{inviteMode === 'link' && (
<>
<TextInput
label={t('workspace.people.inviteLink.email', 'Email (optional)')}
placeholder={t('workspace.people.inviteLink.emailPlaceholder', 'user@example.com')}
value={inviteLinkForm.email}
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, email: e.currentTarget.value })}
description={t('workspace.people.inviteLink.emailDescription', 'If provided, the link will be tied to this email address')}
/>
<Select
label={t('workspace.people.addMember.role')}
data={roleOptions}
value={inviteLinkForm.role}
onChange={(value) => setInviteLinkForm({ ...inviteLinkForm, role: value || 'ROLE_USER' })}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
label={t('workspace.people.addMember.team')}
placeholder={t('workspace.people.addMember.teamPlaceholder')}
data={teamOptions}
value={inviteLinkForm.teamId?.toString()}
onChange={(value) => setInviteLinkForm({ ...inviteLinkForm, teamId: value ? parseInt(value) : undefined })}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<TextInput
label={t('workspace.people.inviteLink.expiryHours', 'Link expires in (hours)')}
type="number"
value={inviteLinkForm.expiryHours}
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, expiryHours: parseInt(e.currentTarget.value) || 72 })}
min={1}
max={720}
/>
{inviteLinkForm.email && (
<Checkbox
label={t('workspace.people.inviteLink.sendEmail', 'Send invite link via email')}
description={t('workspace.people.inviteLink.sendEmailDescription', 'Also send the link to the provided email address')}
checked={inviteLinkForm.sendEmail}
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, sendEmail: e.currentTarget.checked })}
/>
)}
{/* Display generated link */}
{generatedInviteLink && (
<Paper withBorder p="md" bg="var(--mantine-color-green-light)">
<Stack gap="sm">
<Text size="sm" fw={500}>{t('workspace.people.inviteLink.generated', 'Invite Link Generated')}</Text>
<Group gap="xs">
<TextInput
value={generatedInviteLink}
readOnly
style={{ flex: 1 }}
/>
<Button
variant="light"
onClick={async () => {
try {
await navigator.clipboard.writeText(generatedInviteLink);
alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard!') });
} catch {
// Fallback for browsers without clipboard API
const textArea = document.createElement('textarea');
textArea.value = generatedInviteLink;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard!') });
}
}}
>
<LocalIcon icon="content-copy" width="1rem" height="1rem" />
</Button>
</Group>
</Stack>
</Paper>
)}
</>
)}
{/* Email Mode */}
{inviteMode === 'email' && config?.enableEmailInvites && (
<>
<Textarea
label={t('workspace.people.emailInvite.emails', 'Email Addresses')}
placeholder={t('workspace.people.emailInvite.emailsPlaceholder', 'user1@example.com, user2@example.com')}
value={emailInviteForm.emails}
onChange={(e) => setEmailInviteForm({ ...emailInviteForm, emails: e.currentTarget.value })}
minRows={3}
required
/>
<Select
label={t('workspace.people.addMember.role')}
data={roleOptions}
value={emailInviteForm.role}
onChange={(value) => setEmailInviteForm({ ...emailInviteForm, role: value || 'ROLE_USER' })}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
label={t('workspace.people.addMember.team')}
placeholder={t('workspace.people.addMember.teamPlaceholder')}
data={teamOptions}
value={emailInviteForm.teamId?.toString()}
onChange={(value) => setEmailInviteForm({ ...emailInviteForm, teamId: value ? parseInt(value) : undefined })}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
</>
)}
{/* Direct/Username Mode */}
{inviteMode === 'direct' && (
<>
<TextInput
label={t('workspace.people.addMember.username')}
placeholder={t('workspace.people.addMember.usernamePlaceholder')}
value={inviteForm.username}
onChange={(e) => setInviteForm({ ...inviteForm, username: e.currentTarget.value })}
required
/>
<TextInput
label={t('workspace.people.addMember.password')}
type="password"
placeholder={t('workspace.people.addMember.passwordPlaceholder')}
value={inviteForm.password}
onChange={(e) => setInviteForm({ ...inviteForm, password: e.currentTarget.value })}
required
/>
<Select
label={t('workspace.people.addMember.role')}
data={roleOptions}
value={inviteForm.role}
onChange={(value) => setInviteForm({ ...inviteForm, role: value || 'ROLE_USER' })}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
label={t('workspace.people.addMember.team')}
placeholder={t('workspace.people.addMember.teamPlaceholder')}
data={teamOptions}
value={inviteForm.teamId?.toString()}
onChange={(value) => setInviteForm({ ...inviteForm, teamId: value ? parseInt(value) : undefined })}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Checkbox
label={t('workspace.people.addMember.forcePasswordChange', 'Force password change on first login')}
checked={inviteForm.forceChange}
onChange={(e) => setInviteForm({ ...inviteForm, forceChange: e.currentTarget.checked })}
/>
</>
)}
{/* Action Button */}
<Button
onClick={inviteMode === 'email' ? handleEmailInvite : inviteMode === 'link' ? handleGenerateInviteLink : handleInviteUser}
loading={processing}
fullWidth
size="md"
mt="md"
>
{inviteMode === 'email'
? t('workspace.people.emailInvite.submit', 'Send Invites')
: inviteMode === 'link'
? t('workspace.people.inviteLink.submit', 'Generate Link')
: t('workspace.people.addMember.submit')}
</Button>
</Stack>
</Box>
</Modal>
);
}
@@ -13,6 +13,10 @@ import {
Group,
Modal,
Select,
Paper,
Checkbox,
Textarea,
SegmentedControl,
Tooltip,
CloseButton,
Avatar,
@@ -24,7 +28,6 @@ import { userManagementService, User } from '@app/services/userManagementService
import { teamService, Team } from '@app/services/teamService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import InviteMembersModal from '@app/components/shared/InviteMembersModal';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
@@ -40,6 +43,8 @@ export default function PeopleSection() {
const [editUserModalOpened, setEditUserModalOpened] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [processing, setProcessing] = useState(false);
const [inviteMode, setInviteMode] = useState<'email' | 'direct' | 'link'>('direct');
const [generatedInviteLink, setGeneratedInviteLink] = useState<string | null>(null);
// License information
const [licenseInfo, setLicenseInfo] = useState<{
@@ -51,6 +56,31 @@ export default function PeopleSection() {
totalUsers: number;
} | null>(null);
// Form state for direct invite
const [inviteForm, setInviteForm] = useState({
username: '',
password: '',
role: 'ROLE_USER',
teamId: undefined as number | undefined,
forceChange: false,
});
// Form state for email invite
const [emailInviteForm, setEmailInviteForm] = useState({
emails: '',
role: 'ROLE_USER',
teamId: undefined as number | undefined,
});
// Form state for invite link
const [inviteLinkForm, setInviteLinkForm] = useState({
email: '',
role: 'ROLE_USER',
teamId: undefined as number | undefined,
expiryHours: 72,
sendEmail: false,
});
// Form state for edit user modal
const [editForm, setEditForm] = useState({
role: 'ROLE_USER',
@@ -175,6 +205,143 @@ export default function PeopleSection() {
}
};
const handleInviteUser = async () => {
if (!inviteForm.username || !inviteForm.password) {
alert({ alertType: 'error', title: t('workspace.people.addMember.usernameRequired') });
return;
}
try {
setProcessing(true);
await userManagementService.createUser({
username: inviteForm.username,
password: inviteForm.password,
role: inviteForm.role,
teamId: inviteForm.teamId,
authType: 'password',
forceChange: inviteForm.forceChange,
});
alert({ alertType: 'success', title: t('workspace.people.addMember.success') });
setInviteModalOpened(false);
setInviteForm({
username: '',
password: '',
role: 'ROLE_USER',
teamId: undefined,
forceChange: false,
});
fetchData();
} catch (error: any) {
console.error('Failed to create user:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.people.addMember.error');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleEmailInvite = async () => {
if (!emailInviteForm.emails.trim()) {
alert({ alertType: 'error', title: t('workspace.people.emailInvite.emailsRequired', 'At least one email address is required') });
return;
}
try {
setProcessing(true);
const response = await userManagementService.inviteUsers({
emails: emailInviteForm.emails,
role: emailInviteForm.role,
teamId: emailInviteForm.teamId,
});
if (response.successCount > 0) {
alert({
alertType: 'success',
title: t('workspace.people.emailInvite.success', `${response.successCount} user(s) invited successfully`)
});
if (response.failureCount > 0 && response.errors) {
alert({
alertType: 'warning',
title: t('workspace.people.emailInvite.partialSuccess', 'Some invites failed'),
body: response.errors
});
}
setInviteModalOpened(false);
setEmailInviteForm({
emails: '',
role: 'ROLE_USER',
teamId: undefined,
});
fetchData();
} else {
alert({
alertType: 'error',
title: t('workspace.people.emailInvite.allFailed', 'Failed to invite users'),
body: response.errors || response.error
});
}
} catch (error: any) {
console.error('Failed to invite users:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.people.emailInvite.error', 'Failed to send invites');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleGenerateInviteLink = async () => {
try {
setProcessing(true);
const response = await userManagementService.generateInviteLink({
email: inviteLinkForm.email,
role: inviteLinkForm.role,
teamId: inviteLinkForm.teamId,
expiryHours: inviteLinkForm.expiryHours,
sendEmail: inviteLinkForm.sendEmail,
});
// Construct full frontend URL
const frontendUrl = `${window.location.origin}/invite/${response.token}`;
setGeneratedInviteLink(frontendUrl);
if (response.emailSent) {
alert({
alertType: 'success',
title: t('workspace.people.inviteLink.successWithEmail', 'Invite link generated and email sent!')
});
} else if (inviteLinkForm.sendEmail && response.emailError) {
// Email was requested but failed
alert({
alertType: 'warning',
title: t('workspace.people.inviteLink.emailFailed', 'Invite link generated, but email failed'),
body: t('workspace.people.inviteLink.emailFailedDetails', 'Error: {0}. Please share the invite link manually.').replace('{0}', response.emailError)
});
} else {
alert({
alertType: 'success',
title: t('workspace.people.inviteLink.success', 'Invite link generated successfully!')
});
}
} catch (error: any) {
console.error('Failed to generate invite link:', error);
const errorMessage = error.response?.data?.message ||
error.response?.data?.error ||
error.message ||
t('workspace.people.inviteLink.error', 'Failed to generate invite link');
alert({ alertType: 'error', title: errorMessage });
} finally {
setProcessing(false);
}
};
const handleUpdateUserRole = async () => {
if (!selectedUser) return;
@@ -253,6 +420,18 @@ export default function PeopleSection() {
});
};
const closeInviteModal = () => {
setInviteModalOpened(false);
setGeneratedInviteLink(null);
setInviteLinkForm({
email: '',
role: 'ROLE_USER',
teamId: undefined,
expiryHours: 72,
sendEmail: false,
});
};
const filteredUsers = users.filter((user) =>
user.username.toLowerCase().includes(searchQuery.toLowerCase())
);
@@ -541,11 +720,277 @@ export default function PeopleSection() {
</Table.Tbody>
</Table>
{/* Invite Members Modal (reusable) */}
<InviteMembersModal
{/* Add Member Modal */}
<Modal
opened={inviteModalOpened}
onClose={() => setInviteModalOpened(false)}
/>
onClose={closeInviteModal}
size="md"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
centered
padding="xl"
withCloseButton={false}
>
<Box pos="relative">
<CloseButton
onClick={closeInviteModal}
size="lg"
style={{
position: 'absolute',
top: -8,
right: -8,
zIndex: 1
}}
/>
<Stack gap="lg" pt="md">
{/* Header with Icon */}
<Stack gap="md" align="center">
<LocalIcon icon="person-add" width="3rem" height="3rem" style={{ color: 'var(--mantine-color-gray-6)' }} />
<Text size="xl" fw={600} ta="center">
{t('workspace.people.inviteMembers.label', 'Invite Members')}
</Text>
{inviteMode === 'email' && (
<Text size="sm" c="dimmed" ta="center" px="md">
{t('workspace.people.inviteMembers.subtitle', 'Type or paste in emails below, separated by commas. Your workspace will be billed by members.')}
</Text>
)}
</Stack>
{/* License Warning/Info */}
{licenseInfo && (
<Paper withBorder p="sm" bg={licenseInfo.availableSlots === 0 ? 'var(--mantine-color-red-light)' : 'var(--mantine-color-blue-light)'}>
<Stack gap="xs">
<Group gap="xs">
<LocalIcon icon={licenseInfo.availableSlots > 0 ? 'info' : 'warning'} width="1rem" height="1rem" />
<Text size="sm" fw={500}>
{licenseInfo.availableSlots > 0
? t('workspace.people.license.slotsAvailable', {
count: licenseInfo.availableSlots,
defaultValue: `${licenseInfo.availableSlots} user slot(s) available`
})
: t('workspace.people.license.noSlotsAvailable', 'No user slots available')}
</Text>
</Group>
<Text size="xs" c="dimmed">
{t('workspace.people.license.currentUsage', {
current: licenseInfo.totalUsers,
max: licenseInfo.maxAllowedUsers,
defaultValue: `Currently using ${licenseInfo.totalUsers} of ${licenseInfo.maxAllowedUsers} user licenses`
})}
</Text>
</Stack>
</Paper>
)}
{/* Mode Toggle */}
<Tooltip
label={t('workspace.people.inviteMode.emailDisabled', 'Email invites require SMTP configuration and mail.enableInvites=true in settings')}
disabled={!!config?.enableEmailInvites}
position="bottom"
withArrow
zIndex={Z_INDEX_OVER_CONFIG_MODAL + 1}
>
<div>
<SegmentedControl
value={inviteMode}
onChange={(value) => {
setInviteMode(value as 'email' | 'direct' | 'link');
setGeneratedInviteLink(null);
}}
data={[
{
label: t('workspace.people.inviteMode.username', 'Username'),
value: 'direct',
},
{
label: t('workspace.people.inviteMode.link', 'Link'),
value: 'link',
},
{
label: t('workspace.people.inviteMode.email', 'Email'),
value: 'email',
disabled: !config?.enableEmailInvites,
},
]}
fullWidth
/>
</div>
</Tooltip>
{/* Link Mode */}
{inviteMode === 'link' && (
<>
<TextInput
label={t('workspace.people.inviteLink.email', 'Email (optional)')}
placeholder={t('workspace.people.inviteLink.emailPlaceholder', 'user@example.com')}
value={inviteLinkForm.email}
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, email: e.currentTarget.value })}
description={t('workspace.people.inviteLink.emailDescription', 'If provided, the link will be tied to this email address')}
/>
<Select
label={t('workspace.people.addMember.role')}
data={roleOptions}
value={inviteLinkForm.role}
onChange={(value) => setInviteLinkForm({ ...inviteLinkForm, role: value || 'ROLE_USER' })}
renderOption={renderRoleOption}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
label={t('workspace.people.addMember.team')}
placeholder={t('workspace.people.addMember.teamPlaceholder')}
data={teamOptions}
value={inviteLinkForm.teamId?.toString()}
onChange={(value) => setInviteLinkForm({ ...inviteLinkForm, teamId: value ? parseInt(value) : undefined })}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<TextInput
label={t('workspace.people.inviteLink.expiryHours', 'Link expires in (hours)')}
type="number"
value={inviteLinkForm.expiryHours}
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, expiryHours: parseInt(e.currentTarget.value) || 72 })}
min={1}
max={720}
/>
{inviteLinkForm.email && (
<Checkbox
label={t('workspace.people.inviteLink.sendEmail', 'Send invite link via email')}
description={t('workspace.people.inviteLink.sendEmailDescription', 'Also send the link to the provided email address')}
checked={inviteLinkForm.sendEmail}
onChange={(e) => setInviteLinkForm({ ...inviteLinkForm, sendEmail: e.currentTarget.checked })}
/>
)}
{/* Display generated link */}
{generatedInviteLink && (
<Paper withBorder p="md" bg="var(--mantine-color-green-light)">
<Stack gap="sm">
<Text size="sm" fw={500}>{t('workspace.people.inviteLink.generated', 'Invite Link Generated')}</Text>
<Group gap="xs">
<TextInput
value={generatedInviteLink}
readOnly
style={{ flex: 1 }}
/>
<Button
variant="light"
onClick={async () => {
try {
await navigator.clipboard.writeText(generatedInviteLink);
alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard!') });
} catch {
// Fallback for browsers without clipboard API
const textArea = document.createElement('textarea');
textArea.value = generatedInviteLink;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
alert({ alertType: 'success', title: t('workspace.people.inviteLink.copied', 'Link copied to clipboard!') });
}
}}
>
<LocalIcon icon="content-copy" width="1rem" height="1rem" />
</Button>
</Group>
</Stack>
</Paper>
)}
</>
)}
{/* Email Mode */}
{inviteMode === 'email' && config?.enableEmailInvites && (
<>
<Textarea
label={t('workspace.people.emailInvite.emails', 'Email Addresses')}
placeholder={t('workspace.people.emailInvite.emailsPlaceholder', 'user1@example.com, user2@example.com')}
value={emailInviteForm.emails}
onChange={(e) => setEmailInviteForm({ ...emailInviteForm, emails: e.currentTarget.value })}
minRows={3}
required
/>
<Select
label={t('workspace.people.addMember.role')}
data={roleOptions}
value={emailInviteForm.role}
onChange={(value) => setEmailInviteForm({ ...emailInviteForm, role: value || 'ROLE_USER' })}
renderOption={renderRoleOption}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
label={t('workspace.people.addMember.team')}
placeholder={t('workspace.people.addMember.teamPlaceholder')}
data={teamOptions}
value={emailInviteForm.teamId?.toString()}
onChange={(value) => setEmailInviteForm({ ...emailInviteForm, teamId: value ? parseInt(value) : undefined })}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
</>
)}
{/* Direct/Username Mode */}
{inviteMode === 'direct' && (
<>
<TextInput
label={t('workspace.people.addMember.username')}
placeholder={t('workspace.people.addMember.usernamePlaceholder')}
value={inviteForm.username}
onChange={(e) => setInviteForm({ ...inviteForm, username: e.currentTarget.value })}
required
/>
<TextInput
label={t('workspace.people.addMember.password')}
type="password"
placeholder={t('workspace.people.addMember.passwordPlaceholder')}
value={inviteForm.password}
onChange={(e) => setInviteForm({ ...inviteForm, password: e.currentTarget.value })}
required
/>
<Select
label={t('workspace.people.addMember.role')}
data={roleOptions}
value={inviteForm.role}
onChange={(value) => setInviteForm({ ...inviteForm, role: value || 'ROLE_USER' })}
renderOption={renderRoleOption}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Select
label={t('workspace.people.addMember.team')}
placeholder={t('workspace.people.addMember.teamPlaceholder')}
data={teamOptions}
value={inviteForm.teamId?.toString()}
onChange={(value) => setInviteForm({ ...inviteForm, teamId: value ? parseInt(value) : undefined })}
clearable
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
<Checkbox
label={t('workspace.people.addMember.forcePasswordChange', 'Force password change on first login')}
checked={inviteForm.forceChange}
onChange={(e) => setInviteForm({ ...inviteForm, forceChange: e.currentTarget.checked })}
/>
</>
)}
{/* Action Button */}
<Button
onClick={inviteMode === 'email' ? handleEmailInvite : inviteMode === 'link' ? handleGenerateInviteLink : handleInviteUser}
loading={processing}
fullWidth
size="md"
mt="md"
>
{inviteMode === 'email'
? t('workspace.people.emailInvite.submit', 'Send Invites')
: inviteMode === 'link'
? t('workspace.people.inviteLink.submit', 'Generate Link')
: t('workspace.people.addMember.submit')}
</Button>
</Stack>
</Box>
</Modal>
{/* Edit User Modal */}
<Modal
@@ -9,38 +9,17 @@ function getJwtTokenFromStorage(): string | null {
}
}
function getXsrfToken(): string | null {
try {
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'XSRF-TOKEN') {
return decodeURIComponent(value);
}
}
return null;
} catch (error) {
console.error('[API Client] Failed to read XSRF token from cookies:', error);
return null;
}
}
export function setupApiInterceptors(client: AxiosInstance): void {
// Install request interceptor to add JWT token
client.interceptors.request.use(
(config) => {
const jwtToken = getJwtTokenFromStorage();
const xsrfToken = getXsrfToken();
if (jwtToken && !config.headers.Authorization) {
config.headers.Authorization = `Bearer ${jwtToken}`;
console.debug('[API Client] Added JWT token from localStorage to Authorization header');
}
if (xsrfToken && !config.headers['X-XSRF-TOKEN']) {
config.headers['X-XSRF-TOKEN'] = xsrfToken;
}
return config;
},
(error) => {
@@ -69,7 +69,7 @@ export interface InviteUsersResponse {
}
export interface InviteLinkRequest {
email?: string;
email: string;
role: string;
teamId?: number;
expiryHours?: number;
@@ -1,6 +0,0 @@
import React from 'react';
import { PreferencesProvider } from '@app/contexts/PreferencesContext';
export const PreferencesTestWrapper = ({ children }: { children: React.ReactNode }) => (
<PreferencesProvider>{children}</PreferencesProvider>
);
+1 -2
View File
@@ -4,8 +4,7 @@
"paths": {
"@app/*": [
"src/core/*"
],
"@testing/*": ["src/testing/*"]
]
}
},
"exclude": [
+1 -2
View File
@@ -9,8 +9,7 @@
],
"@desktop/*": ["src/desktop/*"],
"@proprietary/*": ["src/proprietary/*"],
"@core/*": ["src/core/*"],
"@testing/*": ["src/testing/*"]
"@core/*": ["src/core/*"]
}
},
"exclude": [
-1
View File
@@ -36,7 +36,6 @@
"src/proprietary/*",
"src/core/*"
],
"@testing/*": ["src/testing/*"],
"@desktop/*": ["src/desktop/*"],
"@proprietary/*": ["src/proprietary/*"],
"@core/*": ["src/core/*"]
+1 -2
View File
@@ -6,8 +6,7 @@
"src/proprietary/*",
"src/core/*"
],
"@core/*": ["src/core/*"],
"@testing/*": ["src/testing/*"]
"@core/*": ["src/core/*"]
}
},
"exclude": [