Compare commits

..
Author SHA1 Message Date
Anthony Stirling 84ac407017 extra validations 2025-11-21 12:59:30 +00:00
Anthony Stirling 794d15ebaa address comments list[] 2025-11-21 12:57:35 +00:00
Anthony Stirling 1e11848d4f remove logs 2025-11-21 11:55:25 +00:00
Anthony Stirling 5f32502b54 style cleanup 2025-11-21 11:49:35 +00:00
Anthony Stirling 4f4f3c90ba Merge branch 'codex/re-enable-tool-disabling-functionality' of git@github.com:Stirling-Tools/Stirling-PDF.git into codex/re-enable-tool-disabling-functionality 2025-11-21 11:03:52 +00:00
Anthony Stirling 2e51a7b288 translations 2025-11-21 10:48:01 +00:00
Anthony Stirling 8140ff8c02 rtf to text oops 2025-11-20 23:57:05 +00:00
Anthony StirlingandGitHub 432e1047d9 Merge branch 'V2' into codex/re-enable-tool-disabling-functionality 2025-11-20 23:55:00 +00:00
Anthony Stirling 89df78c347 update tool disable 2025-11-20 23:52:02 +00:00
Anthony Stirling dd6419743f Merge remote-tracking branch 'origin/V2' into
codex/re-enable-tool-disabling-functionality
2025-11-20 21:27:06 +00:00
Dario Ghunney WareandGitHub fca8470637 Moar Login Fixes (#4948) 2025-11-20 20:51:53 +00:00
Balázs SzücsandGitHub 76f2fd3b76 [V2] refactor(tooltips): add merge tooltips header for consistency, and update other tooltips (#4895)
# Description of Changes

Note on tooltips: I'll do more PRs on tooltips, however this has
priority, hence I submitted this for now.


#### Before:

<img width="758" height="721" alt="image"
src="https://github.com/user-attachments/assets/ce3166d2-c681-447e-82df-68fe6d18669d"
/>

<img width="782" height="485" alt="image"
src="https://github.com/user-attachments/assets/48970a4c-9911-4ade-9346-c33c5a6a319f"
/>

<img width="782" height="602" alt="image"
src="https://github.com/user-attachments/assets/9f77648d-4d1a-4e4b-93af-eaab6db849ff"
/>

<img width="782" height="602" alt="image"
src="https://github.com/user-attachments/assets/ae653534-bf04-47a0-8aab-0bb5b9c3ecef"
/>

#### After:
<img width="758" height="721" alt="image"
src="https://github.com/user-attachments/assets/8d45e876-a0df-4fe3-89a8-5184492db204"
/>

<img width="782" height="602" alt="image"
src="https://github.com/user-attachments/assets/550ee105-1286-4072-9f55-46255b3fbe80"
/>

<img width="782" height="602" alt="image"
src="https://github.com/user-attachments/assets/fba7ea93-73fe-4529-84fe-c37816375a9e"
/>
<img width="782" height="602" alt="image"
src="https://github.com/user-attachments/assets/ca452e77-b58b-4a5b-8e97-e6ccef4cf500"
/>


<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [X] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [X] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [X] I have performed a self-review of my own code
- [X] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [X] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
2025-11-20 15:00:16 +00:00
Balázs SzücsandGitHub 06af6be14b [V2] feat(pipeline): add pre-publish sanitization workflow (#4910)
# Description of Changes
TLDR
- Created `Pre-publish-sanitization.json` default pipeline configuration
- Added sanitization operations removing metadata, JavaScript, embedded
files, and annotations
- Registered new pipeline in `GeneralUtils`
- Included "Pre-publish Sanitization" in the suggested automations list

This pull request introduces a new "Pre-publish Sanitization" workflow
for PDF files, designed to help users remove sensitive metadata and
content before publishing documents online. The changes include backend
and frontend updates to support this workflow, as well as a minor bug
fix in form data handling.

**New Pre-publish Sanitization Workflow:**

* Added a new default configuration file `Pre-publish-sanitization.json`
that defines a pipeline for sanitizing PDFs by removing JavaScript,
embedded files, metadata, annotations, flattening forms, and compressing
the document.
* Registered the new `Pre-publish-sanitization.json` config in the set
of default web UI configurations in `GeneralUtils.java`, making it
available in the application.

**Frontend Integration:**

* Added a new suggested automation called "Pre-publish Sanitization" in
the `useSuggestedAutomations` hook, including its name, description,
operations, and a new privacy icon for better UI representation.

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [X] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [X] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [X] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [X] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
2025-11-20 14:59:13 +00:00
Anthony StirlingandGitHub c87da6d5cc Add automatic unlock prompt for encrypted PDFs (#4912)
## Summary
- propagate an `isEncrypted` flag from thumbnail generation into
processed file metadata so uploads know when a password is still present
- add queueing logic inside `FileContext` that detects encrypted
uploads, prompts the user via a new modal, and automatically runs the
Remove Password endpoint to replace the file and preserve history
- introduce a dedicated `EncryptedPdfUnlockModal` component that mirrors
existing styling and messaging for unlocking PDFs

## Testing
- npm run typecheck:core


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6919a0a418bc8328b886ec76a28170b7)
2025-11-20 14:54:41 +00:00
6c8d2c89fe Add initial Windows signing infrastructure (#4945)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: James Brunton <james@stirlingpdf.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2025-11-20 12:21:42 +00:00
8d9e70c796 Stripe and license payment integration (#4935)
selfhosted stripe payment and license integration

---------

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
2025-11-20 12:07:37 +00:00
Anthony Stirling 5f62e6f81f Differentiate unavailable tools by reason 2025-11-16 22:05:25 +00:00
58 changed files with 3075 additions and 262 deletions
+304 -2
View File
@@ -14,7 +14,7 @@ on:
- macos
- linux
pull_request:
branches: [main, V2]
branches: [main, V2, V2-tauri-windows]
paths:
- 'frontend/src-tauri/**'
- 'frontend/src/desktop/**'
@@ -61,6 +61,9 @@ jobs:
fail-fast: false
matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }}
runs-on: ${{ matrix.platform }}
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
@@ -174,6 +177,84 @@ jobs:
working-directory: ./frontend
run: npm install
# DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker
id: digicert-setup
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
uses: digicert/ssm-code-signing@v1.1.0
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
SM_HOST: ${{ secrets.SM_HOST }}
- name: Setup DigiCert KeyLocker Certificate
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
shell: pwsh
run: |
Write-Host "Setting up DigiCert KeyLocker environment..."
# Decode client certificate
$certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}")
$certPath = "D:\Certificate_pkcs12.p12"
[IO.File]::WriteAllBytes($certPath, $certBytes)
# Set environment variables
echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV
echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV
echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV
echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV
echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV
# Get PKCS11 config path from DigiCert action
$pkcs11Config = $env:PKCS11_CONFIG
if ($pkcs11Config) {
Write-Host "Found PKCS11_CONFIG: $pkcs11Config"
echo "PKCS11_CONFIG=$pkcs11Config" >> $env:GITHUB_ENV
} else {
Write-Host "PKCS11_CONFIG not set by DigiCert action, using default path"
$defaultPath = "C:\Users\RUNNER~1\AppData\Local\Temp\smtools-windows-x64\pkcs11properties.cfg"
if (Test-Path $defaultPath) {
Write-Host "Found config at default path: $defaultPath"
echo "PKCS11_CONFIG=$defaultPath" >> $env:GITHUB_ENV
} else {
Write-Host "Warning: Could not find PKCS11 config file"
}
}
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
- name: Import Windows Code Signing Certificate
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' }}
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
shell: powershell
run: |
if ($env:WINDOWS_CERTIFICATE) {
Write-Host "Importing Windows Code Signing Certificate..."
# Decode base64 certificate and save to file
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
$certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
[IO.File]::WriteAllBytes($certPath, $certBytes)
# Import certificate to CurrentUser\My store
$cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
# Extract and set thumbprint as environment variable
$thumbprint = $cert.Thumbprint
Write-Host "Certificate imported with thumbprint: $thumbprint"
echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
# Clean up certificate file
Remove-Item $certPath
Write-Host "Windows certificate import completed."
} else {
Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
}
- name: Import Apple Developer Certificate
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
env:
@@ -229,13 +310,174 @@ jobs:
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
SIGN: 1
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
SIGN: ${{ (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
CI: true
with:
projectPath: ./frontend
tauriScript: npx tauri
args: ${{ matrix.args }}
# Sign with DigiCert KeyLocker (post-build)
- name: Sign Windows binaries with DigiCert KeyLocker
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
shell: pwsh
run: |
Write-Host "=== DigiCert KeyLocker Signing ==="
# Test smctl connectivity first
Write-Host "Testing smctl connection..."
$healthCheck = & smctl healthcheck 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "[SUCCESS] Connected to DigiCert KeyLocker"
} else {
Write-Host "[ERROR] Failed to connect to DigiCert KeyLocker"
Write-Host $healthCheck
exit 1
}
Write-Host ""
# Sync certificates to Windows certificate store
Write-Host "Syncing certificates to Windows certificate store..."
$syncOutput = & smctl windows certsync 2>&1
Write-Host "Cert sync result: $syncOutput"
Write-Host ""
# List available certificates and check if they have certificates attached
Write-Host "Checking for available certificates..."
$certList = & smctl keypair ls 2>&1
Write-Host "Keypair list output:"
Write-Host $certList
Write-Host ""
# Parse the output to check certificate status
$lines = $certList -split "`n"
$foundKeypair = $false
$hasCertificate = $false
foreach ($line in $lines) {
if ($line -match "${{ secrets.SM_KEYPAIR_ALIAS }}") {
$foundKeypair = $true
Write-Host "[SUCCESS] Found keypair in list"
# Check if this line has certificate info (not just empty spaces after alias)
$parts = $line -split "\s+"
if ($parts.Count -gt 2 -and $parts[1] -ne "" -and $parts[1] -ne "CERTIFICATE") {
$hasCertificate = $true
Write-Host "[SUCCESS] Certificate is associated with keypair"
}
}
}
if (-not $foundKeypair) {
Write-Host "[ERROR] Keypair not found: ${{ secrets.SM_KEYPAIR_ALIAS }}"
Write-Host "Available keypairs are listed above"
Write-Host ""
Write-Host "Please verify:"
Write-Host " 1. Keypair alias is correct in GitHub secret"
Write-Host " 2. API key has access to this keypair"
exit 1
}
if (-not $hasCertificate) {
Write-Host "[ERROR] No certificate associated with keypair"
Write-Host "This usually means:"
Write-Host " 1. Certificate not yet synced to KeyLocker (run sync manually)"
Write-Host " 2. Certificate is pending approval"
Write-Host " 3. Certificate needs to be attached to the keypair"
Write-Host ""
Write-Host "Try running in DigiCert ONE portal:"
Write-Host " smctl keypair sync"
exit 1
}
Write-Host "[SUCCESS] Certificate check passed"
Write-Host ""
# Find only the files we need to sign (not build scripts)
$filesToSign = @()
# Main application executable
$mainExe = Get-ChildItem -Path "./frontend/src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "stirling-pdf.exe" -File -ErrorAction SilentlyContinue
if ($mainExe) { $filesToSign += $mainExe }
# MSI installer
$msiFiles = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*.msi" -Recurse -File
$filesToSign += $msiFiles
if ($filesToSign.Count -eq 0) {
Write-Host "[ERROR] No files found to sign"
exit 1
}
Write-Host "Found $($filesToSign.Count) files to sign:"
foreach ($f in $filesToSign) { Write-Host " - $($f.Name)" }
Write-Host ""
$signedCount = 0
foreach ($file in $filesToSign) {
Write-Host "Signing: $($file.Name)"
# Get PKCS11 config file path (set by DigiCert action)
$pkcs11Config = $env:PKCS11_CONFIG
if (-not $pkcs11Config) {
Write-Host "[ERROR] PKCS11_CONFIG environment variable not set"
Write-Host "DigiCert KeyLocker action may not have run correctly"
exit 1
}
Write-Host "Using PKCS11 config: $pkcs11Config"
# Try signing with certificate fingerprint first (if available)
$fingerprint = "${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}"
if ($fingerprint -and $fingerprint -ne "") {
Write-Host "Attempting to sign with certificate fingerprint..."
$output = & smctl sign --fingerprint "$fingerprint" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
$exitCode = $LASTEXITCODE
} else {
Write-Host "No fingerprint provided, using keypair alias..."
# Use smctl to sign with keypair alias
$output = & smctl sign --keypair-alias "${{ secrets.SM_KEYPAIR_ALIAS }}" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
$exitCode = $LASTEXITCODE
}
Write-Host "Exit code: $exitCode"
Write-Host "Output: $output"
# Check if output contains "FAILED" even with exit code 0
if ($output -match "FAILED" -or $output -match "error" -or $output -match "Error") {
Write-Host ""
Write-Host "[ERROR] Signing failed for $($file.Name)"
Write-Host "[ERROR] smctl returned success but output indicates failure"
Write-Host ""
Write-Host "Possible issues:"
Write-Host " 1. Certificate not fully synced to KeyLocker (wait a few minutes)"
Write-Host " 2. Incorrect keypair alias"
Write-Host " 3. API key lacks signing permissions"
Write-Host ""
Write-Host "Please verify in DigiCert ONE portal:"
Write-Host " - Certificate status is 'Issued' (not Pending)"
Write-Host " - Keypair status is 'Online'"
Write-Host " - 'Can sign' is set to 'Yes'"
exit 1
}
if ($exitCode -ne 0) {
Write-Host "[ERROR] Failed to sign $($file.Name)"
Write-Host "Full error output:"
Write-Host $output
exit 1
}
$signedCount++
Write-Host "[SUCCESS] Signed: $($file.Name)"
Write-Host ""
}
Write-Host "=== Summary ==="
Write-Host "[SUCCESS] Signed $signedCount/$($filesToSign.Count) files successfully"
- name: Verify notarization (macOS only)
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
run: |
@@ -269,6 +511,66 @@ jobs:
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
fi
- name: Verify Windows Code Signature
if: matrix.platform == 'windows-latest'
shell: pwsh
run: |
Write-Host "Verifying Windows code signatures..."
$exePath = "./dist/Stirling-PDF-${{ matrix.name }}.exe"
$msiPath = "./dist/Stirling-PDF-${{ matrix.name }}.msi"
$allSigned = $true
$usingKeyLocker = "${{ env.SM_API_KEY }}" -ne ""
$usingPfx = "${{ env.WINDOWS_CERTIFICATE }}" -ne ""
# Check EXE signature
if (Test-Path $exePath) {
$exeSig = Get-AuthenticodeSignature -FilePath $exePath
Write-Host "EXE Signature Status: $($exeSig.Status)"
Write-Host "EXE Signer: $($exeSig.SignerCertificate.Subject)"
Write-Host "EXE Timestamp: $($exeSig.TimeStamperCertificate.NotAfter)"
if ($exeSig.Status -ne "Valid") {
Write-Host "[WARNING] EXE is not properly signed (Status: $($exeSig.Status))"
if ($usingKeyLocker -or $usingPfx) {
Write-Host "[ERROR] Certificate was provided but signing failed"
$allSigned = $false
} else {
Write-Host "[INFO] Building unsigned binary (no certificate provided)"
}
} else {
Write-Host "[SUCCESS] EXE is properly signed"
}
}
# Check MSI signature
if (Test-Path $msiPath) {
$msiSig = Get-AuthenticodeSignature -FilePath $msiPath
Write-Host "MSI Signature Status: $($msiSig.Status)"
Write-Host "MSI Signer: $($msiSig.SignerCertificate.Subject)"
Write-Host "MSI Timestamp: $($msiSig.TimeStamperCertificate.NotAfter)"
if ($msiSig.Status -ne "Valid") {
Write-Host "[WARNING] MSI is not properly signed (Status: $($msiSig.Status))"
if ($usingKeyLocker -or $usingPfx) {
Write-Host "[ERROR] Certificate was provided but signing failed"
$allSigned = $false
} else {
Write-Host "[INFO] Building unsigned binary (no certificate provided)"
}
} else {
Write-Host "[SUCCESS] MSI is properly signed"
}
}
if (($usingKeyLocker -or $usingPfx) -and -not $allSigned) {
Write-Host "[ERROR] Code signing verification failed"
exit 1
} else {
Write-Host "[SUCCESS] Code signature verification completed"
}
- name: Upload artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
+258
View File
@@ -0,0 +1,258 @@
# Windows Code Signing Setup Guide
This guide explains how to set up Windows code signing for Stirling-PDF desktop application builds.
## Overview
Windows code signing is essential for:
- Preventing Windows SmartScreen warnings
- Building trust with users
- Enabling Microsoft Store distribution
- Professional application distribution
## Certificate Types
### OV Certificate (Organization Validated)
- More affordable option
- Requires business verification
- May trigger SmartScreen warnings initially until reputation builds
- Suitable for most independent software vendors
### EV Certificate (Extended Validation)
- Premium option with immediate SmartScreen reputation
- Requires hardware security module (HSM) or cloud-based signing
- Higher cost but provides immediate trust
- Required since June 2023 for new certificates
## Obtaining a Certificate
### Certificate Authorities
Popular certificate authorities for Windows code signing:
- DigiCert
- Sectigo (formerly Comodo)
- GlobalSign
- SSL.com
### Certificate Format
You'll receive a certificate in one of these formats:
- `.pfx` or `.p12` (preferred - contains both certificate and private key)
- `.cer` + private key (needs conversion to .pfx)
### Converting to PFX (if needed)
If you have separate certificate and private key files:
```bash
openssl pkcs12 -export -out certificate.pfx -inkey private-key.key -in certificate.cer
```
## Setting Up GitHub Secrets
### Required Secrets
Navigate to your GitHub repository → Settings → Secrets and variables → Actions
Add the following secrets:
#### 1. `WINDOWS_CERTIFICATE`
- **Description**: Base64-encoded .pfx certificate file
- **How to create**:
**On macOS/Linux:**
```bash
base64 -i certificate.pfx | pbcopy # Copies to clipboard
```
**On Windows (PowerShell):**
```powershell
[Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx")) | Set-Clipboard
```
Paste the entire base64 string into the GitHub secret.
#### 2. `WINDOWS_CERTIFICATE_PASSWORD`
- **Description**: Password for the .pfx certificate
- **Value**: The password you set when creating/exporting the .pfx file
### Optional Secrets for Tauri Updater
If you're using Tauri's built-in updater feature:
#### `TAURI_SIGNING_PRIVATE_KEY`
- Generated using Tauri CLI: `npm run tauri signer generate`
- Used for update package verification
#### `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`
- Password for the Tauri signing key
## Configuration Files
### 1. Tauri Configuration (frontend/src-tauri/tauri.conf.json)
The Windows signing configuration is already set up:
```json
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": "http://timestamp.digicert.com"
}
```
**Configuration Options:**
- `certificateThumbprint`: Automatically extracted from imported certificate (leave as `null`)
- `digestAlgorithm`: Hashing algorithm - `sha256` is recommended
- `timestampUrl`: Timestamp server to prove signing time (survives certificate expiration)
**Alternative Timestamp Servers:**
- DigiCert: `http://timestamp.digicert.com`
- Sectigo: `http://timestamp.sectigo.com`
- GlobalSign: `http://timestamp.globalsign.com`
### 2. GitHub Workflow (.github/workflows/tauri-build.yml)
The workflow includes three Windows signing steps:
1. **Import Certificate**: Decodes and imports the .pfx certificate into Windows certificate store
2. **Build Tauri App**: Builds and signs the application using the imported certificate
3. **Verify Signature**: Validates that both .exe and .msi files are properly signed
## Testing the Setup
### 1. Local Testing (Windows Only)
Before pushing to GitHub, test locally:
```powershell
# Set environment variables
$env:WINDOWS_CERTIFICATE = [Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx"))
$env:WINDOWS_CERTIFICATE_PASSWORD = "your-certificate-password"
# Build the application
cd frontend
npm run tauri build
# Verify the signature
Get-AuthenticodeSignature "./src-tauri/target/release/bundle/msi/Stirling-PDF_*.msi"
```
### 2. GitHub Actions Testing
1. Push your changes to a branch
2. Manually trigger the workflow:
- Go to Actions → Build Tauri Applications
- Click "Run workflow"
- Select "windows" platform
3. Check the build logs for:
- ✅ Certificate import success
- ✅ Build completion
- ✅ Signature verification
### 3. Verifying Signed Binaries
After downloading the built artifacts:
**Windows (PowerShell):**
```powershell
Get-AuthenticodeSignature "Stirling-PDF-windows-x86_64.exe"
Get-AuthenticodeSignature "Stirling-PDF-windows-x86_64.msi"
```
Look for:
- Status: `Valid`
- Signer: Your organization name
- Timestamp: Recent date/time
**Windows (GUI):**
1. Right-click the .exe or .msi file
2. Select "Properties"
3. Go to "Digital Signatures" tab
4. Verify signature details
## Troubleshooting
### "HashMismatch" Status
- Certificate doesn't match the binary
- Possible file corruption during download
- Re-download and verify
### "NotSigned" Status
- Certificate wasn't imported correctly
- Check GitHub secrets are set correctly
- Verify base64 encoding is complete (no truncation)
### "UnknownError" Status
- Timestamp server unreachable
- Try alternative timestamp URL in tauri.conf.json
- Check network connectivity in GitHub Actions
### SmartScreen Still Shows Warnings
- Normal for OV certificates initially
- Reputation builds over time with user downloads
- Consider EV certificate for immediate reputation
### Certificate Not Found During Build
- Verify `WINDOWS_CERTIFICATE` secret is set
- Check base64 encoding is correct (no extra whitespace)
- Ensure password is correct
## Security Best Practices
1. **Never commit certificates to version control**
- Keep .pfx files secure and backed up
- Use GitHub secrets for CI/CD
2. **Rotate certificates before expiration**
- Set calendar reminders
- Update GitHub secrets with new certificate
3. **Use strong passwords**
- Certificate password should be complex
- Store securely (password manager)
4. **Monitor certificate usage**
- Review GitHub Actions logs
- Set up notifications for failed builds
5. **Limit access to secrets**
- Only repository admins should access secrets
- Audit secret access regularly
## Certificate Lifecycle
### Before Expiration
1. Obtain new certificate from CA (typically annual renewal)
2. Convert to .pfx format if needed
3. Update `WINDOWS_CERTIFICATE` secret with new base64-encoded certificate
4. Update `WINDOWS_CERTIFICATE_PASSWORD` if password changed
5. Test build to verify new certificate works
### Expired Certificates
- Signed binaries remain valid (timestamp proves signing time)
- New builds will fail until certificate is renewed
- Users can still install previously signed versions
## Cost Considerations
### Certificate Costs (Annual, as of 2024)
- **OV Certificate**: $100-400/year
- **EV Certificate**: $400-1000/year
### Choosing the Right Certificate
- **Open source / early stage**: Start with OV
- **Commercial / enterprise**: Consider EV for better trust
- **Microsoft Store**: EV certificate required
## Additional Resources
- [Tauri Windows Signing Documentation](https://v2.tauri.app/distribute/sign/windows/)
- [Microsoft Code Signing Overview](https://docs.microsoft.com/windows/win32/seccrypto/cryptography-tools)
- [DigiCert Code Signing Guide](https://www.digicert.com/signing/code-signing-certificates)
- [Windows SmartScreen FAQ](https://support.microsoft.com/windows/smartscreen-faq)
## Support
If you encounter issues with Windows code signing:
1. Check GitHub Actions logs for detailed error messages
2. Verify all secrets are set correctly
3. Test certificate locally first (Windows environment required)
4. Open an issue in the repository with relevant logs (remove sensitive data)
@@ -38,6 +38,7 @@ public class GeneralUtils {
Set.of(
"OCR images.json",
"Prepare-pdfs-for-email.json",
"Pre-publish-sanitization.json",
"split-rotate-auto-rename.json");
private final String DEFAULT_WEBUI_CONFIGS_DIR = "defaultWebUIConfigs";
@@ -18,11 +18,37 @@ import stirling.software.common.model.ApplicationProperties;
@Slf4j
public class EndpointConfiguration {
public enum DisableReason {
CONFIG,
DEPENDENCY,
UNKNOWN
}
public static class EndpointAvailability {
private final boolean enabled;
private final DisableReason reason;
public EndpointAvailability(boolean enabled, DisableReason reason) {
this.enabled = enabled;
this.reason = reason;
}
public boolean isEnabled() {
return enabled;
}
public DisableReason getReason() {
return reason;
}
}
private static final String REMOVE_BLANKS = "remove-blanks";
private final ApplicationProperties applicationProperties;
@Getter private Map<String, Boolean> endpointStatuses = new ConcurrentHashMap<>();
private Map<String, Set<String>> endpointGroups = new ConcurrentHashMap<>();
private Set<String> disabledGroups = new HashSet<>();
private Map<String, DisableReason> endpointDisableReasons = new ConcurrentHashMap<>();
private Map<String, DisableReason> groupDisableReasons = new ConcurrentHashMap<>();
private Map<String, Set<String>> endpointAlternatives = new ConcurrentHashMap<>();
private final boolean runningProOrHigher;
@@ -35,16 +61,31 @@ public class EndpointConfiguration {
processEnvironmentConfigs();
}
private String normalizeEndpoint(String endpoint) {
if (endpoint == null) {
return null;
}
return endpoint.startsWith("/") ? endpoint.substring(1) : endpoint;
}
public void enableEndpoint(String endpoint) {
endpointStatuses.put(endpoint, true);
log.debug("Enabled endpoint: {}", endpoint);
String normalized = normalizeEndpoint(endpoint);
endpointStatuses.put(normalized, true);
endpointDisableReasons.remove(normalized);
log.debug("Enabled endpoint: {}", normalized);
}
public void disableEndpoint(String endpoint) {
if (!Boolean.FALSE.equals(endpointStatuses.get(endpoint))) {
log.debug("Disabling endpoint: {}", endpoint);
disableEndpoint(endpoint, DisableReason.CONFIG);
}
public void disableEndpoint(String endpoint, DisableReason reason) {
String normalized = normalizeEndpoint(endpoint);
if (!Boolean.FALSE.equals(endpointStatuses.get(normalized))) {
log.debug("Disabling endpoint: {}", normalized);
}
endpointStatuses.put(endpoint, false);
endpointStatuses.put(normalized, false);
endpointDisableReasons.put(normalized, reason);
}
public boolean isEndpointEnabled(String endpoint) {
@@ -150,6 +191,10 @@ public class EndpointConfiguration {
}
public void disableGroup(String group) {
disableGroup(group, DisableReason.CONFIG);
}
public void disableGroup(String group, DisableReason reason) {
if (disabledGroups.add(group)) {
if (isToolGroup(group)) {
log.debug(
@@ -161,11 +206,12 @@ public class EndpointConfiguration {
group);
}
}
groupDisableReasons.put(group, reason);
// Only cascade to endpoints for *functional* groups
if (!isToolGroup(group)) {
Set<String> endpoints = endpointGroups.get(group);
if (endpoints != null) {
endpoints.forEach(this::disableEndpoint);
endpoints.forEach(endpoint -> disableEndpoint(endpoint, reason));
}
}
}
@@ -174,12 +220,39 @@ public class EndpointConfiguration {
if (disabledGroups.remove(group)) {
log.debug("Enabling group: {}", group);
}
groupDisableReasons.remove(group);
Set<String> endpoints = endpointGroups.get(group);
if (endpoints != null) {
endpoints.forEach(this::enableEndpoint);
}
}
public EndpointAvailability getEndpointAvailability(String endpoint) {
boolean enabled = isEndpointEnabled(endpoint);
DisableReason reason = enabled ? null : determineDisableReason(endpoint);
return new EndpointAvailability(enabled, reason);
}
private DisableReason determineDisableReason(String endpoint) {
String normalized = normalizeEndpoint(endpoint);
if (Boolean.FALSE.equals(endpointStatuses.get(normalized))) {
return endpointDisableReasons.getOrDefault(normalized, DisableReason.CONFIG);
}
for (Map.Entry<String, Set<String>> entry : endpointGroups.entrySet()) {
String group = entry.getKey();
Set<String> endpoints = entry.getValue();
if (!disabledGroups.contains(group) || endpoints == null) {
continue;
}
if (endpoints.contains(normalized)) {
return groupDisableReasons.getOrDefault(group, DisableReason.CONFIG);
}
}
return DisableReason.UNKNOWN;
}
public Set<String> getDisabledGroups() {
return new HashSet<>(disabledGroups);
}
@@ -261,6 +334,8 @@ public class EndpointConfiguration {
addEndpointToGroup("Convert", "pdf-to-csv");
addEndpointToGroup("Convert", "pdf-to-markdown");
addEndpointToGroup("Convert", "eml-to-pdf");
addEndpointToGroup("Convert", "cbz-to-pdf");
addEndpointToGroup("Convert", "pdf-to-cbz");
// Adding endpoints to "Security" group
addEndpointToGroup("Security", "add-password");
@@ -394,6 +469,8 @@ public class EndpointConfiguration {
addEndpointToGroup("Java", "pdf-to-markdown");
addEndpointToGroup("Java", "add-attachments");
addEndpointToGroup("Java", "compress-pdf");
addEndpointToGroup("Java", "cbz-to-pdf");
addEndpointToGroup("Java", "pdf-to-cbz");
addEndpointToGroup("rar", "pdf-to-cbr");
// Javascript
@@ -12,6 +12,7 @@ import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration.DisableReason;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.util.RegexPatternUtils;
@@ -97,7 +98,7 @@ public class ExternalAppDepConfig {
if (affectedGroups != null) {
for (String group : affectedGroups) {
List<String> affectedFeatures = getAffectedFeatures(group);
endpointConfiguration.disableGroup(group);
endpointConfiguration.disableGroup(group, DisableReason.DEPENDENCY);
log.warn(
"Missing dependency: {} - Disabling group: {} (Affected features: {})",
command,
@@ -127,8 +128,8 @@ public class ExternalAppDepConfig {
if (!pythonAvailable) {
List<String> pythonFeatures = getAffectedFeatures("Python");
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
endpointConfiguration.disableGroup("Python");
endpointConfiguration.disableGroup("OpenCV");
endpointConfiguration.disableGroup("Python", DisableReason.DEPENDENCY);
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
log.warn(
"Missing dependency: Python - Disabling Python features: {} and OpenCV features: {}",
String.join(", ", pythonFeatures),
@@ -146,14 +147,14 @@ public class ExternalAppDepConfig {
int exitCode = process.waitFor();
if (exitCode != 0) {
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
endpointConfiguration.disableGroup("OpenCV");
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
log.warn(
"OpenCV not available in Python - Disabling OpenCV features: {}",
String.join(", ", openCVFeatures));
}
} catch (Exception e) {
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
endpointConfiguration.disableGroup("OpenCV");
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
log.warn(
"Error checking OpenCV: {} - Disabling OpenCV features: {}",
e.getMessage(),
@@ -2,6 +2,9 @@ package stirling.software.SPDF.config;
import java.io.IOException;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
@@ -9,17 +12,14 @@ import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.service.WeeklyActiveUsersService;
/**
* Filter to track browser IDs for Weekly Active Users (WAU) counting.
* Only active when security is disabled (no-login mode).
* Filter to track browser IDs for Weekly Active Users (WAU) counting. Only active when security is
* disabled (no-login mode).
*/
@Component
@ConditionalOnProperty(name = "security.enableLogin", havingValue = "false")
@@ -1,6 +1,7 @@
package stirling.software.SPDF.controller.api.misc;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.context.ApplicationContext;
@@ -10,9 +11,13 @@ import org.springframework.web.bind.annotation.RequestParam;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
import stirling.software.SPDF.config.InitialSetup;
import stirling.software.common.annotations.api.ConfigApi;
import stirling.software.common.configuration.AppConfig;
@@ -200,4 +205,19 @@ public class ConfigController {
}
return ResponseEntity.ok(result);
}
@GetMapping("/endpoints-availability")
public ResponseEntity<Map<String, EndpointAvailability>> getEndpointAvailability(
@RequestParam(name = "endpoints")
@Size(min = 1, max = 100, message = "Must provide between 1 and 100 endpoints")
List<@NotBlank String> endpoints) {
Map<String, EndpointAvailability> result = new HashMap<>();
for (String endpoint : endpoints) {
String trimmedEndpoint = endpoint.trim();
result.put(
trimmedEndpoint,
endpointConfiguration.getEndpointAvailability(trimmedEndpoint));
}
return ResponseEntity.ok(result);
}
}
@@ -369,7 +369,8 @@ public class MetricsController {
// Check if WAU service is available (only when security.enableLogin=false)
if (wauService.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("WAU tracking is only available when security is disabled (no-login mode)");
.body(
"WAU tracking is only available when security is disabled (no-login mode)");
}
WeeklyActiveUsersService service = wauService.get();
@@ -10,8 +10,8 @@ import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* Service for tracking Weekly Active Users (WAU) in no-login mode.
* Uses in-memory storage with automatic cleanup of old entries.
* Service for tracking Weekly Active Users (WAU) in no-login mode. Uses in-memory storage with
* automatic cleanup of old entries.
*/
@Service
@Slf4j
@@ -28,6 +28,7 @@ public class WeeklyActiveUsersService {
/**
* Records a browser access with the current timestamp
*
* @param browserId Unique browser identifier from X-Browser-Id header
*/
public void recordBrowserAccess(String browserId) {
@@ -46,6 +47,7 @@ public class WeeklyActiveUsersService {
/**
* Gets the count of unique browsers seen in the last 7 days
*
* @return Weekly Active Users count
*/
public long getWeeklyActiveUsers() {
@@ -55,6 +57,7 @@ public class WeeklyActiveUsersService {
/**
* Gets the total count of unique browsers ever seen
*
* @return Total unique browsers count
*/
public long getTotalUniqueBrowsers() {
@@ -63,6 +66,7 @@ public class WeeklyActiveUsersService {
/**
* Gets the number of days the service has been running
*
* @return Days online
*/
public long getDaysOnline() {
@@ -71,23 +75,20 @@ public class WeeklyActiveUsersService {
/**
* Gets the timestamp when tracking started
*
* @return Start time
*/
public Instant getStartTime() {
return startTime;
}
/**
* Removes entries older than 7 days
*/
/** Removes entries older than 7 days */
private void cleanupOldEntries() {
Instant sevenDaysAgo = Instant.now().minus(7, ChronoUnit.DAYS);
activeBrowsers.entrySet().removeIf(entry -> entry.getValue().isBefore(sevenDaysAgo));
}
/**
* Manual cleanup trigger (can be called by scheduled task if needed)
*/
/** Manual cleanup trigger (can be called by scheduled task if needed) */
public void performCleanup() {
int sizeBefore = activeBrowsers.size();
cleanupOldEntries();
@@ -0,0 +1,54 @@
{
"name": "Pre-publish-sanitization",
"pipeline": [
{
"operation": "/api/v1/security/sanitize-pdf",
"parameters": {
"removeJavaScript": true,
"removeEmbeddedFiles": true,
"removeXMPMetadata": true,
"removeMetadata": true,
"removeLinks": true,
"removeFonts": false
}
},
{
"operation": "/api/v1/misc/flatten",
"parameters": {
"flattenOnlyForms": true
}
},
{
"operation": "/api/v1/general/remove-annotations",
"parameters": {}
},
{
"operation": "/api/v1/misc/update-metadata",
"parameters": {
"deleteAll": true,
"author": "",
"creationDate": "",
"creator": "",
"keywords": "",
"modificationDate": "",
"producer": "",
"subject": "",
"title": "",
"trapped": ""
}
},
{
"operation": "/api/v1/misc/compress-pdf",
"parameters": {
"optimizeLevel": 3,
"expectedOutputSize": ""
}
}
],
"_examples": {
"outputDir": "{outputFolder}/{folderName}",
"outputFileName": "{filename}-{pipelineName}-{date}-{time}"
},
"outputDir": "{outputFolder}",
"outputFileName": "pre_publish_{filename}.PDF"
}
@@ -39,7 +39,7 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
public class JwtService implements JwtServiceInterface {
private static final String ISSUER = "https://stirling.com";
private static final long EXPIRATION = 3600000;
private static final long EXPIRATION = 43200000;
private final KeyPersistenceServiceInterface keyPersistenceService;
private final boolean v2Enabled;
+55 -2
View File
@@ -17,6 +17,8 @@
"comingSoon": "Coming soon:",
"favorite": "Add to favourites",
"favorites": "Favourites",
"unavailable": "Disabled by server administrator:",
"unavailableDependency": "Unavailable - required tool missing on server:",
"heading": "All tools (fullscreen view)",
"noResults": "Try adjusting your search or toggle descriptions to find what you need.",
"recommended": "Recommended",
@@ -918,6 +920,11 @@
},
"error": {
"failed": "An error occurred while merging the PDFs."
},
"tooltip": {
"header": {
"title": "Merge Settings Overview"
}
}
},
"split": {
@@ -2372,6 +2379,14 @@
"title": "About Remove Annotations",
"description": "This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents."
},
"tooltip": {
"header": {
"title": "About Remove Annotations"
},
"description": {
"title": "What it does"
}
},
"error": {
"failed": "An error occurred while removing annotations from the PDF."
}
@@ -2834,6 +2849,9 @@
"header": {
"title": "How Auto-Rename Works"
},
"description": {
"title": "What it does"
},
"howItWorks": {
"title": "Smart Renaming",
"text": "Automatically finds the title from your PDF content and uses it as the filename.",
@@ -2841,6 +2859,9 @@
"bullet2": "Creates a clean, valid filename from the detected title",
"bullet3": "Keeps the original name if no suitable title is found"
}
},
"settings": {
"title": "About"
}
},
"adjust-contrast": {
@@ -4840,7 +4861,9 @@
"secureWorkflow": "Security Workflow",
"secureWorkflowDesc": "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorised access. Password is set to 'password' by default.",
"processImages": "Process Images",
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images.",
"prePublishSanitization": "Pre-publish Sanitization",
"prePublishSanitizationDesc": "Sanitization workflow that removes all hidden metadata, JavaScript, embedded files, annotations, and flattens forms to prevent data leakage before publishing PDFs online."
}
},
"colorPicker": {
@@ -4932,6 +4955,14 @@
"addMoreFiles": "Add more files...",
"selectedFiles": "Selected Files",
"submit": "Add Attachments",
"tooltip": {
"header": {
"title": "About Add Attachments"
},
"description": {
"title": "What it does"
}
},
"results": {
"title": "Attachment Results"
},
@@ -5557,6 +5588,24 @@
"starting": "Backend starting up...",
"wait": "Please wait for the backend to finish launching and try again."
},
"encryptedPdfUnlock": {
"unlockPrompt": "Unlock PDF to continue",
"title": "Remove password to continue",
"description": "This PDF is password protected. Enter the password so you can continue working with it.",
"password": {
"label": "PDF password",
"placeholder": "Enter the PDF password"
},
"skip": "Skip for now",
"unlock": "Unlock & Continue",
"incorrectPassword": "Incorrect password",
"missingFile": "The selected file is no longer available.",
"emptyResponse": "Password removal did not produce a file.",
"required": "Enter the password to continue.",
"successTitle": "Password removed",
"successBodyWithName": "Password removed from {{fileName}}",
"successBody": "Password removed successfully."
},
"setup": {
"welcome": "Welcome to Stirling PDF",
"description": "Get started by choosing how you want to use Stirling PDF",
@@ -5673,7 +5722,11 @@
"latestVersion": "Latest Version",
"checkForUpdates": "Check for Updates",
"viewDetails": "View Details"
}
},
"hideUnavailableTools": "Hide unavailable tools",
"hideUnavailableToolsDescription": "Remove tools that have been disabled by your server instead of showing them greyed out.",
"hideUnavailableConversions": "Hide unavailable conversions",
"hideUnavailableConversionsDescription": "Remove disabled conversion options in the Convert tool instead of showing them greyed out."
},
"hotkeys": {
"errorConflict": "Shortcut already used by {{tool}}.",
+5
View File
@@ -51,6 +51,11 @@
"desktopTemplate": "stirling-pdf.desktop"
}
},
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": "http://timestamp.digicert.com"
},
"macOS": {
"minimumSystemVersion": "10.15",
"signingIdentity": null,
@@ -9,6 +9,7 @@ import VisibilityIcon from '@mui/icons-material/Visibility';
import UnarchiveIcon from '@mui/icons-material/Unarchive';
import PushPinIcon from '@mui/icons-material/PushPin';
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
import LockOpenIcon from '@mui/icons-material/LockOpen';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { StirlingFileStub } from '@app/types/fileContext';
@@ -56,7 +57,14 @@ const FileEditorThumbnail = ({
isSupported = true,
}: FileEditorThumbnailProps) => {
const { t } = useTranslation();
const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions } = useFileContext();
const {
pinFile,
unpinFile,
isFilePinned,
activeFiles,
actions: fileActions,
openEncryptedUnlockPrompt,
} = useFileContext();
const { state } = useFileState();
const hasError = state.ui.errorFileIds.includes(file.id);
@@ -77,6 +85,7 @@ const FileEditorThumbnail = ({
const isZipFile = zipFileService.isZipFileStub(file);
const pageCount = file.processedFile?.totalPages || 0;
const isEncrypted = Boolean(file.processedFile?.isEncrypted);
const handleRef = useRef<HTMLSpanElement | null>(null);
@@ -301,6 +310,21 @@ const FileEditorThumbnail = ({
{/* Action buttons group */}
<div className={styles.headerActions}>
{isEncrypted && (
<Tooltip label={t('encryptedPdfUnlock.unlockPrompt', 'Unlock PDF to continue')}>
<ActionIcon
aria-label={t('encryptedPdfUnlock.unlockPrompt', 'Unlock PDF to continue')}
variant="subtle"
className={styles.headerIconButton}
onClick={(e) => {
e.stopPropagation();
openEncryptedUnlockPrompt(file.id);
}}
>
<LockOpenIcon fontSize="small" />
</ActionIcon>
</Tooltip>
)}
{/* Pin/Unpin icon */}
<Tooltip label={isPinned ? t('unpin', 'Unpin File (replace after tool run)') : t('pin', 'Pin File (keep active after tool run)')}>
<ActionIcon
@@ -0,0 +1,85 @@
import { Modal, Stack, Text, Button, PasswordInput, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { type KeyboardEventHandler } from 'react';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
interface EncryptedPdfUnlockModalProps {
opened: boolean;
fileName?: string;
password: string;
errorMessage?: string | null;
isProcessing: boolean;
onPasswordChange: (value: string) => void;
onUnlock: () => void;
onSkip: () => void;
}
const EncryptedPdfUnlockModal = ({
opened,
fileName,
password,
errorMessage,
isProcessing,
onPasswordChange,
onUnlock,
onSkip,
}: EncryptedPdfUnlockModalProps) => {
const { t } = useTranslation();
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
if (event.key === 'Enter' && !isProcessing && password.trim().length > 0) {
onUnlock();
}
};
return (
<Modal
opened={opened}
onClose={onSkip}
title={t('encryptedPdfUnlock.title', 'Remove password to continue')}
centered
size="md"
closeOnClickOutside={!isProcessing}
closeOnEscape={!isProcessing}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
>
<Stack gap="md">
<Text fw={600} ta="center">{fileName}</Text>
<Text c="dimmed" ta="center">
{t(
'encryptedPdfUnlock.description',
'This PDF is password protected. Enter the password so you can continue working with it.'
)}
</Text>
<Stack gap={4}>
<PasswordInput
label={t('encryptedPdfUnlock.password.label', 'PDF password')}
placeholder={t('encryptedPdfUnlock.password.placeholder', 'Enter the PDF password')}
value={password}
onChange={(event) => onPasswordChange(event.currentTarget.value)}
onKeyDown={handleKeyDown}
disabled={isProcessing}
autoFocus
/>
{errorMessage ? (
<Text c="red" size="sm">
{errorMessage}
</Text>
) : null}
</Stack>
<Group justify="space-between">
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={onSkip} disabled={isProcessing}>
{t('encryptedPdfUnlock.skip', 'Skip for now')}
</Button>
<Button onClick={onUnlock} loading={isProcessing} disabled={password.trim().length === 0}>
{t('encryptedPdfUnlock.unlock', 'Unlock & Continue')}
</Button>
</Group>
</Stack>
</Modal>
);
};
export default EncryptedPdfUnlockModal;
@@ -53,11 +53,17 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
};
const summary = await updateService.getUpdateSummary(config.appVersion, machineInfo);
if (summary) {
if (summary && summary.latest_version) {
const isNewerVersion = updateService.compareVersions(summary.latest_version, config.appVersion) > 0;
if (isNewerVersion) {
setUpdateSummary(summary);
} else {
// Clear any existing update summary if user is on latest version
setUpdateSummary(null);
}
} else {
// No update available (latest_version is null) - clear any existing update summary
setUpdateSummary(null);
}
setCheckingUpdate(false);
};
@@ -128,83 +134,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
</Paper>
)}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.defaultToolPickerMode', 'Default tool picker mode')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.defaultToolPickerModeDescription', 'Choose whether the tool picker opens in fullscreen or sidebar by default')}
</Text>
</div>
<SegmentedControl
value={preferences.defaultToolPanelMode}
onChange={(val: string) => updatePreference('defaultToolPanelMode', val as ToolPanelMode)}
data={[
{ label: t('settings.general.mode.sidebar', 'Sidebar'), value: 'sidebar' },
{ label: t('settings.general.mode.fullscreen', 'Fullscreen'), value: 'fullscreen' },
]}
/>
</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
w={300}
withArrow
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.autoUnzip', 'Auto-unzip API responses')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.autoUnzipDescription', 'Automatically extract files from ZIP responses')}
</Text>
</div>
<Switch
checked={preferences.autoUnzip}
onChange={(event) => updatePreference('autoUnzip', event.currentTarget.checked)}
/>
</div>
</Tooltip>
<Tooltip
label={t('settings.general.autoUnzipFileLimitTooltip', 'Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.')}
multiline
w={300}
withArrow
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.autoUnzipFileLimit', 'Auto-unzip file limit')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.autoUnzipFileLimitDescription', 'Maximum number of files to extract from ZIP')}
</Text>
</div>
<NumberInput
value={fileLimitInput}
onChange={setFileLimitInput}
onBlur={() => {
const numValue = Number(fileLimitInput);
const finalValue = (!fileLimitInput || isNaN(numValue) || numValue < 1 || numValue > 100) ? DEFAULT_AUTO_UNZIP_FILE_LIMIT : numValue;
setFileLimitInput(finalValue);
updatePreference('autoUnzipFileLimit', finalValue);
}}
min={1}
max={100}
step={1}
disabled={!preferences.autoUnzip}
style={{ width: 90 }}
/>
</div>
</Tooltip>
</Stack>
</Paper>
{/* Update Check Section */}
{config?.appVersion && (
<Paper withBorder p="md" radius="md">
@@ -292,6 +221,111 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
</Paper>
)}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.defaultToolPickerMode', 'Default tool picker mode')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.defaultToolPickerModeDescription', 'Choose whether the tool picker opens in fullscreen or sidebar by default')}
</Text>
</div>
<SegmentedControl
value={preferences.defaultToolPanelMode}
onChange={(val: string) => updatePreference('defaultToolPanelMode', val as ToolPanelMode)}
data={[
{ label: t('settings.general.mode.sidebar', 'Sidebar'), value: 'sidebar' },
{ label: t('settings.general.mode.fullscreen', 'Fullscreen'), value: 'fullscreen' },
]}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.hideUnavailableTools', 'Hide unavailable tools')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.hideUnavailableToolsDescription', 'Remove tools that have been disabled by your server instead of showing them greyed out.')}
</Text>
</div>
<Switch
checked={preferences.hideUnavailableTools}
onChange={(event) => updatePreference('hideUnavailableTools', event.currentTarget.checked)}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.hideUnavailableConversions', 'Hide unavailable conversions')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.hideUnavailableConversionsDescription', 'Remove disabled conversion options in the Convert tool instead of showing them greyed out.')}
</Text>
</div>
<Switch
checked={preferences.hideUnavailableConversions}
onChange={(event) => updatePreference('hideUnavailableConversions', 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
w={300}
withArrow
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.autoUnzip', 'Auto-unzip API responses')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.autoUnzipDescription', 'Automatically extract files from ZIP responses')}
</Text>
</div>
<Switch
checked={preferences.autoUnzip}
onChange={(event) => updatePreference('autoUnzip', event.currentTarget.checked)}
/>
</div>
</Tooltip>
<Tooltip
label={t('settings.general.autoUnzipFileLimitTooltip', 'Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.')}
multiline
w={300}
withArrow
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.autoUnzipFileLimit', 'Auto-unzip file limit')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.autoUnzipFileLimitDescription', 'Maximum number of files to extract from ZIP')}
</Text>
</div>
<NumberInput
value={fileLimitInput}
onChange={setFileLimitInput}
onBlur={() => {
const numValue = Number(fileLimitInput);
const finalValue = (!fileLimitInput || isNaN(numValue) || numValue < 1 || numValue > 100) ? DEFAULT_AUTO_UNZIP_FILE_LIMIT : numValue;
setFileLimitInput(finalValue);
updatePreference('autoUnzipFileLimit', finalValue);
}}
min={1}
max={100}
step={1}
disabled={!preferences.autoUnzip}
style={{ width: 90 }}
/>
</div>
</Tooltip>
</Stack>
</Paper>
{/* Update Modal */}
{updateSummary && config?.appVersion && config?.machineType && (
<UpdateModal
@@ -4,7 +4,7 @@
* Allows selecting files to attach to PDFs.
*/
import { Stack, Text, Group, ActionIcon, Alert, ScrollArea, Button } from "@mantine/core";
import { Stack, Text, Group, ActionIcon, ScrollArea, Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { AddAttachmentsParameters } from "@app/hooks/tools/addAttachments/useAddAttachmentsParameters";
import LocalIcon from "@app/components/shared/LocalIcon";
@@ -20,16 +20,7 @@ const AddAttachmentsSettings = ({ parameters, onParameterChange, disabled = fals
return (
<Stack gap="md">
<Alert color="blue" variant="light">
<Text size="sm">
{t("AddAttachmentsRequest.info", "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.")}
</Text>
</Alert>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t("AddAttachmentsRequest.selectFiles", "Select Files to Attach")}
</Text>
<input
type="file"
multiple
@@ -8,6 +8,7 @@ import { getConversionEndpoints } from "@app/data/toolsTaxonomy";
import { useFileSelection } from "@app/contexts/FileContext";
import { useFileState } from "@app/contexts/FileContext";
import { detectFileExtension } from "@app/utils/fileUtils";
import { usePreferences } from "@app/contexts/PreferencesContext";
import GroupedFormatDropdown from "@app/components/tools/convert/GroupedFormatDropdown";
import ConvertToImageSettings from "@app/components/tools/convert/ConvertToImageSettings";
import ConvertFromImageSettings from "@app/components/tools/convert/ConvertFromImageSettings";
@@ -47,8 +48,12 @@ const ConvertSettings = ({
const { setSelectedFiles } = useFileSelection();
const { state, selectors } = useFileState();
const activeFiles = state.files.ids;
const { preferences } = usePreferences();
const allEndpoints = useMemo(() => getConversionEndpoints(EXTENSION_TO_ENDPOINT), []);
const allEndpoints = useMemo(() => {
const endpoints = getConversionEndpoints(EXTENSION_TO_ENDPOINT);
return endpoints;
}, []);
const { endpointStatus } = useMultipleEndpointsEnabled(allEndpoints);
@@ -56,7 +61,8 @@ const ConvertSettings = ({
const endpointKey = EXTENSION_TO_ENDPOINT[fromExt]?.[toExt];
if (!endpointKey) return false;
return endpointStatus[endpointKey] === true;
const isAvailable = endpointStatus[endpointKey] === true;
return isAvailable;
};
// Enhanced FROM options with endpoint availability
@@ -74,6 +80,12 @@ const ConvertSettings = ({
};
});
// Filter out unavailable source formats if preference is enabled
let filteredOptions = baseOptions;
if (preferences.hideUnavailableConversions) {
filteredOptions = baseOptions.filter(opt => opt.enabled !== false);
}
// Add dynamic format option if current selection is a file-<extension> format
if (parameters.fromExtension && parameters.fromExtension.startsWith('file-')) {
const extension = parameters.fromExtension.replace('file-', '');
@@ -85,22 +97,32 @@ const ConvertSettings = ({
};
// Add the dynamic option at the beginning
return [dynamicOption, ...baseOptions];
return [dynamicOption, ...filteredOptions];
}
return baseOptions;
}, [parameters.fromExtension, endpointStatus]);
return filteredOptions;
}, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions]);
// Enhanced TO options with endpoint availability
const enhancedToOptions = useMemo(() => {
if (!parameters.fromExtension) return [];
const availableOptions = getAvailableToExtensions(parameters.fromExtension) || [];
return availableOptions.map(option => ({
...option,
enabled: isConversionAvailable(parameters.fromExtension, option.value)
}));
}, [parameters.fromExtension, endpointStatus]);
const enhanced = availableOptions.map(option => {
const enabled = isConversionAvailable(parameters.fromExtension, option.value);
return {
...option,
enabled
};
});
// Filter out unavailable conversions if preference is enabled
if (preferences.hideUnavailableConversions) {
return enhanced.filter(opt => opt.enabled !== false);
}
return enhanced;
}, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions]);
const resetParametersToDefaults = () => {
onParameterChange('imageOptions', {
@@ -5,7 +5,7 @@ import { Tooltip } from '@app/components/shared/Tooltip';
import HotkeyDisplay from '@app/components/hotkeys/HotkeyDisplay';
import FavoriteStar from '@app/components/tools/toolPicker/FavoriteStar';
import { ToolRegistryEntry, getSubcategoryColor } from '@app/data/toolsTaxonomy';
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta } from '@app/components/tools/fullscreen/shared';
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta, getDisabledLabel } from '@app/components/tools/fullscreen/shared';
interface CompactToolItemProps {
id: string;
@@ -17,7 +17,7 @@ interface CompactToolItemProps {
const CompactToolItem: React.FC<CompactToolItemProps> = ({ id, tool, isSelected, onClick, tooltipPortalTarget }) => {
const { t } = useTranslation();
const { binding, isFav, toggleFavorite, disabled } = useToolMeta(id, tool);
const { binding, isFav, toggleFavorite, disabled, disabledReason } = useToolMeta(id, tool);
const categoryColor = getSubcategoryColor(tool.subcategoryId);
const iconBg = getIconBackground(categoryColor, false);
const iconClasses = 'tool-panel__fullscreen-list-icon';
@@ -73,9 +73,12 @@ const CompactToolItem: React.FC<CompactToolItemProps> = ({ id, tool, isSelected,
</button>
);
const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason);
const disabledMessage = t(disabledKey, disabledFallback);
const tooltipContent = disabled
? (
<span><strong>{t('toolPanel.fullscreen.comingSoon', 'Coming soon:')}</strong> {tool.description}</span>
<span><strong>{disabledMessage}</strong> {tool.description}</span>
)
: (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import HotkeyDisplay from '@app/components/hotkeys/HotkeyDisplay';
import FavoriteStar from '@app/components/tools/toolPicker/FavoriteStar';
import { ToolRegistryEntry, getSubcategoryColor } from '@app/data/toolsTaxonomy';
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta } from '@app/components/tools/fullscreen/shared';
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta, getDisabledLabel } from '@app/components/tools/fullscreen/shared';
interface DetailedToolItemProps {
id: string;
@@ -15,7 +15,7 @@ interface DetailedToolItemProps {
const DetailedToolItem: React.FC<DetailedToolItemProps> = ({ id, tool, isSelected, onClick }) => {
const { t } = useTranslation();
const { binding, isFav, toggleFavorite, disabled } = useToolMeta(id, tool);
const { binding, isFav, toggleFavorite, disabled, disabledReason } = useToolMeta(id, tool);
const categoryColor = getSubcategoryColor(tool.subcategoryId);
const iconBg = getIconBackground(categoryColor, true);
@@ -34,6 +34,9 @@ const DetailedToolItem: React.FC<DetailedToolItemProps> = ({ id, tool, isSelecte
iconNode = tool.icon;
}
const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason);
const disabledMessage = t(disabledKey, disabledFallback);
return (
<button
type="button"
@@ -60,7 +63,12 @@ const DetailedToolItem: React.FC<DetailedToolItemProps> = ({ id, tool, isSelecte
{tool.name}
</Text>
<Text size="sm" c="dimmed" className="tool-panel__fullscreen-description">
{tool.description}
{disabled ? (
<>
<strong>{disabledMessage} </strong>
{tool.description}
</>
) : tool.description}
</Text>
{binding && (
<div className="tool-panel__fullscreen-shortcut">
@@ -2,6 +2,7 @@ import { useHotkeys } from '@app/contexts/HotkeyContext';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { ToolRegistryEntry } from '@app/data/toolsTaxonomy';
import { ToolId } from '@app/types/toolId';
import type { ToolAvailabilityMap } from '@app/hooks/useToolManagement';
export const getItemClasses = (isDetailed: boolean): string => {
return isDetailed ? 'tool-panel__fullscreen-item--detailed' : '';
@@ -22,23 +23,67 @@ export const getIconStyle = (): Record<string, string> => {
return {};
};
export const isToolDisabled = (id: string, tool: ToolRegistryEntry): boolean => {
return !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
export type ToolDisabledReason = 'comingSoon' | 'disabledByAdmin' | 'missingDependency' | 'unknownUnavailable' | null;
export const getToolDisabledReason = (
id: string,
tool: ToolRegistryEntry,
toolAvailability?: ToolAvailabilityMap
): ToolDisabledReason => {
if (!tool.component && !tool.link && id !== 'read' && id !== 'multiTool') {
return 'comingSoon';
}
const availabilityInfo = toolAvailability?.[id as ToolId];
if (availabilityInfo && availabilityInfo.available === false) {
if (availabilityInfo.reason === 'missingDependency') {
return 'missingDependency';
}
if (availabilityInfo.reason === 'disabledByAdmin') {
return 'disabledByAdmin';
}
return 'unknownUnavailable';
}
return null;
};
export const getDisabledLabel = (
disabledReason: ToolDisabledReason
): { key: string; fallback: string } => {
if (disabledReason === 'missingDependency') {
return {
key: 'toolPanel.fullscreen.unavailableDependency',
fallback: 'Unavailable - required tool missing on server:'
};
}
if (disabledReason === 'disabledByAdmin' || disabledReason === 'unknownUnavailable') {
return {
key: 'toolPanel.fullscreen.unavailable',
fallback: 'Disabled by server administrator:'
};
}
return {
key: 'toolPanel.fullscreen.comingSoon',
fallback: 'Coming soon:'
};
};
export function useToolMeta(id: string, tool: ToolRegistryEntry) {
const { hotkeys } = useHotkeys();
const { isFavorite, toggleFavorite } = useToolWorkflow();
const { isFavorite, toggleFavorite, toolAvailability } = useToolWorkflow();
const isFav = isFavorite(id as ToolId);
const binding = hotkeys[id as ToolId];
const disabled = isToolDisabled(id, tool);
const disabledReason = getToolDisabledReason(id, tool, toolAvailability);
const disabled = disabledReason !== null;
return {
binding,
isFav,
toggleFavorite: () => toggleFavorite(id as ToolId),
disabled,
disabledReason,
};
}
@@ -1,24 +1,9 @@
import { useTranslation } from 'react-i18next';
import { Stack, Text, Alert } from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { Stack } from '@mantine/core';
const RemoveAnnotationsSettings = () => {
const { t } = useTranslation();
return (
<Stack gap="md">
<Alert
icon={<LocalIcon icon="info-rounded" width="1.2rem" height="1.2rem" />}
title={t('removeAnnotations.info.title', 'About Remove Annotations')}
color="blue"
variant="light"
>
<Text size="sm">
{t('removeAnnotations.info.description',
'This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents.'
)}
</Text>
</Alert>
{/* No settings needed for this tool - description is in tooltip */}
</Stack>
);
};
@@ -12,6 +12,7 @@ import HotkeyDisplay from "@app/components/hotkeys/HotkeyDisplay";
import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { ToolId } from "@app/types/toolId";
import { getToolDisabledReason, getDisabledLabel } from "@app/components/tools/fullscreen/shared";
interface ToolButtonProps {
id: ToolId;
@@ -26,12 +27,12 @@ interface ToolButtonProps {
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym, hasStars = false }) => {
const { t } = useTranslation();
// Special case: read and multiTool are navigational tools that are always available
const isUnavailable = !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
const { isFavorite, toggleFavorite, toolAvailability } = useToolWorkflow();
const disabledReason = getToolDisabledReason(id, tool, toolAvailability);
const isUnavailable = disabledReason !== null;
const { hotkeys } = useHotkeys();
const binding = hotkeys[id];
const { getToolNavigation } = useToolNavigation();
const { isFavorite, toggleFavorite } = useToolWorkflow();
const fav = isFavorite(id as ToolId);
const handleClick = (id: ToolId) => {
@@ -48,8 +49,11 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
// Get navigation props for URL support (only if navigation is not disabled)
const navProps = !isUnavailable && !tool.link && !disableNavigation ? getToolNavigation(id, tool) : null;
const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason);
const disabledMessage = t(disabledKey, disabledFallback);
const tooltipContent = isUnavailable
? (<span><strong>Coming soon:</strong> {tool.description}</span>)
? (<span><strong>{disabledMessage}</strong> {tool.description}</span>)
: (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<span>{tool.description}</span>
@@ -0,0 +1,18 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '@app/types/tips';
export const useAddAttachmentsTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("AddAttachmentsRequest.tooltip.header.title", "About Add Attachments")
},
tips: [
{
title: t("AddAttachmentsRequest.tooltip.description.title", "What it does"),
description: t("AddAttachmentsRequest.info", "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel."),
}
]
};
};
@@ -9,6 +9,10 @@ export const useAutoRenameTips = (): TooltipContent => {
title: t("auto-rename.tooltip.header.title", "How Auto-Rename Works")
},
tips: [
{
title: t("auto-rename.tooltip.description.title", "What it does"),
description: t("auto-rename.description", "Automatically finds the title from your PDF content and uses it as the filename."),
},
{
title: t("auto-rename.tooltip.howItWorks.title", "Smart Renaming"),
bullets: [
@@ -5,6 +5,9 @@ export const useMergeTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t('merge.tooltip.header.title', 'Merge Settings Overview')
},
tips: [
{
title: t('merge.removeDigitalSignature.tooltip.title', 'Remove Digital Signature'),
@@ -0,0 +1,20 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '@app/types/tips';
export const useRemoveAnnotationsTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("removeAnnotations.tooltip.header.title", "About Remove Annotations")
},
tips: [
{
title: t("removeAnnotations.tooltip.description.title", "What it does"),
description: t('removeAnnotations.info.description',
'This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents.'
),
}
]
};
};
@@ -0,0 +1,288 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { waitFor, renderHook, act } from '@testing-library/react';
import { AppConfigProvider, useAppConfig } from '@app/contexts/AppConfigContext';
import apiClient from '@app/services/apiClient';
import { ReactNode } from 'react';
// Mock apiClient
vi.mock('@app/services/apiClient');
describe('AppConfigContext', () => {
beforeEach(() => {
vi.clearAllMocks();
// Mock window.location.pathname
Object.defineProperty(window, 'location', {
value: { pathname: '/' },
writable: true,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
const wrapper = ({ children }: { children: ReactNode }) => (
<AppConfigProvider>{children}</AppConfigProvider>
);
it('should fetch and provide app config on non-auth pages', async () => {
const mockConfig = {
enableLogin: false,
appNameNavbar: 'Stirling PDF',
languages: ['en-US', 'en-GB'],
};
vi.mocked(apiClient.get).mockResolvedValueOnce({
status: 200,
data: mockConfig,
} as any);
const { result } = renderHook(() => useAppConfig(), { wrapper });
// Initially loading
expect(result.current.loading).toBe(true);
expect(result.current.config).toBeNull();
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.config).toEqual(mockConfig);
expect(result.current.error).toBeNull();
});
expect(apiClient.get).toHaveBeenCalledWith('/api/v1/config/app-config', {
suppressErrorToast: true,
});
});
it('should skip fetch on auth pages and use default config', async () => {
// Mock being on login page
Object.defineProperty(window, 'location', {
value: { pathname: '/login' },
writable: true,
});
const { result } = renderHook(() => useAppConfig(), { wrapper });
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.config).toEqual({ enableLogin: true });
});
// Should NOT call API on auth pages
expect(apiClient.get).not.toHaveBeenCalled();
});
it('should handle 401 error gracefully', async () => {
const mockError = Object.assign(new Error('Unauthorized'), {
response: { status: 401, data: {} },
});
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useAppConfig(), { wrapper });
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.config).toEqual({ enableLogin: true });
// 401 should be handled gracefully, error may be null or set
});
});
it('should handle network errors', async () => {
const errorMessage = 'Network error occurred';
const mockError = new Error(errorMessage);
// Network errors don't have response property
// Mock rejection for all retry attempts (default is 3 attempts)
vi.mocked(apiClient.get)
.mockRejectedValueOnce(mockError)
.mockRejectedValueOnce(mockError)
.mockRejectedValueOnce(mockError);
const { result } = renderHook(() => useAppConfig(), { wrapper });
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.config).toEqual({ enableLogin: true });
expect(result.current.error).toBe(errorMessage);
});
});
it('should skip fetch on signup page', async () => {
Object.defineProperty(window, 'location', {
value: { pathname: '/signup' },
writable: true,
});
const { result } = renderHook(() => useAppConfig(), { wrapper });
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.config).toEqual({ enableLogin: true });
});
expect(apiClient.get).not.toHaveBeenCalled();
});
it('should skip fetch on auth callback page', async () => {
Object.defineProperty(window, 'location', {
value: { pathname: '/auth/callback' },
writable: true,
});
const { result } = renderHook(() => useAppConfig(), { wrapper });
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.config).toEqual({ enableLogin: true });
});
expect(apiClient.get).not.toHaveBeenCalled();
});
it('should skip fetch on invite accept page', async () => {
Object.defineProperty(window, 'location', {
value: { pathname: '/invite/abc123' },
writable: true,
});
const { result } = renderHook(() => useAppConfig(), { wrapper });
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.config).toEqual({ enableLogin: true });
});
expect(apiClient.get).not.toHaveBeenCalled();
});
it('should refetch config when jwt-available event is triggered', async () => {
const initialConfig = {
enableLogin: true,
appNameNavbar: 'Stirling PDF',
};
const updatedConfig = {
enableLogin: true,
appNameNavbar: 'Stirling PDF',
isAdmin: true,
enableAnalytics: true,
};
// First call returns initial config
vi.mocked(apiClient.get).mockResolvedValueOnce({
status: 200,
data: initialConfig,
} as any);
const { result } = renderHook(() => useAppConfig(), { wrapper });
await waitFor(() => {
expect(result.current.config).toEqual(initialConfig);
});
// Setup second call for refetch
vi.mocked(apiClient.get).mockResolvedValueOnce({
status: 200,
data: updatedConfig,
} as any);
// Trigger jwt-available event wrapped in act
await act(async () => {
window.dispatchEvent(new CustomEvent('jwt-available'));
// Wait a tick for event handler to run
await new Promise((resolve) => setTimeout(resolve, 0));
});
await waitFor(() => {
expect(result.current.config).toEqual(updatedConfig);
});
expect(apiClient.get).toHaveBeenCalledTimes(2);
});
it('should provide refetch function', async () => {
const mockConfig = {
enableLogin: false,
appNameNavbar: 'Test App',
};
vi.mocked(apiClient.get).mockResolvedValue({
status: 200,
data: mockConfig,
} as any);
const { result } = renderHook(() => useAppConfig(), { wrapper });
await waitFor(() => {
expect(result.current.config).toEqual(mockConfig);
});
// Call refetch wrapped in act
await act(async () => {
await result.current.refetch();
});
expect(apiClient.get).toHaveBeenCalledTimes(2);
});
it('should not fetch twice without force flag', async () => {
const mockConfig = {
enableLogin: false,
};
vi.mocked(apiClient.get).mockResolvedValue({
status: 200,
data: mockConfig,
} as any);
const { result } = renderHook(() => useAppConfig(), { wrapper });
await waitFor(() => {
expect(result.current.config).toEqual(mockConfig);
});
// Should only be called once (no duplicate fetches)
expect(apiClient.get).toHaveBeenCalledTimes(1);
});
it('should handle initial config prop', async () => {
const initialConfig = {
enableLogin: false,
appNameNavbar: 'Initial App',
};
const customWrapper = ({ children }: { children: ReactNode }) => (
<AppConfigProvider initialConfig={initialConfig}>
{children}
</AppConfigProvider>
);
const { result } = renderHook(() => useAppConfig(), {
wrapper: customWrapper,
});
// With blocking mode (default), should still fetch even with initial config
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
// Should still make API call
expect(apiClient.get).toHaveBeenCalled();
});
it('should use suppressErrorToast for all config requests', async () => {
const mockConfig = { enableLogin: true };
vi.mocked(apiClient.get).mockResolvedValueOnce({
status: 200,
data: mockConfig,
} as any);
renderHook(() => useAppConfig(), { wrapper });
await waitFor(() => {
expect(apiClient.get).toHaveBeenCalledWith('/api/v1/config/app-config', {
suppressErrorToast: true,
});
});
});
});
@@ -114,7 +114,8 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
}
// apiClient automatically adds JWT header if available via interceptors
const response = await apiClient.get<AppConfig>('/api/v1/config/app-config', !isBlockingMode ? { suppressErrorToast: true } : undefined);
// Always suppress error toast - we handle 401 errors locally
const response = await apiClient.get<AppConfig>('/api/v1/config/app-config', { suppressErrorToast: true });
const data = response.data;
console.debug('[AppConfig] Config fetched successfully:', data);
@@ -159,8 +160,25 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
}, [fetchCount, hasResolvedConfig, isBlockingMode, maxRetries, initialDelay]);
useEffect(() => {
// Always try to fetch config to check if login is disabled
// The endpoint should be public and return proper JSON
// Skip config fetch on auth pages (/login, /signup, /auth/callback, /invite/*)
// Config will be fetched after successful authentication via jwt-available event
const currentPath = window.location.pathname;
const isAuthPage = currentPath.includes('/login') ||
currentPath.includes('/signup') ||
currentPath.includes('/auth/callback') ||
currentPath.includes('/invite/');
// On auth pages, always skip the config fetch
// The config will be fetched after authentication via jwt-available event
if (isAuthPage) {
console.debug('[AppConfig] On auth page - using default config, skipping fetch');
setConfig({ enableLogin: true });
setHasResolvedConfig(true);
setLoading(false);
return;
}
// On non-auth pages, fetch config (will validate JWT if present)
if (autoFetch) {
fetchConfig();
}
+195 -4
View File
@@ -12,7 +12,7 @@
* Memory management handled by FileLifecycleManager (PDF.js cleanup, blob URL revocation).
*/
import { useReducer, useCallback, useEffect, useRef, useMemo } from 'react';
import { useReducer, useCallback, useEffect, useRef, useMemo, useState } from 'react';
import {
FileContextProviderProps,
FileContextSelectors,
@@ -22,17 +22,27 @@ import {
FileId,
StirlingFileStub,
StirlingFile,
createStirlingFile,
} from '@app/types/fileContext';
// Import modular components
import { fileContextReducer, initialFileContextState } from '@app/contexts/file/FileReducer';
import { createFileSelectors } from '@app/contexts/file/fileSelectors';
import { addFiles, addStirlingFileStubs, consumeFiles, undoConsumeFiles, createFileActions } from '@app/contexts/file/fileActions';
import { addFiles, addStirlingFileStubs, consumeFiles, undoConsumeFiles, createFileActions, createChildStub, generateProcessedFileMetadata } from '@app/contexts/file/fileActions';
import { FileLifecycleManager } from '@app/contexts/file/lifecycle';
import { FileStateContext, FileActionsContext } from '@app/contexts/file/contexts';
import { IndexedDBProvider, useIndexedDB } from '@app/contexts/IndexedDBContext';
import { useZipConfirmation } from '@app/hooks/useZipConfirmation';
import ZipWarningModal from '@app/components/shared/ZipWarningModal';
import EncryptedPdfUnlockModal from '@app/components/shared/EncryptedPdfUnlockModal';
import { useTranslation } from 'react-i18next';
import { alert } from '@app/components/toast';
import { buildRemovePasswordFormData } from '@app/hooks/tools/removePassword/buildRemovePasswordFormData';
import type { RemovePasswordParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters';
import apiClient from '@app/services/apiClient';
import { processResponse } from '@app/utils/toolResponseProcessor';
import { ToolOperation } from '@app/types/file';
import { handlePasswordError } from '@app/utils/toolErrorHandler';
const DEBUG = process.env.NODE_ENV === 'development';
@@ -63,6 +73,98 @@ function FileContextInner({
lifecycleManagerRef.current = new FileLifecycleManager(filesRef, dispatch);
}
const lifecycleManager = lifecycleManagerRef.current;
const { t } = useTranslation();
const [encryptedQueue, setEncryptedQueue] = useState<FileId[]>([]);
const [activeEncryptedFileId, setActiveEncryptedFileId] = useState<FileId | null>(null);
const [unlockPassword, setUnlockPassword] = useState('');
const [unlockError, setUnlockError] = useState<string | null>(null);
const [isUnlocking, setIsUnlocking] = useState(false);
const dismissedEncryptedFilesRef = useRef<Set<FileId>>(new Set());
const observedFileIdsRef = useRef<Set<FileId>>(new Set());
const enqueueEncryptedFiles = useCallback((fileIds: FileId[]) => {
if (fileIds.length === 0) return;
setEncryptedQueue(prevQueue => {
const existing = new Set(prevQueue);
const next = [...prevQueue];
for (const id of fileIds) {
if (dismissedEncryptedFilesRef.current.has(id)) continue;
if (id === activeEncryptedFileId) continue;
if (existing.has(id)) continue;
existing.add(id);
next.push(id);
}
return next;
});
}, [activeEncryptedFileId]);
useEffect(() => {
const previousIds = observedFileIdsRef.current;
const nextIds = new Set(state.files.ids);
const newEncryptedIds: FileId[] = [];
for (const id of state.files.ids) {
if (!previousIds.has(id)) {
const stub = state.files.byId[id];
if ((stub?.versionNumber ?? 1) <= 1 && stub?.processedFile?.isEncrypted) {
newEncryptedIds.push(id);
}
}
}
if (newEncryptedIds.length > 0) {
enqueueEncryptedFiles(newEncryptedIds);
}
observedFileIdsRef.current = nextIds;
}, [state.files.ids, state.files.byId, enqueueEncryptedFiles]);
useEffect(() => {
if (!activeEncryptedFileId && encryptedQueue.length > 0) {
setActiveEncryptedFileId(encryptedQueue[0]);
setEncryptedQueue(prev => prev.slice(1));
}
}, [activeEncryptedFileId, encryptedQueue]);
useEffect(() => {
if (activeEncryptedFileId && !state.files.ids.includes(activeEncryptedFileId)) {
setActiveEncryptedFileId(null);
}
}, [activeEncryptedFileId, state.files.ids]);
useEffect(() => {
setUnlockPassword('');
setUnlockError(null);
}, [activeEncryptedFileId]);
const handleUnlockSkip = useCallback(() => {
if (activeEncryptedFileId) {
dismissedEncryptedFilesRef.current.add(activeEncryptedFileId);
}
setActiveEncryptedFileId(null);
}, [activeEncryptedFileId]);
const promptEncryptedUnlock = useCallback((fileId: FileId) => {
const stub = stateRef.current.files.byId[fileId];
if (!stub?.processedFile?.isEncrypted) {
return;
}
dismissedEncryptedFilesRef.current.delete(fileId);
setEncryptedQueue(prevQueue => prevQueue.filter(id => id !== fileId));
setActiveEncryptedFileId(currentActiveId => {
if (currentActiveId && currentActiveId !== fileId) {
setEncryptedQueue(prevQueue => {
const withoutDuplicates = prevQueue.filter(id => id !== currentActiveId && id !== fileId);
return [currentActiveId, ...withoutDuplicates];
});
}
return fileId;
});
}, []);
// Create stable selectors (memoized once to avoid re-renders)
const selectors = useMemo<FileContextSelectors>(() =>
@@ -131,6 +233,80 @@ function FileContextInner({
return consumeFiles(inputFileIds, outputStirlingFiles, outputStirlingFileStubs, filesRef, dispatch);
}, []);
const runAutomaticPasswordRemoval = useCallback(async (fileId: FileId, password: string): Promise<void> => {
const file = filesRef.current.get(fileId);
const parentStub = stateRef.current.files.byId[fileId];
if (!file || !parentStub) {
throw new Error(t('encryptedPdfUnlock.missingFile', 'The selected file is no longer available.'));
}
const params: RemovePasswordParameters = { password };
const formData = buildRemovePasswordFormData(params, file);
const response = await apiClient.post('/api/v1/security/remove-password', formData, {
responseType: 'blob',
suppressErrorToast: true // Handle errors in modal UI instead of toast
});
const responseFiles = await processResponse(response.data, [file]);
const unlockedFile = responseFiles[0];
if (!unlockedFile) {
throw new Error(t('encryptedPdfUnlock.emptyResponse', 'Password removal did not produce a file.'));
}
const processedMetadata = await generateProcessedFileMetadata(unlockedFile);
const thumbnail = processedMetadata?.thumbnailUrl;
const operation: ToolOperation = {
toolId: 'removePassword',
timestamp: Date.now()
};
const childStub = createChildStub(parentStub, operation, unlockedFile, thumbnail, processedMetadata);
const stirlingUnlockedFile = createStirlingFile(unlockedFile, childStub.id);
await consumeFilesWrapper([fileId], [stirlingUnlockedFile], [childStub]);
}, [consumeFilesWrapper, t]);
const handleUnlockSubmit = useCallback(async () => {
if (!activeEncryptedFileId) return;
if (!unlockPassword.trim()) {
setUnlockError(t('encryptedPdfUnlock.required', 'Enter the password to continue.'));
return;
}
setIsUnlocking(true);
setUnlockError(null);
try {
await runAutomaticPasswordRemoval(activeEncryptedFileId, unlockPassword.trim());
const fileName = stateRef.current.files.byId[activeEncryptedFileId]?.name;
alert({
alertType: 'success',
title: t('encryptedPdfUnlock.successTitle', 'Password removed'),
body: fileName
? t('encryptedPdfUnlock.successBodyWithName', {
defaultValue: 'Removed password from {{fileName}}',
fileName,
})
: t('encryptedPdfUnlock.successBody', 'Password removed successfully.'),
expandable: false,
isPersistentPopup: false,
});
dismissedEncryptedFilesRef.current.delete(activeEncryptedFileId);
setActiveEncryptedFileId(null);
} catch (error) {
const errorMessage = await handlePasswordError(
error,
t('encryptedPdfUnlock.incorrectPassword', 'Incorrect password'),
t('removePassword.error.failed', 'An error occurred while removing the password from the PDF.')
);
setUnlockError(errorMessage);
} finally {
setIsUnlocking(false);
}
}, [activeEncryptedFileId, unlockPassword, runAutomaticPasswordRemoval, t]);
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]): Promise<void> => {
return undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds, filesRef, dispatch, indexedDB);
}, [indexedDB]);
@@ -199,7 +375,8 @@ function FileContextInner({
trackBlobUrl: lifecycleManager.trackBlobUrl,
cleanupFile: (fileId: FileId) => lifecycleManager.cleanupFile(fileId, stateRef),
scheduleCleanup: (fileId: FileId, delay?: number) =>
lifecycleManager.scheduleCleanup(fileId, delay, stateRef)
lifecycleManager.scheduleCleanup(fileId, delay, stateRef),
openEncryptedUnlockPrompt: promptEncryptedUnlock
}), [
baseActions,
addRawFiles,
@@ -211,7 +388,8 @@ function FileContextInner({
pinFileWrapper,
unpinFileWrapper,
indexedDB,
enablePersistence
enablePersistence,
promptEncryptedUnlock
]);
// Split context values to minimize re-renders
@@ -225,6 +403,9 @@ function FileContextInner({
dispatch
}), [actions]);
const activeEncryptedStub = activeEncryptedFileId ? state.files.byId[activeEncryptedFileId] : undefined;
const isUnlockModalOpen = Boolean(activeEncryptedFileId && activeEncryptedStub);
// Persistence loading disabled - files only loaded on explicit user action
// useEffect(() => {
// if (!enablePersistence || !indexedDB) return;
@@ -251,6 +432,16 @@ function FileContextInner({
fileCount={confirmationState.fileCount}
zipFileName={confirmationState.fileName}
/>
<EncryptedPdfUnlockModal
opened={isUnlockModalOpen}
fileName={activeEncryptedStub?.name}
password={unlockPassword}
errorMessage={unlockError}
isProcessing={isUnlocking}
onPasswordChange={setUnlockPassword}
onUnlock={handleUnlockSubmit}
onSkip={handleUnlockSkip}
/>
</FileActionsContext.Provider>
</FileStateContext.Provider>
);
@@ -4,7 +4,7 @@
*/
import React, { createContext, useContext, useReducer, useCallback, useMemo, useEffect } from 'react';
import { useToolManagement } from '@app/hooks/useToolManagement';
import { useToolManagement, type ToolAvailabilityMap } from '@app/hooks/useToolManagement';
import { PageEditorFunctions } from '@app/types/pageEditor';
import { ToolRegistryEntry, ToolRegistry } from '@app/data/toolsTaxonomy';
import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext';
@@ -44,6 +44,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
selectedTool: ToolRegistryEntry | null;
toolRegistry: Partial<ToolRegistry>;
getSelectedTool: (toolId: ToolId | null) => ToolRegistryEntry | null;
toolAvailability: ToolAvailabilityMap;
// UI Actions
setSidebarsVisible: (visible: boolean) => void;
@@ -112,7 +113,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
const navigationState = useNavigationState();
// Tool management hook
const { toolRegistry, getSelectedTool } = useToolManagement();
const { toolRegistry, getSelectedTool, toolAvailability } = useToolManagement();
const { allTools } = useToolRegistry();
// Tool history hook
@@ -258,6 +259,11 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
// Workflow actions (compound actions that coordinate multiple state changes)
const handleToolSelect = useCallback((toolId: ToolId) => {
const availabilityInfo = toolAvailability[toolId];
const isExplicitlyDisabled = availabilityInfo ? availabilityInfo.available === false : false;
if (toolId !== 'read' && toolId !== 'multiTool' && isExplicitlyDisabled) {
return;
}
// If we're currently on a custom workbench (e.g., Validate Signature report),
// selecting any tool should take the user back to the default file manager view.
const wasInCustomWorkbench = !isBaseWorkbench(navigationState.workbench);
@@ -299,7 +305,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
setSearchQuery('');
setLeftPanelView('toolContent');
setReaderMode(false); // Disable read mode when selecting tools
}, [actions, getSelectedTool, navigationState.workbench, setLeftPanelView, setReaderMode, setSearchQuery]);
}, [actions, getSelectedTool, navigationState.workbench, setLeftPanelView, setReaderMode, setSearchQuery, toolAvailability]);
const handleBackToTools = useCallback(() => {
setLeftPanelView('toolPicker');
@@ -354,6 +360,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
toolResetFunctions,
registerToolReset,
resetTool,
toolAvailability,
// Workflow Actions
handleToolSelect,
@@ -381,6 +388,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
selectedTool,
toolRegistry,
getSelectedTool,
toolAvailability,
setSidebarsVisible,
setLeftPanelView,
setReaderMode,
@@ -63,7 +63,7 @@ export function createProcessedFile(
thumbnail?: string,
pageRotations?: number[],
pageDimensions?: Array<{ width: number; height: number }>
) {
): ProcessedFileMetadata {
return {
totalPages: pageCount,
pages: Array.from({ length: pageCount }, (_, index) => ({
@@ -106,6 +106,10 @@ export async function generateProcessedFileMetadata(file: File): Promise<Process
// Use rotated thumbnail for file manager
processedFile.thumbnailUrl = rotatedResult.thumbnail;
if (unrotatedResult.isEncrypted || rotatedResult.isEncrypted) {
processedFile.isEncrypted = true;
}
return processedFile;
} catch (error) {
if (DEBUG) console.warn(`📄 Failed to generate processedFileMetadata for ${file.name}:`, error);
@@ -188,6 +188,7 @@ export function useFileContext() {
// Active files
activeFiles: selectors.getFiles(),
openEncryptedUnlockPrompt: actions.openEncryptedUnlockPrompt,
// Direct access to actions and selectors (for advanced use cases)
actions,
@@ -82,6 +82,7 @@ import { adjustPageScaleOperationConfig } from "@app/hooks/tools/adjustPageScale
import { scannerImageSplitOperationConfig } from "@app/hooks/tools/scannerImageSplit/useScannerImageSplitOperation";
import { addPageNumbersOperationConfig } from "@app/components/tools/addPageNumbers/useAddPageNumbersOperation";
import { extractPagesOperationConfig } from "@app/hooks/tools/extractPages/useExtractPagesOperation";
import { ENDPOINTS as SPLIT_ENDPOINT_NAMES } from '@app/constants/splitConstants';
import CompressSettings from "@app/components/tools/compress/CompressSettings";
import AddPasswordSettings from "@app/components/tools/addPassword/AddPasswordSettings";
import RemovePasswordSettings from "@app/components/tools/removePassword/RemovePasswordSettings";
@@ -300,6 +301,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
description: t("home.getPdfInfo.desc", "Grabs any and all information possible on PDFs"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.VERIFICATION,
endpoints: ["get-info-on-pdf"],
synonyms: getSynonyms(t, "getPdfInfo"),
supportsAutomate: false,
automationSettings: null
@@ -398,6 +400,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
description: t("home.split.desc", "Split PDFs into multiple documents"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
endpoints: Array.from(new Set(Object.values(SPLIT_ENDPOINT_NAMES))),
operationConfig: splitOperationConfig,
automationSettings: SplitAutomationSettings,
synonyms: getSynonyms(t, "split")
@@ -465,6 +468,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
description: t("home.bookletImposition.desc", "Create booklets with proper page ordering and multi-page layout for printing and binding"),
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING,
endpoints: ["booklet-imposition"],
},
pdfToSinglePage: {
@@ -559,6 +563,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.REMOVAL,
maxFiles: -1,
endpoints: ["remove-annotations"],
operationConfig: removeAnnotationsOperationConfig,
automationSettings: null,
synonyms: getSynonyms(t, "removeAnnotations")
@@ -597,7 +602,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.REMOVAL,
maxFiles: -1,
endpoints: ["remove-certificate-sign"],
endpoints: ["remove-cert-sign"],
operationConfig: removeCertificateSignOperationConfig,
synonyms: getSynonyms(t, "removeCertSign"),
automationSettings: null,
@@ -626,7 +631,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
name: t("home.autoRename.title", "Auto Rename PDF File"),
component: AutoRename,
maxFiles: -1,
endpoints: ["remove-certificate-sign"],
endpoints: ["auto-rename"],
operationConfig: autoRenameOperationConfig,
description: t("home.autoRename.desc", "Automatically rename PDF files based on their content"),
categoryId: ToolCategoryId.ADVANCED_TOOLS,
@@ -681,6 +686,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
description: t("home.overlay-pdfs.desc", "Overlay one PDF on top of another"),
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
endpoints: ["overlay-pdf"],
operationConfig: overlayPdfsOperationConfig,
synonyms: getSynonyms(t, "overlay-pdfs"),
automationSettings: OverlayPdfsSettings
@@ -705,6 +711,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
description: t("home.addImage.desc", "Add images to PDF documents"),
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
endpoints: ["add-image"],
synonyms: getSynonyms(t, "addImage"),
automationSettings: null
},
@@ -715,6 +722,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
description: t("home.scannerEffect.desc", "Create a PDF that looks like it was scanned"),
categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
endpoints: ["scanner-effect"],
synonyms: getSynonyms(t, "scannerEffect"),
automationSettings: null
},
@@ -805,6 +813,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
endpoints: ["compress-pdf"],
operationConfig: compressOperationConfig,
automationSettings: CompressSettings,
synonyms: getSynonyms(t, "compress")
@@ -848,6 +857,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1,
endpoints: ["ocr-pdf"],
operationConfig: ocrOperationConfig,
automationSettings: OCRSettings,
synonyms: getSynonyms(t, "ocr")
@@ -9,6 +9,7 @@ import { SPLIT_METHODS } from '@app/constants/splitConstants';
const CompressIcon = () => React.createElement(LocalIcon, { icon: 'compress', width: '1.5rem', height: '1.5rem' });
const SecurityIcon = () => React.createElement(LocalIcon, { icon: 'security', width: '1.5rem', height: '1.5rem' });
const StarIcon = () => React.createElement(LocalIcon, { icon: 'star', width: '1.5rem', height: '1.5rem' });
const PrivacyIcon = () => React.createElement(LocalIcon, { icon: 'shield-lock', width: '1.5rem', height: '1.5rem' });
export function useSuggestedAutomations(): SuggestedAutomation[] {
const { t } = useTranslation();
@@ -67,6 +68,63 @@ export function useSuggestedAutomations(): SuggestedAutomation[] {
updatedAt: now,
icon: SecurityIcon,
},
{
id: "pre-publish-sanitization",
name: t("automation.suggested.prePublishSanitization", "Pre-publish Sanitization"),
description: t("automation.suggested.prePublishSanitizationDesc", "Sanitization workflow that removes all hidden metadata, JavaScript, embedded files, annotations, and flattens forms to prevent data leakage before publishing PDFs online."),
operations: [
{
operation: "sanitize",
parameters: {
removeJavaScript: true,
removeEmbeddedFiles: true,
removeXMPMetadata: true,
removeMetadata: true,
removeLinks: true,
removeFonts: false,
}
},
{
operation: "flatten",
parameters: {
flattenOnlyForms: true,
}
},
{
operation: "removeAnnotations",
parameters: {}
},
{
operation: "changeMetadata",
parameters: {
deleteAll: true,
author: '',
creationDate: '',
creator: '',
keywords: '',
modificationDate: '',
producer: '',
subject: '',
title: '',
trapped: '',
}
},
{
operation: "compress",
parameters: {
compressionLevel: 3,
grayscale: false,
expectedSize: '',
compressionMethod: 'quality',
fileSizeValue: '',
fileSizeUnit: 'MB',
}
},
],
createdAt: now,
updatedAt: now,
icon: PrivacyIcon,
},
{
id: "email-preparation",
name: t("automation.suggested.emailPreparation", "Email Preparation"),
@@ -40,13 +40,15 @@ export const buildChangeMetadataFormData = (parameters: ChangeMetadataParameters
// Custom metadata - backend expects them as values to 'allRequestParams[customKeyX/customValueX]'
let keyNumber = 0;
parameters.customMetadata.forEach((entry) => {
if (entry.key.trim() && entry.value.trim()) {
keyNumber += 1;
formData.append(`allRequestParams[customKey${keyNumber}]`, entry.key.trim());
formData.append(`allRequestParams[customValue${keyNumber}]`, entry.value.trim());
}
});
if (parameters.customMetadata && Array.isArray(parameters.customMetadata)) {
parameters.customMetadata.forEach((entry) => {
if (entry.key.trim() && entry.value.trim()) {
keyNumber += 1;
formData.append(`allRequestParams[customKey${keyNumber}]`, entry.key.trim());
formData.append(`allRequestParams[customValue${keyNumber}]`, entry.value.trim());
}
});
}
return formData;
};
@@ -14,6 +14,6 @@ export type RemoveCertificateSignParametersHook = BaseParametersHook<RemoveCerti
export const useRemoveCertificateSignParameters = (): RemoveCertificateSignParametersHook => {
return useBaseParameters({
defaultParameters,
endpointName: 'remove-certificate-sign',
endpointName: 'remove-cert-sign',
});
};
@@ -0,0 +1,12 @@
import { RemovePasswordParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters';
/**
* Builds FormData for remove password API request.
* Separated from operation config to avoid circular dependencies with FileContext.
*/
export const buildRemovePasswordFormData = (parameters: RemovePasswordParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("password", parameters.password);
return formData;
};
@@ -2,14 +2,10 @@ import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { RemovePasswordParameters, defaultParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters';
import { buildRemovePasswordFormData } from '@app/hooks/tools/removePassword/buildRemovePasswordFormData';
// Static function that can be used by both the hook and automation executor
export const buildRemovePasswordFormData = (parameters: RemovePasswordParameters, file: File): FormData => {
const formData = new FormData();
formData.append("fileInput", file);
formData.append("password", parameters.password);
return formData;
};
// Re-export for backwards compatibility with any other imports
export { buildRemovePasswordFormData };
// Static configuration object
export const removePasswordOperationConfig = {
+77 -44
View File
@@ -1,9 +1,10 @@
import { useState, useEffect } from 'react';
import apiClient from '@app/services/apiClient';
import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability';
// Track globally fetched endpoint sets to prevent duplicate fetches across components
const globalFetchedSets = new Set<string>();
const globalEndpointCache: Record<string, boolean> = {};
const globalEndpointCache: Record<string, EndpointAvailabilityDetails> = {};
/**
* Hook to check if a specific endpoint is enabled
@@ -59,11 +60,13 @@ export function useEndpointEnabled(endpoint: string): {
*/
export function useMultipleEndpointsEnabled(endpoints: string[]): {
endpointStatus: Record<string, boolean>;
endpointDetails: Record<string, EndpointAvailabilityDetails>;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
} {
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>({});
const [endpointDetails, setEndpointDetails] = useState<Record<string, EndpointAvailabilityDetails>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -73,31 +76,25 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
// Skip if we already fetched these exact endpoints globally
if (!force && globalFetchedSets.has(endpointsKey)) {
console.debug('[useEndpointConfig] Already fetched these endpoints globally, using cache');
const cachedStatus = endpoints.reduce((acc, endpoint) => {
if (endpoint in globalEndpointCache) {
acc[endpoint] = globalEndpointCache[endpoint];
}
return acc;
}, {} as Record<string, boolean>);
setEndpointStatus(cachedStatus);
const cached = endpoints.reduce(
(acc, endpoint) => {
const cachedDetails = globalEndpointCache[endpoint];
if (cachedDetails) {
acc.status[endpoint] = cachedDetails.enabled;
acc.details[endpoint] = cachedDetails;
}
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
);
setEndpointStatus(cached.status);
setEndpointDetails(prev => ({ ...prev, ...cached.details }));
setLoading(false);
return;
}
if (!endpoints || endpoints.length === 0) {
setEndpointStatus({});
setLoading(false);
return;
}
// Check if JWT exists - if not, optimistically enable all endpoints
const hasJwt = !!localStorage.getItem('stirling_jwt');
if (!hasJwt) {
console.debug('[useEndpointConfig] No JWT found - optimistically enabling all endpoints');
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
acc[endpoint] = true;
return acc;
}, {} as Record<string, boolean>);
setEndpointStatus(optimisticStatus);
setEndpointDetails({});
setLoading(false);
return;
}
@@ -110,11 +107,19 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
const newEndpoints = endpoints.filter(ep => !(ep in globalEndpointCache));
if (newEndpoints.length === 0) {
console.debug('[useEndpointConfig] All endpoints already in global cache');
const cachedStatus = endpoints.reduce((acc, endpoint) => {
acc[endpoint] = globalEndpointCache[endpoint];
return acc;
}, {} as Record<string, boolean>);
setEndpointStatus(cachedStatus);
const cached = endpoints.reduce(
(acc, endpoint) => {
const cachedDetails = globalEndpointCache[endpoint];
if (cachedDetails) {
acc.status[endpoint] = cachedDetails.enabled;
acc.details[endpoint] = cachedDetails;
}
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
);
setEndpointStatus(cached.status);
setEndpointDetails(prev => ({ ...prev, ...cached.details }));
globalFetchedSets.add(endpointsKey);
setLoading(false);
return;
@@ -123,30 +128,51 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
// Use batch API for efficiency - only fetch new endpoints
const endpointsParam = newEndpoints.join(',');
const response = await apiClient.get<Record<string, boolean>>(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`);
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`);
const statusMap = response.data;
// Update global cache with new results
Object.assign(globalEndpointCache, statusMap);
Object.entries(statusMap).forEach(([endpoint, details]) => {
globalEndpointCache[endpoint] = {
enabled: details?.enabled ?? true,
reason: details?.reason ?? null,
};
});
// Get all requested endpoints from cache (including previously cached ones)
const fullStatus = endpoints.reduce((acc, endpoint) => {
acc[endpoint] = globalEndpointCache[endpoint] ?? true; // Default to true if not in cache
return acc;
}, {} as Record<string, boolean>);
const fullStatus = endpoints.reduce(
(acc, endpoint) => {
const cachedDetails = globalEndpointCache[endpoint];
if (cachedDetails) {
acc.status[endpoint] = cachedDetails.enabled;
acc.details[endpoint] = cachedDetails;
} else {
acc.status[endpoint] = true;
}
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
);
setEndpointStatus(fullStatus);
setEndpointStatus(fullStatus.status);
setEndpointDetails(prev => ({ ...prev, ...fullStatus.details }));
globalFetchedSets.add(endpointsKey);
} catch (err: any) {
// On 401 (auth error), use optimistic fallback instead of disabling
if (err.response?.status === 401) {
console.warn('[useEndpointConfig] 401 error - using optimistic fallback');
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
acc[endpoint] = true;
globalEndpointCache[endpoint] = true; // Cache the optimistic value
return acc;
}, {} as Record<string, boolean>);
setEndpointStatus(optimisticStatus);
const optimisticStatus = endpoints.reduce(
(acc, endpoint) => {
const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
acc.status[endpoint] = true;
acc.details[endpoint] = optimisticDetails;
globalEndpointCache[endpoint] = optimisticDetails;
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
);
setEndpointStatus(optimisticStatus.status);
setEndpointDetails(prev => ({ ...prev, ...optimisticStatus.details }));
setLoading(false);
return;
}
@@ -156,11 +182,17 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
console.error('[EndpointConfig] Failed to check multiple endpoints:', err);
// Fallback: assume all endpoints are enabled on error (optimistic)
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
acc[endpoint] = true;
return acc;
}, {} as Record<string, boolean>);
setEndpointStatus(optimisticStatus);
const optimisticStatus = endpoints.reduce(
(acc, endpoint) => {
const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
acc.status[endpoint] = true;
acc.details[endpoint] = optimisticDetails;
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
);
setEndpointStatus(optimisticStatus.status);
setEndpointDetails(prev => ({ ...prev, ...optimisticStatus.details }));
} finally {
setLoading(false);
}
@@ -186,6 +218,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
return {
endpointStatus,
endpointDetails,
loading,
error,
refetch: () => fetchAllEndpointStatuses(true),
+67 -11
View File
@@ -1,9 +1,20 @@
import { useState, useCallback, useMemo } from 'react';
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
import { usePreferences } from '@app/contexts/PreferencesContext';
import { getAllEndpoints, type ToolRegistryEntry, type ToolRegistry } from "@app/data/toolsTaxonomy";
import { useMultipleEndpointsEnabled } from "@app/hooks/useEndpointConfig";
import { FileId } from '@app/types/file';
import { ToolId } from "@app/types/toolId";
import type { EndpointDisableReason } from '@app/types/endpointAvailability';
export type ToolDisableCause = 'disabledByAdmin' | 'missingDependency' | 'unknown';
export interface ToolAvailabilityInfo {
available: boolean;
reason?: ToolDisableCause;
}
export type ToolAvailabilityMap = Partial<Record<ToolId, ToolAvailabilityInfo>>;
interface ToolManagementResult {
selectedTool: ToolRegistryEntry | null;
@@ -11,6 +22,7 @@ interface ToolManagementResult {
toolRegistry: Partial<ToolRegistry>;
setToolSelectedFileIds: (fileIds: FileId[]) => void;
getSelectedTool: (toolKey: ToolId | null) => ToolRegistryEntry | null;
toolAvailability: ToolAvailabilityMap;
}
export const useToolManagement = (): ToolManagementResult => {
@@ -19,9 +31,10 @@ export const useToolManagement = (): ToolManagementResult => {
// Build endpoints list from registry entries with fallback to legacy mapping
const { allTools } = useToolRegistry();
const baseRegistry = allTools;
const { preferences } = usePreferences();
const allEndpoints = useMemo(() => getAllEndpoints(baseRegistry), [baseRegistry]);
const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
const { endpointStatus, endpointDetails, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
const isToolAvailable = useCallback((toolKey: string): boolean => {
// Keep tools enabled during loading (optimistic UX)
@@ -38,22 +51,64 @@ export const useToolManagement = (): ToolManagementResult => {
return endpoints.some((endpoint: string) => endpointStatus[endpoint] !== false);
}, [endpointsLoading, endpointStatus, baseRegistry]);
const deriveToolDisableReason = useCallback((toolKey: ToolId): ToolDisableCause => {
const tool = baseRegistry[toolKey];
if (!tool) {
return 'unknown';
}
const endpoints = tool.endpoints || [];
const disabledReasons: EndpointDisableReason[] = endpoints
.filter(endpoint => endpointStatus[endpoint] === false)
.map(endpoint => endpointDetails[endpoint]?.reason ?? 'CONFIG');
if (disabledReasons.some(reason => reason === 'DEPENDENCY')) {
return 'missingDependency';
}
if (disabledReasons.some(reason => reason === 'CONFIG')) {
return 'disabledByAdmin';
}
if (disabledReasons.length > 0) {
return 'unknown';
}
return 'unknown';
}, [baseRegistry, endpointDetails, endpointStatus]);
const toolAvailability = useMemo(() => {
if (endpointsLoading) {
return {};
}
const availability: ToolAvailabilityMap = {};
(Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => {
const available = isToolAvailable(toolKey);
availability[toolKey] = available
? { available: true }
: { available: false, reason: deriveToolDisableReason(toolKey) };
});
return availability;
}, [baseRegistry, deriveToolDisableReason, endpointsLoading, isToolAvailable]);
const toolRegistry: Partial<ToolRegistry> = useMemo(() => {
const availableToolRegistry: Partial<ToolRegistry> = {};
(Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => {
if (isToolAvailable(toolKey)) {
const baseTool = baseRegistry[toolKey];
if (baseTool) {
availableToolRegistry[toolKey] = {
...baseTool,
name: baseTool.name,
description: baseTool.description,
};
}
const baseTool = baseRegistry[toolKey];
if (!baseTool) return;
const availabilityInfo = toolAvailability[toolKey];
const isAvailable = availabilityInfo ? availabilityInfo.available !== false : true;
// Check if tool is "coming soon" (has no component and no link)
const isComingSoon = !baseTool.component && !baseTool.link && toolKey !== 'read' && toolKey !== 'multiTool';
if (preferences.hideUnavailableTools && (!isAvailable || isComingSoon)) {
return;
}
availableToolRegistry[toolKey] = {
...baseTool,
name: baseTool.name,
description: baseTool.description,
};
});
return availableToolRegistry;
}, [isToolAvailable, baseRegistry]);
}, [baseRegistry, preferences.hideUnavailableTools, toolAvailability]);
const getSelectedTool = useCallback((toolKey: ToolId | null): ToolRegistryEntry | null => {
return toolKey ? toolRegistry[toolKey] || null : null;
@@ -65,5 +120,6 @@ export const useToolManagement = (): ToolManagementResult => {
toolRegistry,
setToolSelectedFileIds,
getSelectedTool,
toolAvailability,
};
};
@@ -9,6 +9,8 @@ export interface UserPreferences {
toolPanelModePromptSeen: boolean;
showLegacyToolDescriptions: boolean;
hasCompletedOnboarding: boolean;
hideUnavailableTools: boolean;
hideUnavailableConversions: boolean;
}
export const DEFAULT_PREFERENCES: UserPreferences = {
@@ -19,6 +21,8 @@ export const DEFAULT_PREFERENCES: UserPreferences = {
toolPanelModePromptSeen: false,
showLegacyToolDescriptions: false,
hasCompletedOnboarding: false,
hideUnavailableTools: false,
hideUnavailableConversions: false,
};
const STORAGE_KEY = 'stirlingpdf_preferences';
+2 -2
View File
@@ -1,6 +1,6 @@
export interface UpdateSummary {
latest_version: string;
latest_stable_version?: string;
latest_version: string | null;
latest_stable_version?: string | null;
max_priority: 'urgent' | 'normal' | 'minor' | 'low';
recommended_action?: string;
any_breaking: boolean;
@@ -8,10 +8,12 @@ import { useAddAttachmentsParameters } from "@app/hooks/tools/addAttachments/use
import { useAddAttachmentsOperation } from "@app/hooks/tools/addAttachments/useAddAttachmentsOperation";
import { useAccordionSteps } from "@app/hooks/tools/shared/useAccordionSteps";
import AddAttachmentsSettings from "@app/components/tools/addAttachments/AddAttachmentsSettings";
import { useAddAttachmentsTips } from "@app/components/tooltips/useAddAttachmentsTips";
const AddAttachments = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
const { selectedFiles } = useFileSelection();
const addAttachmentsTips = useAddAttachmentsTips();
const params = useAddAttachmentsParameters();
const operation = useAddAttachmentsOperation();
@@ -64,6 +66,7 @@ const AddAttachments = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
isCollapsed: accordion.getCollapsedState(AddAttachmentsStep.ATTACHMENTS),
onCollapsedClick: () => accordion.handleStepToggle(AddAttachmentsStep.ATTACHMENTS),
isVisible: true,
tooltip: addAttachmentsTips,
content: (
<AddAttachmentsSettings
parameters={params.parameters}
+10 -3
View File
@@ -9,21 +9,28 @@ import { useAutoRenameTips } from "@app/components/tooltips/useAutoRenameTips";
const AutoRename =(props: BaseToolProps) => {
const { t } = useTranslation();
const autoRenameTips = useAutoRenameTips();
const base = useBaseTool(
'"auto-rename-pdf-file',
'auto-rename-pdf-file',
useAutoRenameParameters,
useAutoRenameOperation,
props
);
return createToolFlow({
title: { title:t("auto-rename.title", "Auto Rename PDF"), description: t("auto-rename.description", "Auto Rename PDF"), tooltip: useAutoRenameTips()},
files: {
selectedFiles: base.selectedFiles,
isCollapsed: base.hasResults,
},
steps: [],
steps: [
{
title: t("auto-rename.settings.title", "About"),
isCollapsed: false,
tooltip: autoRenameTips,
content: null,
},
],
executeButton: {
text: t("auto-rename.submit", "Auto Rename"),
isVisible: !base.hasResults,
@@ -5,9 +5,11 @@ import { useRemoveAnnotationsParameters } from "@app/hooks/tools/removeAnnotatio
import { useRemoveAnnotationsOperation } from "@app/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation";
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
import { BaseToolProps, ToolComponent } from "@app/types/tool";
import { useRemoveAnnotationsTips } from "@app/components/tooltips/useRemoveAnnotationsTips";
const RemoveAnnotations = (props: BaseToolProps) => {
const { t } = useTranslation();
const removeAnnotationsTips = useRemoveAnnotationsTips();
const base = useBaseTool(
'removeAnnotations',
@@ -26,6 +28,7 @@ const RemoveAnnotations = (props: BaseToolProps) => {
title: t("removeAnnotations.settings.title", "Settings"),
isCollapsed: base.settingsCollapsed,
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
tooltip: removeAnnotationsTips,
content: <RemoveAnnotationsSettings />,
},
],
@@ -0,0 +1,6 @@
export type EndpointDisableReason = 'CONFIG' | 'DEPENDENCY' | 'UNKNOWN' | null;
export interface EndpointAvailabilityDetails {
enabled: boolean;
reason?: EndpointDisableReason;
}
+2
View File
@@ -23,6 +23,7 @@ export interface ProcessedFileMetadata {
pages: ProcessedFilePage[];
totalPages?: number;
lastProcessed?: number;
isEncrypted?: boolean;
[key: string]: any;
}
@@ -301,6 +302,7 @@ export interface FileContextActions {
trackBlobUrl: (url: string) => void;
scheduleCleanup: (fileId: FileId, delay?: number) => void;
cleanupFile: (fileId: FileId) => void;
openEncryptedUnlockPrompt: (fileId: FileId) => void;
}
// File selectors (separate from actions to avoid re-renders)
+2 -1
View File
@@ -5,6 +5,7 @@ export interface ThumbnailWithMetadata {
pageCount: number;
pageRotations?: number[]; // Rotation for each page (0, 90, 180, 270)
pageDimensions?: Array<{ width: number; height: number }>;
isEncrypted?: boolean;
}
interface ColorScheme {
@@ -451,7 +452,7 @@ export async function generateThumbnailWithMetadata(file: File, applyRotation: b
if (error instanceof Error && error.name === "PasswordException") {
// Handle encrypted PDFs
const thumbnail = generateEncryptedPDFThumbnail(file);
return { thumbnail, pageCount: 1 };
return { thumbnail, pageCount: 1, isEncrypted: true };
}
const thumbnail = generatePlaceholderThumbnail(file);
@@ -2,6 +2,8 @@
* Standardized error handling utilities for tool operations
*/
import { normalizeAxiosErrorData } from '@app/services/errorUtils';
/**
* Default error extractor that follows the standard pattern
*/
@@ -30,4 +32,36 @@ export const createStandardErrorHandler = (fallbackMessage: string) => {
}
return fallbackMessage;
};
};
/**
* Handles password-related errors with status code checking
* @param error - The error object from axios
* @param incorrectPasswordMessage - Message to show for incorrect password (typically 500 status)
* @param fallbackMessage - Message to show for other errors
* @returns Error message string
*/
export const handlePasswordError = async (
error: any,
incorrectPasswordMessage: string,
fallbackMessage: string
): Promise<string> => {
const status = error?.response?.status;
// Handle specific error cases with user-friendly messages
if (status === 500) {
// 500 typically means incorrect password for encrypted PDFs
return incorrectPasswordMessage;
}
// For other errors, try to extract the message
const normalizedData = await normalizeAxiosErrorData(error?.response?.data);
const errorWithNormalizedData = {
...error,
response: {
...error?.response,
data: normalizedData
}
};
return extractErrorMessage(errorWithNormalizedData) || fallbackMessage;
};
@@ -4,8 +4,10 @@ import { useTranslation } from 'react-i18next';
import apiClient from '@app/services/apiClient';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { isBackendNotReadyError } from '@app/constants/backendErrors';
import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability';
import { connectionModeService } from '@desktop/services/connectionModeService';
interface EndpointConfig {
backendUrl: string;
}
@@ -128,6 +130,7 @@ export function useEndpointEnabled(endpoint: string): {
export function useMultipleEndpointsEnabled(endpoints: string[]): {
endpointStatus: Record<string, boolean>;
endpointDetails: Record<string, EndpointAvailabilityDetails>;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
@@ -140,6 +143,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
return acc;
}, {} as Record<string, boolean>);
});
const [endpointDetails, setEndpointDetails] = useState<Record<string, EndpointAvailabilityDetails>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const isMountedRef = useRef(true);
@@ -174,13 +178,27 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
const endpointsParam = endpoints.join(',');
const response = await apiClient.get<Record<string, boolean>>('/api/v1/config/endpoints-enabled', {
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>('/api/v1/config/endpoints-availability', {
params: { endpoints: endpointsParam },
suppressErrorToast: true,
});
if (!isMountedRef.current) return;
setEndpointStatus(response.data);
const details = Object.entries(response.data).reduce((acc, [endpointName, detail]) => {
acc[endpointName] = {
enabled: detail?.enabled ?? true,
reason: detail?.reason ?? null,
};
return acc;
}, {} as Record<string, EndpointAvailabilityDetails>);
const statusMap = Object.keys(details).reduce((acc, key) => {
acc[key] = details[key].enabled;
return acc;
}, {} as Record<string, boolean>);
setEndpointDetails(prev => ({ ...prev, ...details }));
setEndpointStatus(statusMap);
} catch (err: unknown) {
const isBackendStarting = isBackendNotReadyError(err);
const message = getErrorMessage(err);
@@ -188,10 +206,13 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
setError(isBackendStarting ? t('backendHealth.starting', 'Backend starting up...') : message);
const fallbackStatus = endpoints.reduce((acc, endpointName) => {
acc[endpointName] = true;
const fallbackDetail: EndpointAvailabilityDetails = { enabled: true, reason: null };
acc.status[endpointName] = true;
acc.details[endpointName] = fallbackDetail;
return acc;
}, {} as Record<string, boolean>);
setEndpointStatus(fallbackStatus);
}, { status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> });
setEndpointStatus(fallbackStatus.status);
setEndpointDetails(prev => ({ ...prev, ...fallbackStatus.details }));
if (!retryTimeoutRef.current) {
retryTimeoutRef.current = setTimeout(() => {
@@ -209,6 +230,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
useEffect(() => {
if (!endpoints || endpoints.length === 0) {
setEndpointStatus({});
setEndpointDetails({});
setLoading(false);
return;
}
@@ -230,6 +252,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
return {
endpointStatus,
endpointDetails,
loading,
error,
refetch: fetchAllEndpointStatuses,
@@ -0,0 +1,354 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { springAuth } from '@app/auth/springAuthClient';
import apiClient from '@app/services/apiClient';
import { AxiosError } from 'axios';
// Mock apiClient
vi.mock('@app/services/apiClient');
describe('SpringAuthClient', () => {
beforeEach(() => {
// Clear localStorage before each test
localStorage.clear();
// Clear all mocks
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('getSession', () => {
it('should return null session when no JWT in localStorage', async () => {
const result = await springAuth.getSession();
expect(result.data.session).toBeNull();
expect(result.error).toBeNull();
expect(apiClient.get).not.toHaveBeenCalled();
});
it('should validate JWT and return session when JWT exists', async () => {
const mockToken = 'mock-jwt-token';
const mockUser = {
id: '123',
email: 'test@example.com',
username: 'testuser',
role: 'USER',
};
localStorage.setItem('stirling_jwt', mockToken);
vi.mocked(apiClient.get).mockResolvedValueOnce({
status: 200,
data: { user: mockUser },
} as any);
const result = await springAuth.getSession();
expect(apiClient.get).toHaveBeenCalledWith('/api/v1/auth/me', {
headers: { Authorization: `Bearer ${mockToken}` },
suppressErrorToast: true,
});
expect(result.data.session).toBeTruthy();
expect(result.data.session?.user).toEqual(mockUser);
expect(result.data.session?.access_token).toBe(mockToken);
expect(result.error).toBeNull();
});
it('should clear invalid JWT on 401 error', async () => {
const mockToken = 'invalid-jwt-token';
localStorage.setItem('stirling_jwt', mockToken);
const mockError = new AxiosError(
'Unauthorized',
'ERR_BAD_REQUEST',
undefined,
undefined,
{
status: 401,
statusText: 'Unauthorized',
data: {},
headers: {},
config: {} as any,
}
);
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
const result = await springAuth.getSession();
expect(localStorage.getItem('stirling_jwt')).toBeNull();
expect(result.data.session).toBeNull();
// 401 is handled gracefully, so error should be null
expect(result.error).toBeNull();
});
it('should clear invalid JWT on 403 error', async () => {
const mockToken = 'forbidden-jwt-token';
localStorage.setItem('stirling_jwt', mockToken);
const mockError = new AxiosError(
'Forbidden',
'ERR_BAD_REQUEST',
undefined,
undefined,
{
status: 403,
statusText: 'Forbidden',
data: {},
headers: {},
config: {} as any,
}
);
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
const result = await springAuth.getSession();
expect(localStorage.getItem('stirling_jwt')).toBeNull();
expect(result.data.session).toBeNull();
// 403 is handled gracefully, so error should be null
expect(result.error).toBeNull();
});
});
describe('signInWithPassword', () => {
it('should successfully sign in with email and password', async () => {
const credentials = {
email: 'test@example.com',
password: 'password123',
};
const mockToken = 'new-jwt-token';
const mockUser = {
id: '123',
email: credentials.email,
username: credentials.email,
role: 'USER',
};
vi.mocked(apiClient.post).mockResolvedValueOnce({
status: 200,
data: {
user: mockUser,
session: {
access_token: mockToken,
expires_in: 3600,
},
},
} as any);
// Spy on window.dispatchEvent
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
const result = await springAuth.signInWithPassword(credentials);
expect(apiClient.post).toHaveBeenCalledWith(
'/api/v1/auth/login',
{
username: credentials.email,
password: credentials.password,
},
{ withCredentials: true }
);
expect(localStorage.getItem('stirling_jwt')).toBe(mockToken);
expect(dispatchEventSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: 'jwt-available' })
);
expect(result.user).toEqual(mockUser);
expect(result.session?.access_token).toBe(mockToken);
expect(result.error).toBeNull();
});
it('should return error on failed login', async () => {
const credentials = {
email: 'wrong@example.com',
password: 'wrongpassword',
};
const errorMessage = 'Invalid credentials';
const mockError = Object.assign(new Error(errorMessage), {
isAxiosError: true,
response: {
status: 401,
data: { message: errorMessage },
},
});
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
const result = await springAuth.signInWithPassword(credentials);
expect(result.user).toBeNull();
expect(result.session).toBeNull();
expect(result.error).toBeTruthy();
expect(result.error?.message).toBe(errorMessage);
});
});
describe('signUp', () => {
it('should successfully register new user', async () => {
const credentials = {
email: 'newuser@example.com',
password: 'newpassword123',
};
const mockUser = {
id: '456',
email: credentials.email,
username: credentials.email,
role: 'USER',
};
vi.mocked(apiClient.post).mockResolvedValueOnce({
status: 200,
data: { user: mockUser },
} as any);
const result = await springAuth.signUp(credentials);
expect(apiClient.post).toHaveBeenCalledWith(
'/api/v1/user/register',
{
username: credentials.email,
password: credentials.password,
},
{ withCredentials: true }
);
expect(result.user).toEqual(mockUser);
expect(result.session).toBeNull(); // No auto-login on signup
expect(result.error).toBeNull();
});
it('should return error on failed registration', async () => {
const credentials = {
email: 'existing@example.com',
password: 'password123',
};
const errorMessage = 'User already exists';
const mockError = Object.assign(new Error(errorMessage), {
isAxiosError: true,
response: {
status: 409,
data: { message: errorMessage },
},
});
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
const result = await springAuth.signUp(credentials);
expect(result.user).toBeNull();
expect(result.session).toBeNull();
expect(result.error).toBeTruthy();
expect(result.error?.message).toBe(errorMessage);
});
});
describe('signOut', () => {
it('should successfully sign out and clear JWT', async () => {
const mockToken = 'jwt-to-clear';
localStorage.setItem('stirling_jwt', mockToken);
vi.mocked(apiClient.post).mockResolvedValueOnce({
status: 200,
data: {},
} as any);
const result = await springAuth.signOut();
expect(apiClient.post).toHaveBeenCalledWith(
'/api/v1/auth/logout',
null,
expect.objectContaining({ withCredentials: true })
);
expect(localStorage.getItem('stirling_jwt')).toBeNull();
expect(result.error).toBeNull();
});
it('should clear JWT even if logout request fails', async () => {
const mockToken = 'jwt-to-clear';
localStorage.setItem('stirling_jwt', mockToken);
vi.mocked(apiClient.post).mockRejectedValueOnce({
isAxiosError: true,
response: { status: 500 },
message: 'Server error',
});
const result = await springAuth.signOut();
expect(localStorage.getItem('stirling_jwt')).toBeNull();
expect(result.error).toBeTruthy();
});
});
describe('refreshSession', () => {
it('should refresh JWT token successfully', async () => {
const newToken = 'refreshed-jwt-token';
const mockUser = {
id: '123',
email: 'test@example.com',
username: 'testuser',
role: 'USER',
};
vi.mocked(apiClient.post).mockResolvedValueOnce({
status: 200,
data: {
user: mockUser,
session: {
access_token: newToken,
expires_in: 3600,
},
},
} as any);
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
const result = await springAuth.refreshSession();
expect(localStorage.getItem('stirling_jwt')).toBe(newToken);
expect(dispatchEventSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: 'jwt-available' })
);
expect(result.data.session?.access_token).toBe(newToken);
expect(result.error).toBeNull();
});
it('should clear JWT and return error on 401', async () => {
localStorage.setItem('stirling_jwt', 'expired-token');
vi.mocked(apiClient.post).mockRejectedValueOnce({
isAxiosError: true,
response: { status: 401 },
message: 'Token expired',
});
const result = await springAuth.refreshSession();
expect(localStorage.getItem('stirling_jwt')).toBeNull();
expect(result.data.session).toBeNull();
expect(result.error).toBeTruthy();
});
});
describe('signInWithOAuth', () => {
it('should redirect to OAuth provider', async () => {
const mockAssign = vi.fn();
Object.defineProperty(window, 'location', {
value: { assign: mockAssign },
writable: true,
});
const result = await springAuth.signInWithOAuth({
provider: 'github',
options: { redirectTo: '/auth/callback' },
});
expect(mockAssign).toHaveBeenCalledWith('/oauth2/authorization/github');
expect(result.error).toBeNull();
});
});
});
@@ -134,6 +134,7 @@ class SpringAuthClient {
headers: {
'Authorization': `Bearer ${token}`,
},
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
});
console.debug('[SpringAuth] /me response status:', response.status);
@@ -314,6 +315,7 @@ class SpringAuthClient {
'X-XSRF-TOKEN': this.getCsrfToken() || '',
},
withCredentials: true,
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
});
const data = response.data;
@@ -0,0 +1,177 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, waitFor } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import AuthCallback from '@app/routes/AuthCallback';
import { springAuth } from '@app/auth/springAuthClient';
// Mock springAuth
vi.mock('@app/auth/springAuthClient', () => ({
springAuth: {
getSession: vi.fn(),
},
}));
// Mock useNavigate
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useNavigate: () => mockNavigate,
};
});
describe('AuthCallback', () => {
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
// Reset window.location.hash
window.location.hash = '';
});
it('should extract JWT from URL hash and validate it', async () => {
const mockToken = 'oauth-jwt-token';
const mockUser = {
id: '123',
email: 'oauth@example.com',
username: 'oauthuser',
role: 'USER',
};
// Set URL hash with access token
window.location.hash = `#access_token=${mockToken}`;
// Mock successful session validation
vi.mocked(springAuth.getSession).mockResolvedValueOnce({
data: {
session: {
user: mockUser,
access_token: mockToken,
expires_in: 3600,
expires_at: Date.now() + 3600000,
},
},
error: null,
});
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>
);
await waitFor(() => {
// Verify JWT was stored
expect(localStorage.getItem('stirling_jwt')).toBe(mockToken);
// Verify jwt-available event was dispatched
expect(dispatchEventSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: 'jwt-available' })
);
// Verify getSession was called to validate token
expect(springAuth.getSession).toHaveBeenCalled();
// Verify navigation to home
expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true });
});
});
it('should redirect to login when no access token in hash', async () => {
// No hash or empty hash
window.location.hash = '';
render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>
);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/login', {
replace: true,
state: { error: 'OAuth login failed - no token received.' },
});
expect(localStorage.getItem('stirling_jwt')).toBeNull();
});
});
it('should redirect to login when token validation fails', async () => {
const invalidToken = 'invalid-oauth-token';
window.location.hash = `#access_token=${invalidToken}`;
// Mock failed session validation
vi.mocked(springAuth.getSession).mockResolvedValueOnce({
data: { session: null },
error: { message: 'Invalid token' },
});
render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>
);
await waitFor(() => {
// JWT should be stored initially
expect(localStorage.getItem('stirling_jwt')).toBeNull(); // Cleared after validation failure
// Verify redirect to login
expect(mockNavigate).toHaveBeenCalledWith('/login', {
replace: true,
state: { error: 'OAuth login failed - invalid token.' },
});
});
});
it('should handle errors gracefully', async () => {
const mockToken = 'error-token';
window.location.hash = `#access_token=${mockToken}`;
// Mock getSession throwing error
vi.mocked(springAuth.getSession).mockRejectedValueOnce(
new Error('Network error')
);
render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>
);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/login', {
replace: true,
state: { error: 'OAuth login failed. Please try again.' },
});
});
});
it('should display loading state while processing', () => {
window.location.hash = '#access_token=processing-token';
vi.mocked(springAuth.getSession).mockImplementationOnce(
() =>
new Promise((resolve) =>
setTimeout(
() =>
resolve({
data: { session: null },
error: { message: 'Token expired' },
}),
100
)
)
);
const { getByText } = render(
<BrowserRouter>
<AuthCallback />
</BrowserRouter>
);
expect(getByText('Completing authentication...')).toBeInTheDocument();
});
});
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '@app/auth/UseSession';
import { springAuth } from '@app/auth/springAuthClient';
/**
* OAuth Callback Handler
@@ -11,7 +11,6 @@ import { useAuth } from '@app/auth/UseSession';
*/
export default function AuthCallback() {
const navigate = useNavigate();
const { refreshSession } = useAuth();
useEffect(() => {
const handleCallback = async () => {
@@ -37,12 +36,23 @@ export default function AuthCallback() {
console.log('[AuthCallback] JWT stored in localStorage');
// Dispatch custom event for other components to react to JWT availability
window.dispatchEvent(new CustomEvent('jwt-available'))
window.dispatchEvent(new CustomEvent('jwt-available'));
// Refresh session to load user info into state
await refreshSession();
// Validate the token and load user info
// This calls /api/v1/auth/me with the JWT to get user details
const { data, error } = await springAuth.getSession();
console.log('[AuthCallback] Session refreshed, redirecting to home');
if (error || !data.session) {
console.error('[AuthCallback] Failed to validate token:', error);
localStorage.removeItem('stirling_jwt');
navigate('/login', {
replace: true,
state: { error: 'OAuth login failed - invalid token.' }
});
return;
}
console.log('[AuthCallback] Token validated, redirecting to home');
// Clear the hash from URL and redirect to home page
navigate('/', { replace: true });
@@ -56,7 +66,7 @@ export default function AuthCallback() {
};
handleCallback();
}, [navigate, refreshSession]);
}, [navigate]);
return (
<div style={{
@@ -0,0 +1,473 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { BrowserRouter, MemoryRouter } from 'react-router-dom';
import { MantineProvider } from '@mantine/core';
import Login from '@app/routes/Login';
import { useAuth } from '@app/auth/UseSession';
import { springAuth } from '@app/auth/springAuthClient';
// Mock i18n to return fallback text
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, fallback?: string | Record<string, unknown>) => {
if (typeof fallback === 'string') return fallback;
return key;
},
}),
}));
// Mock useAuth hook
vi.mock('@app/auth/UseSession', () => ({
useAuth: vi.fn(),
}));
// Mock springAuth
vi.mock('@app/auth/springAuthClient', () => ({
springAuth: {
signInWithPassword: vi.fn(),
signInWithOAuth: vi.fn(),
},
}));
// Mock useDocumentMeta
vi.mock('@app/hooks/useDocumentMeta', () => ({
useDocumentMeta: vi.fn(),
}));
// Mock fetch for provider list
global.fetch = vi.fn();
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useNavigate: () => mockNavigate,
};
});
// Test wrapper with MantineProvider
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
<MantineProvider>{children}</MantineProvider>
);
describe('Login', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default auth state - not logged in
vi.mocked(useAuth).mockReturnValue({
session: null,
user: null,
loading: false,
error: null,
signOut: vi.fn(),
refreshSession: vi.fn(),
});
// Mock fetch for login UI data
vi.mocked(fetch).mockResolvedValue({
ok: true,
json: async () => ({
enableLogin: true,
providerList: {},
}),
} as Response);
});
it('should render login form', async () => {
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
);
await waitFor(() => {
// Check for login form elements - use id since it's more reliable
const emailInput = document.getElementById('email');
expect(emailInput).toBeTruthy();
});
});
it('should redirect authenticated user to home', async () => {
const mockSession = {
user: {
id: '123',
email: 'test@example.com',
username: 'testuser',
role: 'USER',
},
access_token: 'mock-token',
expires_in: 3600,
};
vi.mocked(useAuth).mockReturnValue({
session: mockSession,
user: mockSession.user,
loading: false,
error: null,
signOut: vi.fn(),
refreshSession: vi.fn(),
});
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true });
});
});
it('should show loading state while auth is loading', () => {
vi.mocked(useAuth).mockReturnValue({
session: null,
user: null,
loading: true,
error: null,
signOut: vi.fn(),
refreshSession: vi.fn(),
});
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
);
// Component shouldn't redirect or show form while loading
expect(mockNavigate).not.toHaveBeenCalled();
});
it('should handle email/password login', async () => {
const user = userEvent.setup();
const mockUser = {
id: '123',
email: 'test@example.com',
username: 'test@example.com',
role: 'USER',
};
const mockSession = {
user: mockUser,
access_token: 'new-token',
expires_in: 3600,
};
vi.mocked(springAuth.signInWithPassword).mockResolvedValueOnce({
user: mockUser,
session: mockSession,
error: null,
});
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
);
// Wait for form to load
await waitFor(() => {
const emailInput = document.getElementById('email');
expect(emailInput).toBeTruthy();
const passwordInput = document.getElementById('password');
expect(passwordInput).toBeTruthy();
}, { timeout: 3000 });
// Fill in form using getElementById
const emailInput = document.getElementById('email') as HTMLInputElement;
const passwordInput = document.getElementById('password') as HTMLInputElement;
if (!emailInput || !passwordInput) {
throw new Error('Form inputs not found');
}
await user.type(emailInput, 'test@example.com');
await user.type(passwordInput, 'password123');
// Submit form - use a more flexible query
// Look for button with type="submit" in the form
const submitButton = await waitFor(() => {
const buttons = screen.queryAllByRole('button');
const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit');
if (!submitBtn) {
throw new Error('Submit button not found');
}
return submitBtn;
}, { timeout: 5000 });
await user.click(submitButton);
await waitFor(() => {
expect(springAuth.signInWithPassword).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
});
it('should show error on failed login', async () => {
const user = userEvent.setup();
const errorMessage = 'Invalid credentials';
vi.mocked(springAuth.signInWithPassword).mockResolvedValueOnce({
user: null,
session: null,
error: { message: errorMessage },
});
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
);
await waitFor(() => {
const emailInput = document.getElementById('email');
const passwordInput = document.getElementById('password');
expect(emailInput).toBeTruthy();
expect(passwordInput).toBeTruthy();
}, { timeout: 3000 });
const emailInput = document.getElementById('email') as HTMLInputElement;
const passwordInput = document.getElementById('password') as HTMLInputElement;
await user.type(emailInput, 'wrong@example.com');
await user.type(passwordInput, 'wrongpassword');
const submitButton = await waitFor(() => {
const buttons = screen.queryAllByRole('button');
const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit');
if (!submitBtn) {
throw new Error('Submit button not found');
}
return submitBtn;
}, { timeout: 5000 });
await user.click(submitButton);
await waitFor(() => {
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
});
it('should validate empty email and password', async () => {
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
);
await waitFor(() => {
expect(document.getElementById('email')).toBeTruthy();
}, { timeout: 3000 });
// Find the submit button
const submitButton = await waitFor(() => {
const buttons = screen.queryAllByRole('button');
const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit');
if (!submitBtn) {
throw new Error('Submit button not found');
}
return submitBtn;
}, { timeout: 5000 });
// Button should be disabled when email/password are empty
expect(submitButton).toBeDisabled();
// Verify sign in was not called
expect(springAuth.signInWithPassword).not.toHaveBeenCalled();
});
it('should display session expired message from URL param', () => {
render(
<TestWrapper>
<MemoryRouter initialEntries={['/login?expired=true']}>
<Login />
</MemoryRouter>
</TestWrapper>
);
expect(screen.getByText(/session.*expired/i)).toBeInTheDocument();
});
it('should display account created success message', () => {
render(
<TestWrapper>
<MemoryRouter initialEntries={['/login?messageType=accountCreated']}>
<Login />
</MemoryRouter>
</TestWrapper>
);
expect(screen.getByText(/account created/i)).toBeInTheDocument();
});
it('should prefill email from query param', () => {
const email = 'prefilled@example.com';
render(
<TestWrapper>
<MemoryRouter initialEntries={[`/login?email=${email}`]}>
<Login />
</MemoryRouter>
</TestWrapper>
);
waitFor(() => {
const emailInput = document.getElementById('email') as HTMLInputElement;
expect(emailInput.value).toBe(email);
});
});
it('should redirect to home when login disabled', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
enableLogin: false,
providerList: {},
}),
} as Response);
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
);
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/');
});
});
it('should handle OAuth provider click', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
enableLogin: true,
providerList: {
'/oauth2/authorization/github': 'GitHub',
},
}),
} as Response);
vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({
error: null,
});
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
);
await waitFor(() => {
const githubButton = screen.queryByText(/github/i);
if (githubButton) {
expect(githubButton).toBeInTheDocument();
}
});
// Since OAuth buttons might be dynamically rendered based on config,
// we just verify the mock is set up correctly
expect(springAuth.signInWithOAuth).toBeDefined();
});
it('should show email form by default when no SSO providers', async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
enableLogin: true,
providerList: {}, // No providers
}),
} as Response);
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
);
await waitFor(() => {
expect(document.getElementById('email')).toBeInTheDocument();
expect(document.getElementById('password')).toBeInTheDocument();
});
});
it('should disable submit button while signing in', async () => {
const user = userEvent.setup();
vi.mocked(springAuth.signInWithPassword).mockImplementationOnce(
() =>
new Promise((resolve) =>
setTimeout(
() =>
resolve({
user: null,
session: null,
error: { message: 'Error' },
}),
100
)
)
);
render(
<TestWrapper>
<BrowserRouter>
<Login />
</BrowserRouter>
</TestWrapper>
);
await waitFor(() => {
const emailInput = document.getElementById('email');
const passwordInput = document.getElementById('password');
expect(emailInput).toBeTruthy();
expect(passwordInput).toBeTruthy();
}, { timeout: 3000 });
const emailInput = document.getElementById('email') as HTMLInputElement;
const passwordInput = document.getElementById('password') as HTMLInputElement;
await user.type(emailInput, 'test@example.com');
await user.type(passwordInput, 'password123');
const submitButton = await waitFor(() => {
const buttons = screen.queryAllByRole('button');
const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit');
if (!submitBtn) {
throw new Error('Submit button not found');
}
return submitBtn;
}, { timeout: 5000 });
await user.click(submitButton);
// Button should be disabled while signing in
expect(submitButton).toBeDisabled();
// Wait for completion
await waitFor(() => {
expect(submitButton).not.toBeDisabled();
});
});
});
@@ -30,6 +30,14 @@ export default function Login() {
const [hasSSOProviders, setHasSSOProviders] = useState(false);
const [_enableLogin, setEnableLogin] = useState<boolean | null>(null);
// Redirect immediately if user has valid session (JWT already validated by AuthProvider)
useEffect(() => {
if (!loading && session) {
console.debug('[Login] User already authenticated, redirecting to home');
navigate('/', { replace: true });
}
}, [session, loading, navigate]);
// Fetch enabled SSO providers and login config from backend
useEffect(() => {
const fetchProviders = async () => {
+11 -1
View File
@@ -1,7 +1,8 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useDocumentMeta } from '@app/hooks/useDocumentMeta';
import { useAuth } from '@app/auth/UseSession';
import AuthLayout from '@app/routes/authShared/AuthLayout';
import '@app/routes/authShared/auth.css';
import { BASE_PATH } from '@app/constants/app';
@@ -17,6 +18,7 @@ import { useAuthService } from '@app/routes/signup/AuthService';
export default function Signup() {
const navigate = useNavigate();
const { t } = useTranslation();
const { session, loading } = useAuth();
const [isSigningUp, setIsSigningUp] = useState(false);
const [error, setError] = useState<string | null>(null);
const [email, setEmail] = useState('');
@@ -24,6 +26,14 @@ export default function Signup() {
const [confirmPassword, setConfirmPassword] = useState('');
const [fieldErrors, setFieldErrors] = useState<SignupFieldErrors>({});
// Redirect immediately if user has valid session (JWT already validated by AuthProvider)
useEffect(() => {
if (!loading && session) {
console.debug('[Signup] User already authenticated, redirecting to home');
navigate('/', { replace: true });
}
}, [session, loading, navigate]);
const baseUrl = window.location.origin + BASE_PATH;
// Set document meta