Merge branch 'feature/v2/bookmarks' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/bookmarks

This commit is contained in:
Reece
2025-11-20 15:09:13 +00:00
106 changed files with 10723 additions and 661 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:
+37 -37
View File
@@ -115,46 +115,46 @@ Stirling-PDF currently supports 40 languages!
| Language | Progress |
| -------------------------------------------- | -------------------------------------- |
| Arabic (العربية) (ar_AR) | ![64%](https://geps.dev/progress/64) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![24%](https://geps.dev/progress/24) |
| Basque (Euskara) (eu_ES) | ![14%](https://geps.dev/progress/14) |
| Bulgarian (Български) (bg_BG) | ![26%](https://geps.dev/progress/26) |
| Catalan (Català) (ca_CA) | ![26%](https://geps.dev/progress/26) |
| Croatian (Hrvatski) (hr_HR) | ![24%](https://geps.dev/progress/24) |
| Czech (Česky) (cs_CZ) | ![26%](https://geps.dev/progress/26) |
| Danish (Dansk) (da_DK) | ![23%](https://geps.dev/progress/23) |
| Dutch (Nederlands) (nl_NL) | ![23%](https://geps.dev/progress/23) |
| Arabic (العربية) (ar_AR) | ![94%](https://geps.dev/progress/94) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![93%](https://geps.dev/progress/93) |
| Basque (Euskara) (eu_ES) | ![93%](https://geps.dev/progress/93) |
| Bulgarian (Български) (bg_BG) | ![94%](https://geps.dev/progress/94) |
| Catalan (Català) (ca_CA) | ![93%](https://geps.dev/progress/93) |
| Croatian (Hrvatski) (hr_HR) | ![93%](https://geps.dev/progress/93) |
| Czech (Česky) (cs_CZ) | ![91%](https://geps.dev/progress/91) |
| Danish (Dansk) (da_DK) | ![92%](https://geps.dev/progress/92) |
| Dutch (Nederlands) (nl_NL) | ![93%](https://geps.dev/progress/93) |
| English (English) (en_GB) | ![100%](https://geps.dev/progress/100) |
| English (US) (en_US) | ![100%](https://geps.dev/progress/100) |
| French (Français) (fr_FR) | ![63%](https://geps.dev/progress/63) |
| German (Deutsch) (de_DE) | ![64%](https://geps.dev/progress/64) |
| Greek (Ελληνικά) (el_GR) | ![26%](https://geps.dev/progress/26) |
| Hindi (हिंदी) (hi_IN) | ![26%](https://geps.dev/progress/26) |
| Hungarian (Magyar) (hu_HU) | ![29%](https://geps.dev/progress/29) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![24%](https://geps.dev/progress/24) |
| Irish (Gaeilge) (ga_IE) | ![26%](https://geps.dev/progress/26) |
| Italian (Italiano) (it_IT) | ![64%](https://geps.dev/progress/64) |
| Japanese (日本語) (ja_JP) | ![47%](https://geps.dev/progress/47) |
| Korean (한국어) (ko_KR) | ![26%](https://geps.dev/progress/26) |
| Norwegian (Norsk) (no_NB) | ![24%](https://geps.dev/progress/24) |
| Persian (فارسی) (fa_IR) | ![26%](https://geps.dev/progress/26) |
| Polish (Polski) (pl_PL) | ![27%](https://geps.dev/progress/27) |
| Portuguese (Português) (pt_PT) | ![26%](https://geps.dev/progress/26) |
| Portuguese Brazilian (Português) (pt_BR) | ![64%](https://geps.dev/progress/64) |
| Romanian (Română) (ro_RO) | ![22%](https://geps.dev/progress/22) |
| Russian (Русский) (ru_RU) | ![63%](https://geps.dev/progress/63) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![28%](https://geps.dev/progress/28) |
| Simplified Chinese (简体中文) (zh_CN) | ![65%](https://geps.dev/progress/65) |
| Slovakian (Slovensky) (sk_SK) | ![19%](https://geps.dev/progress/19) |
| Slovenian (Slovenščina) (sl_SI) | ![27%](https://geps.dev/progress/27) |
| Spanish (Español) (es_ES) | ![64%](https://geps.dev/progress/64) |
| Swedish (Svenska) (sv_SE) | ![25%](https://geps.dev/progress/25) |
| Thai (ไทย) (th_TH) | ![23%](https://geps.dev/progress/23) |
| French (Français) (fr_FR) | ![93%](https://geps.dev/progress/93) |
| German (Deutsch) (de_DE) | ![93%](https://geps.dev/progress/93) |
| Greek (Ελληνικά) (el_GR) | ![93%](https://geps.dev/progress/93) |
| Hindi (हिंदी) (hi_IN) | ![94%](https://geps.dev/progress/94) |
| Hungarian (Magyar) (hu_HU) | ![94%](https://geps.dev/progress/94) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![93%](https://geps.dev/progress/93) |
| Irish (Gaeilge) (ga_IE) | ![94%](https://geps.dev/progress/94) |
| Italian (Italiano) (it_IT) | ![93%](https://geps.dev/progress/93) |
| Japanese (日本語) (ja_JP) | ![94%](https://geps.dev/progress/94) |
| Korean (한국어) (ko_KR) | ![94%](https://geps.dev/progress/94) |
| Norwegian (Norsk) (no_NB) | ![93%](https://geps.dev/progress/93) |
| Persian (فارسی) (fa_IR) | ![94%](https://geps.dev/progress/94) |
| Polish (Polski) (pl_PL) | ![93%](https://geps.dev/progress/93) |
| Portuguese (Português) (pt_PT) | ![93%](https://geps.dev/progress/93) |
| Portuguese Brazilian (Português) (pt_BR) | ![93%](https://geps.dev/progress/93) |
| Romanian (Română) (ro_RO) | ![93%](https://geps.dev/progress/93) |
| Russian (Русский) (ru_RU) | ![94%](https://geps.dev/progress/94) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![93%](https://geps.dev/progress/93) |
| Simplified Chinese (简体中文) (zh_CN) | ![94%](https://geps.dev/progress/94) |
| Slovakian (Slovensky) (sk_SK) | ![93%](https://geps.dev/progress/93) |
| Slovenian (Slovenščina) (sl_SI) | ![94%](https://geps.dev/progress/94) |
| Spanish (Español) (es_ES) | ![94%](https://geps.dev/progress/94) |
| Swedish (Svenska) (sv_SE) | ![93%](https://geps.dev/progress/93) |
| Thai (ไทย) (th_TH) | ![93%](https://geps.dev/progress/93) |
| Tibetan (བོད་ཡིག་) (bo_CN) | ![65%](https://geps.dev/progress/65) |
| Traditional Chinese (繁體中文) (zh_TW) | ![29%](https://geps.dev/progress/29) |
| Turkish (Türkçe) (tr_TR) | ![28%](https://geps.dev/progress/28) |
| Ukrainian (Українська) (uk_UA) | ![28%](https://geps.dev/progress/28) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![21%](https://geps.dev/progress/21) |
| Traditional Chinese (繁體中文) (zh_TW) | ![94%](https://geps.dev/progress/94) |
| Turkish (Türkçe) (tr_TR) | ![94%](https://geps.dev/progress/94) |
| Ukrainian (Українська) (uk_UA) | ![94%](https://geps.dev/progress/94) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![93%](https://geps.dev/progress/93) |
| Malayalam (മലയാളം) (ml_IN) | ![73%](https://geps.dev/progress/73) |
## Stirling PDF Enterprise
+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)
@@ -12,6 +12,8 @@ import java.util.Properties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.context.WebServerInitializedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.core.env.Environment;
import org.springframework.scheduling.annotation.EnableScheduling;
@@ -198,6 +200,14 @@ public class SPDFApplication {
// }
}
@EventListener
public void onWebServerInitialized(WebServerInitializedEvent event) {
int actualPort = event.getWebServer().getPort();
serverPortStatic = String.valueOf(actualPort);
// Log the actual runtime port for Tauri to parse
log.info("Stirling-PDF running on port: {}", actualPort);
}
private static void printStartupLogs() {
log.info("Stirling-PDF Started.");
String url = baseUrlStatic + ":" + getStaticPort() + contextPathStatic;
@@ -0,0 +1,49 @@
package stirling.software.SPDF.config;
import java.io.IOException;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
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).
*/
@Component
@ConditionalOnProperty(name = "security.enableLogin", havingValue = "false")
@RequiredArgsConstructor
@Slf4j
public class WAUTrackingFilter implements Filter {
private final WeeklyActiveUsersService wauService;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (request instanceof HttpServletRequest httpRequest) {
// Extract browser ID from header
String browserId = httpRequest.getHeader("X-Browser-Id");
if (browserId != null && !browserId.trim().isEmpty()) {
// Record browser access
wauService.recordBrowserAccess(browserId);
}
}
// Continue the filter chain
chain.doFilter(request, response);
}
}
@@ -46,8 +46,24 @@ public class WebMvcConfig implements WebMvcConfigurer {
"tauri://localhost",
"http://tauri.localhost",
"https://tauri.localhost")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN",
"X-Browser-Id")
.exposedHeaders(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
.allowCredentials(true)
.maxAge(3600);
} else if (hasConfiguredOrigins) {
@@ -63,13 +79,53 @@ public class WebMvcConfig implements WebMvcConfigurer {
.toArray(new String[0]);
registry.addMapping("/**")
.allowedOrigins(allowedOrigins)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*")
.allowedOriginPatterns(allowedOrigins)
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN",
"X-Browser-Id")
.exposedHeaders(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
.allowCredentials(true)
.maxAge(3600);
} else {
// Default to allowing all origins when nothing is configured
logger.info(
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins.");
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN",
"X-Browser-Id")
.exposedHeaders(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
.allowCredentials(true)
.maxAge(3600);
}
// If no origins are configured and not in Tauri mode, CORS is not enabled (secure by
// default)
}
}
@@ -154,6 +154,25 @@ public class ConfigController {
// EE features not available, continue without them
}
// Add version and machine info for update checking
try {
if (applicationContext.containsBean("appVersion")) {
configData.put(
"appVersion", applicationContext.getBean("appVersion", String.class));
}
if (applicationContext.containsBean("machineType")) {
configData.put(
"machineType", applicationContext.getBean("machineType", String.class));
}
if (applicationContext.containsBean("activeSecurity")) {
configData.put(
"activeSecurity",
applicationContext.getBean("activeSecurity", Boolean.class));
}
} catch (Exception e) {
// Version/machine info not available
}
return ResponseEntity.ok(configData);
} catch (Exception e) {
@@ -23,6 +23,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointInspector;
import stirling.software.SPDF.config.StartupApplicationListener;
import stirling.software.SPDF.service.WeeklyActiveUsersService;
import stirling.software.common.annotations.api.InfoApi;
import stirling.software.common.model.ApplicationProperties;
@@ -34,6 +35,7 @@ public class MetricsController {
private final ApplicationProperties applicationProperties;
private final MeterRegistry meterRegistry;
private final EndpointInspector endpointInspector;
private final Optional<WeeklyActiveUsersService> wauService;
private boolean metricsEnabled;
@PostConstruct
@@ -352,6 +354,35 @@ public class MetricsController {
return ResponseEntity.ok(formatDuration(uptime));
}
@GetMapping("/wau")
@Operation(
summary = "Weekly Active Users statistics",
description =
"Returns WAU (Weekly Active Users) count and total unique browsers. "
+ "Only available when security is disabled (no-login mode). "
+ "Tracks unique browsers via client-generated UUID in localStorage.")
public ResponseEntity<?> getWeeklyActiveUsers() {
if (!metricsEnabled) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled.");
}
// 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)");
}
WeeklyActiveUsersService service = wauService.get();
Map<String, Object> wauStats = new HashMap<>();
wauStats.put("weeklyActiveUsers", service.getWeeklyActiveUsers());
wauStats.put("totalUniqueBrowsers", service.getTotalUniqueBrowsers());
wauStats.put("daysOnline", service.getDaysOnline());
wauStats.put("trackingSince", service.getStartTime().toString());
return ResponseEntity.ok(wauStats);
}
private String formatDuration(Duration duration) {
long days = duration.toDays();
long hours = duration.toHoursPart();
@@ -0,0 +1,100 @@
package stirling.software.SPDF.service;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
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
@Slf4j
public class WeeklyActiveUsersService {
// Map of browser ID -> last seen timestamp
private final Map<String, Instant> activeBrowsers = new ConcurrentHashMap<>();
// Track total unique browsers seen (overall)
private long totalUniqueBrowsers = 0;
// Application start time
private final Instant startTime = Instant.now();
/**
* Records a browser access with the current timestamp
* @param browserId Unique browser identifier from X-Browser-Id header
*/
public void recordBrowserAccess(String browserId) {
if (browserId == null || browserId.trim().isEmpty()) {
return;
}
boolean isNewBrowser = !activeBrowsers.containsKey(browserId);
activeBrowsers.put(browserId, Instant.now());
if (isNewBrowser) {
totalUniqueBrowsers++;
log.debug("New browser recorded: {} (Total: {})", browserId, totalUniqueBrowsers);
}
}
/**
* Gets the count of unique browsers seen in the last 7 days
* @return Weekly Active Users count
*/
public long getWeeklyActiveUsers() {
cleanupOldEntries();
return activeBrowsers.size();
}
/**
* Gets the total count of unique browsers ever seen
* @return Total unique browsers count
*/
public long getTotalUniqueBrowsers() {
return totalUniqueBrowsers;
}
/**
* Gets the number of days the service has been running
* @return Days online
*/
public long getDaysOnline() {
return ChronoUnit.DAYS.between(startTime, Instant.now());
}
/**
* Gets the timestamp when tracking started
* @return Start time
*/
public Instant getStartTime() {
return startTime;
}
/**
* 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)
*/
public void performCleanup() {
int sizeBefore = activeBrowsers.size();
cleanupOldEntries();
int sizeAfter = activeBrowsers.size();
if (sizeBefore != sizeAfter) {
log.debug("Cleaned up {} old browser entries", sizeBefore - sizeAfter);
}
}
}
@@ -113,7 +113,12 @@ public class LicenseKeyChecker {
public void updateLicenseKey(String newKey) throws IOException {
applicationProperties.getPremium().setKey(newKey);
GeneralUtils.saveKeyToSettings("EnterpriseEdition.key", newKey);
GeneralUtils.saveKeyToSettings("premium.key", newKey);
evaluateLicense();
synchronizeLicenseSettings();
}
public void resyncLicense() {
evaluateLicense();
synchronizeLicenseSettings();
}
@@ -0,0 +1,245 @@
package stirling.software.proprietary.security.controller.api;
import java.util.HashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.GeneralUtils;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
/**
* Admin controller for license management. Provides installation ID for Stripe checkout metadata
* and endpoints for managing license keys.
*/
@RestController
@Slf4j
@RequestMapping("/api/v1/admin")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@Tag(name = "Admin License Management", description = "Admin-only License Management APIs")
public class AdminLicenseController {
@Autowired(required = false)
private LicenseKeyChecker licenseKeyChecker;
@Autowired(required = false)
private KeygenLicenseVerifier keygenLicenseVerifier;
@Autowired private ApplicationProperties applicationProperties;
/**
* Get the installation ID (machine fingerprint) for this self-hosted instance. This ID is used
* as metadata in Stripe checkout to link licenses to specific installations.
*
* @return Map containing the installation ID
*/
@GetMapping("/installation-id")
@Operation(
summary = "Get installation ID",
description =
"Returns the unique installation ID (MAC-based fingerprint) for this"
+ " self-hosted instance")
public ResponseEntity<Map<String, String>> getInstallationId() {
try {
String installationId = GeneralUtils.generateMachineFingerprint();
log.info("Admin requested installation ID: {}", installationId);
return ResponseEntity.ok(Map.of("installationId", installationId));
} catch (Exception e) {
log.error("Failed to generate installation ID", e);
return ResponseEntity.internalServerError()
.body(Map.of("error", "Failed to generate installation ID"));
}
}
/**
* Save and activate a license key. This endpoint accepts a license key from the frontend (e.g.,
* after Stripe checkout) and activates it on the backend.
*
* @param request Map containing the license key
* @return Response with success status, license type, and whether restart is required
*/
@PostMapping("/license-key")
@Operation(
summary = "Save and activate license key",
description =
"Accepts a license key and activates it on the backend. Returns the activated"
+ " license type.")
public ResponseEntity<Map<String, Object>> saveLicenseKey(
@RequestBody Map<String, String> request) {
String licenseKey = request.get("licenseKey");
// Reject null but allow empty string to clear license
if (licenseKey == null) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "License key is required"));
}
try {
if (licenseKeyChecker == null) {
return ResponseEntity.internalServerError()
.body(Map.of("success", false, "error", "License checker not available"));
}
// assume premium enabled when setting license key
applicationProperties.getPremium().setEnabled(true);
// Use existing LicenseKeyChecker to update and validate license
// Empty string will be evaluated as NORMAL license (free tier)
licenseKeyChecker.updateLicenseKey(licenseKey.trim());
// Get current license status
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
// Auto-enable premium features if license is valid
if (license != License.NORMAL) {
GeneralUtils.saveKeyToSettings("premium.enabled", true);
// Enable premium features
// Save maxUsers from license metadata
Integer maxUsers = applicationProperties.getPremium().getMaxUsers();
if (maxUsers != null) {
GeneralUtils.saveKeyToSettings("premium.maxUsers", maxUsers);
}
} else {
GeneralUtils.saveKeyToSettings("premium.enabled", false);
log.info("License key is not valid for premium features: type={}", license.name());
}
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("licenseType", license.name());
response.put("enabled", applicationProperties.getPremium().isEnabled());
response.put("maxUsers", applicationProperties.getPremium().getMaxUsers());
response.put("requiresRestart", false); // Dynamic evaluation works
response.put("message", "License key saved and activated");
log.info("License key saved and activated: type={}", license.name());
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("Failed to save license key", e);
return ResponseEntity.badRequest()
.body(
Map.of(
"success",
false,
"error",
"Failed to activate license: " + e.getMessage()));
}
}
/**
* Resync the current license with Keygen. This endpoint re-validates the existing license key
* and updates the max users setting. Used after subscription upgrades to sync the new license
* limits.
*
* @return Response with updated license information
*/
@PostMapping("/license/resync")
@Operation(
summary = "Resync license with Keygen",
description =
"Re-validates the existing license key with Keygen and updates local settings."
+ " Used after subscription upgrades.")
public ResponseEntity<Map<String, Object>> resyncLicense() {
try {
if (licenseKeyChecker == null) {
return ResponseEntity.internalServerError()
.body(Map.of("success", false, "error", "License checker not available"));
}
String currentKey = applicationProperties.getPremium().getKey();
if (currentKey == null || currentKey.trim().isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "No license key configured"));
}
log.info("Resyncing license with Keygen");
// Re-validate license and sync settings
licenseKeyChecker.resyncLicense();
// Get updated license status
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
ApplicationProperties.Premium premium = applicationProperties.getPremium();
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("licenseType", license.name());
response.put("enabled", premium.isEnabled());
response.put("maxUsers", premium.getMaxUsers());
response.put("message", "License resynced successfully");
log.info(
"License resynced: type={}, maxUsers={}",
license.name(),
premium.getMaxUsers());
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("Failed to resync license", e);
return ResponseEntity.internalServerError()
.body(
Map.of(
"success",
false,
"error",
"Failed to resync license: " + e.getMessage()));
}
}
/**
* Get information about the current license key status, including license type, enabled status,
* and max users.
*
* @return Map containing license information
*/
@GetMapping("/license-info")
@Operation(
summary = "Get license information",
description =
"Returns information about the current license including type, enabled status,"
+ " and max users")
public ResponseEntity<Map<String, Object>> getLicenseInfo() {
try {
Map<String, Object> response = new HashMap<>();
if (licenseKeyChecker != null) {
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
response.put("licenseType", license.name());
} else {
response.put("licenseType", License.NORMAL.name());
}
ApplicationProperties.Premium premium = applicationProperties.getPremium();
response.put("enabled", premium.isEnabled());
response.put("maxUsers", premium.getMaxUsers());
response.put("hasKey", premium.getKey() != null && !premium.getKey().trim().isEmpty());
// Include license key for upgrades (admin-only endpoint)
if (premium.getKey() != null && !premium.getKey().trim().isEmpty()) {
response.put("licenseKey", premium.getKey());
}
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("Failed to get license info", e);
return ResponseEntity.internalServerError()
.body(Map.of("error", "Failed to retrieve license information"));
}
}
}
@@ -299,6 +299,16 @@ public class AdminSettingsController {
+ String.join(", ", VALID_SECTION_NAMES));
}
// Auto-enable premium features if license key is provided
if ("premium".equalsIgnoreCase(sectionName) && sectionData.containsKey("key")) {
Object keyValue = sectionData.get("key");
if (keyValue != null && !keyValue.toString().trim().isEmpty()) {
// Automatically set enabled to true when a key is provided
sectionData.put("enabled", true);
log.info("Auto-enabling premium features because license key was provided");
}
}
int updatedCount = 0;
for (Map.Entry<String, Object> entry : sectionData.entrySet()) {
String propertyKey = entry.getKey();
+160 -3
View File
@@ -40,10 +40,14 @@
"@mui/icons-material": "^7.3.2",
"@mui/material": "^7.3.2",
"@reactour/tour": "^3.8.0",
"@stripe/react-stripe-js": "^4.0.2",
"@stripe/stripe-js": "^7.9.0",
"@supabase/supabase-js": "^2.47.13",
"@tailwindcss/postcss": "^4.1.13",
"@tanstack/react-virtual": "^3.13.12",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-fs": "^2.4.0",
"@tauri-apps/plugin-http": "^2.5.4",
"autoprefixer": "^10.4.21",
"axios": "^1.12.2",
"globals": "^16.4.0",
@@ -3137,6 +3141,138 @@
"url": "https://github.com/sindresorhus/is?sponsor=1"
}
},
"node_modules/@stripe/react-stripe-js": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-4.0.2.tgz",
"integrity": "sha512-l2wau+8/LOlHl+Sz8wQ1oDuLJvyw51nQCsu6/ljT6smqzTszcMHifjAJoXlnMfcou3+jK/kQyVe04u/ufyTXgg==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.7.2"
},
"peerDependencies": {
"@stripe/stripe-js": ">=1.44.1 <8.0.0",
"react": ">=16.8.0 <20.0.0",
"react-dom": ">=16.8.0 <20.0.0"
}
},
"node_modules/@stripe/stripe-js": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
"license": "MIT",
"engines": {
"node": ">=12.16"
}
},
"node_modules/@supabase/auth-js": {
"version": "2.81.1",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.81.1.tgz",
"integrity": "sha512-K20GgiSm9XeRLypxYHa5UCnybWc2K0ok0HLbqCej/wRxDpJxToXNOwKt0l7nO8xI1CyQ+GrNfU6bcRzvdbeopQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/auth-js/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@supabase/functions-js": {
"version": "2.81.1",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.81.1.tgz",
"integrity": "sha512-sYgSO3mlgL0NvBFS3oRfCK4OgKGQwuOWJLzfPyWg0k8MSxSFSDeN/JtrDJD5GQrxskP6c58+vUzruBJQY78AqQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@supabase/postgrest-js": {
"version": "2.81.1",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.81.1.tgz",
"integrity": "sha512-DePpUTAPXJyBurQ4IH2e42DWoA+/Qmr5mbgY4B6ZcxVc/ZUKfTVK31BYIFBATMApWraFc8Q/Sg+yxtfJ3E0wSg==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/postgrest-js/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@supabase/realtime-js": {
"version": "2.81.1",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.81.1.tgz",
"integrity": "sha512-ViQ+Kxm8BuUP/TcYmH9tViqYKGSD1LBjdqx2p5J+47RES6c+0QHedM0PPAjthMdAHWyb2LGATE9PD2++2rO/tw==",
"license": "MIT",
"dependencies": {
"@types/phoenix": "^1.6.6",
"@types/ws": "^8.18.1",
"tslib": "2.8.1",
"ws": "^8.18.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@supabase/storage-js": {
"version": "2.81.1",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.81.1.tgz",
"integrity": "sha512-UNmYtjnZnhouqnbEMC1D5YJot7y0rIaZx7FG2Fv8S3hhNjcGVvO+h9We/tggi273BFkiahQPS/uRsapo1cSapw==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/storage-js/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@supabase/supabase-js": {
"version": "2.81.1",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.81.1.tgz",
"integrity": "sha512-KSdY7xb2L0DlLmlYzIOghdw/na4gsMcqJ8u4sD6tOQJr+x3hLujU9s4R8N3ob84/1bkvpvlU5PYKa1ae+OICnw==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.81.1",
"@supabase/functions-js": "2.81.1",
"@supabase/postgrest-js": "2.81.1",
"@supabase/realtime-js": "2.81.1",
"@supabase/storage-js": "2.81.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz",
@@ -3904,6 +4040,15 @@
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-http": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.4.tgz",
"integrity": "sha512-/i4U/9za3mrytTgfRn5RHneKubZE/dwRmshYwyMvNRlkWjvu1m4Ma72kcbVJMZFGXpkbl+qLyWMGrihtWB76Zg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -4210,7 +4355,6 @@
"version": "24.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
@@ -4222,6 +4366,12 @@
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
"license": "MIT"
},
"node_modules/@types/phoenix": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
"license": "MIT"
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -4256,6 +4406,15 @@
"@types/react": "*"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
@@ -13969,7 +14128,6 @@
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
},
"node_modules/universalify": {
@@ -14840,7 +14998,6 @@
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
+4
View File
@@ -33,6 +33,9 @@
"@mantine/dates": "^8.3.1",
"@mantine/dropzone": "^8.3.1",
"@mantine/hooks": "^8.3.1",
"@stripe/react-stripe-js": "^4.0.2",
"@stripe/stripe-js": "^7.9.0",
"@supabase/supabase-js": "^2.47.13",
"@mui/icons-material": "^7.3.2",
"@mui/material": "^7.3.2",
"@reactour/tour": "^3.8.0",
@@ -40,6 +43,7 @@
"@tanstack/react-virtual": "^3.13.12",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-fs": "^2.4.0",
"@tauri-apps/plugin-http": "^2.5.4",
"autoprefixer": "^10.4.21",
"axios": "^1.12.2",
"globals": "^16.4.0",
+304 -13
View File
@@ -362,7 +362,15 @@
"defaultPdfEditorInactive": "Another application is set as default",
"defaultPdfEditorChecking": "Checking...",
"defaultPdfEditorSet": "Already Default",
"setAsDefault": "Set as Default"
"setAsDefault": "Set as Default",
"updates": {
"title": "Software Updates",
"description": "Check for updates and view version information",
"currentVersion": "Current Version",
"latestVersion": "Latest Version",
"checkForUpdates": "Check for Updates",
"viewDetails": "View Details"
}
},
"hotkeys": {
"title": "Keyboard Shortcuts",
@@ -383,6 +391,37 @@
"searchPlaceholder": "Search tools..."
}
},
"update": {
"modalTitle": "Update Available",
"current": "Current Version",
"latest": "Latest Version",
"latestStable": "Latest Stable",
"priorityLabel": "Priority",
"recommendedAction": "Recommended Action",
"breakingChangesDetected": "Breaking Changes Detected",
"breakingChangesMessage": "Some versions contain breaking changes. Please review the migration guides below before updating.",
"migrationGuides": "Migration Guides",
"viewGuide": "View Guide",
"loadingDetailedInfo": "Loading detailed information...",
"close": "Close",
"viewAllReleases": "View All Releases",
"downloadLatest": "Download Latest",
"availableUpdates": "Available Updates",
"unableToLoadDetails": "Unable to load detailed information.",
"version": "Version",
"urgentUpdateAvailable": "Urgent Update",
"updateAvailable": "Update Available",
"releaseNotes": "Release Notes",
"priority": {
"urgent": "Urgent",
"normal": "Normal",
"minor": "Minor",
"low": "Low"
},
"breakingChanges": "Breaking Changes",
"breakingChangesDefault": "This version contains breaking changes.",
"migrationGuide": "Migration Guide"
},
"changeCreds": {
"title": "Change Credentials",
"header": "Update Your Account Details",
@@ -2095,13 +2134,54 @@
"title": "Draw your signature",
"clear": "Clear"
},
"canvas": {
"heading": "Draw your signature",
"clickToOpen": "Click to open the drawing canvas",
"modalTitle": "Draw your signature",
"colorLabel": "Colour",
"penSizeLabel": "Pen size",
"penSizePlaceholder": "Size",
"clear": "Clear canvas",
"colorPickerTitle": "Choose stroke colour"
},
"text": {
"name": "Signer Name",
"placeholder": "Enter your full name"
"placeholder": "Enter your full name",
"fontLabel": "Font",
"fontSizeLabel": "Font size",
"fontSizePlaceholder": "Type or select font size (8-200)",
"colorLabel": "Text colour"
},
"clear": "Clear",
"add": "Add",
"saved": "Saved Signatures",
"saved": {
"heading": "Saved signatures",
"description": "Reuse saved signatures at any time.",
"emptyTitle": "No saved signatures yet",
"emptyDescription": "Draw, upload, or type a signature above, then use \"Save to library\" to keep up to {{max}} favourites ready to use.",
"type": {
"canvas": "Drawing",
"image": "Upload",
"text": "Text"
},
"limitTitle": "Limit reached",
"limitDescription": "Remove a saved signature before adding new ones (max {{max}}).",
"carouselPosition": "{{current}} of {{total}}",
"prev": "Previous",
"next": "Next",
"delete": "Remove",
"label": "Label",
"defaultLabel": "Signature",
"defaultCanvasLabel": "Drawing signature",
"defaultImageLabel": "Uploaded signature",
"defaultTextLabel": "Typed signature",
"saveButton": "Save signature",
"saveUnavailable": "Create a signature first to save it.",
"noChanges": "Current signature is already saved.",
"status": {
"saved": "Saved"
}
},
"save": "Save Signature",
"applySignatures": "Apply Signatures",
"personalSigs": "Personal Signatures",
@@ -2120,12 +2200,18 @@
"steps": {
"configure": "Configure Signature"
},
"step": {
"createDesc": "Choose how you want to create the signature",
"place": "Place & save",
"placeDesc": "Position the signature on your PDF"
},
"type": {
"title": "Signature Type",
"draw": "Draw",
"canvas": "Canvas",
"image": "Image",
"text": "Text"
"text": "Text",
"saved": "Saved"
},
"image": {
"label": "Upload signature image",
@@ -2136,11 +2222,17 @@
"title": "How to add signature",
"canvas": "After drawing your signature in the canvas, close the modal then click anywhere on the PDF to place it.",
"image": "After uploading your signature image above, click anywhere on the PDF to place it.",
"text": "After entering your name above, click anywhere on the PDF to place your signature."
"saved": "Select a saved signature above, then click anywhere on the PDF to place it.",
"text": "After entering your name above, click anywhere on the PDF to place your signature.",
"paused": "Placement paused",
"resumeHint": "Resume placement to click and add your signature.",
"noSignature": "Create a signature above to enable placement tools."
},
"mode": {
"move": "Move Signature",
"place": "Place Signature"
"place": "Place Signature",
"pause": "Pause placement",
"resume": "Resume placement"
},
"updateAndPlace": "Update and Place",
"activate": "Activate Signature Placement",
@@ -2323,7 +2415,7 @@
},
"cta": "Compare",
"loading": "Comparing...",
"summary": {
"baseHeading": "Original document",
"comparisonHeading": "Edited document",
@@ -2379,7 +2471,7 @@
"body": "This comparison is taking longer than usual. You can let it continue or cancel it.",
"cancel": "Cancel comparison"
},
"newLine": "new-line",
"complex": {
"message": "One or both of the provided documents are large files, accuracy of comparison may be reduced"
@@ -4328,9 +4420,21 @@
"title": "Premium & Enterprise",
"description": "Configure your premium or enterprise license key.",
"license": "License Configuration",
"licenseKey": {
"toggle": "Got a license key or certificate file?",
"info": "If you have a license key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features."
},
"key": {
"label": "License Key",
"description": "Enter your premium or enterprise license key"
"description": "Enter your premium or enterprise license key. Premium features will be automatically enabled when a key is provided.",
"success": "License Key Saved",
"successMessage": "Your license key has been activated successfully. No restart required.",
"overwriteWarning": {
"title": "⚠️ Warning: Existing License Detected",
"line1": "Overwriting your current license key cannot be undone.",
"line2": "Your previous license will be permanently lost unless you have backed it up elsewhere.",
"line3": "Important: Keep license keys private and secure. Never share them publicly."
}
},
"enabled": {
"label": "Enable Premium Features",
@@ -4740,6 +4844,9 @@
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
}
},
"colorPicker": {
"title": "Choose colour"
},
"common": {
"previous": "Previous",
"next": "Next",
@@ -4755,7 +4862,12 @@
"used": "used",
"available": "available",
"cancel": "Cancel",
"preview": "Preview"
"preview": "Preview",
"close": "Close",
"done": "Done",
"loading": "Loading...",
"back": "Back",
"continue": "Continue"
},
"config": {
"overview": {
@@ -5173,6 +5285,14 @@
"showComparison": "Compare All Features",
"hideComparison": "Hide Feature Comparison",
"featureComparison": "Feature Comparison",
"from": "From",
"perMonth": "/month",
"licensedSeats": "Licensed: {{count}} seats",
"includedInCurrent": "Included in Your Plan",
"selectPlan": "Select Plan",
"manageSubscription": {
"description": "Manage your subscription, billing, and payment methods"
},
"activePlan": {
"title": "Active Plan",
"subtitle": "Your current subscription details"
@@ -5190,13 +5310,16 @@
"upTo": "Up to"
},
"period": {
"month": "month"
"month": "month",
"perUserPerMonth": "/user/month"
},
"free": {
"name": "Free",
"highlight1": "Limited Tool Usage Per week",
"highlight2": "Access to all tools",
"highlight3": "Community support"
"highlight3": "Community support",
"forever": "Forever free",
"included": "Included"
},
"pro": {
"name": "Pro",
@@ -5238,13 +5361,44 @@
"error": "Failed to open billing portal"
}
},
"upgradeBanner": {
"title": "Upgrade to Server Plan",
"message": "Get the most out of Stirling PDF with unlimited users and advanced features",
"upgradeButton": "Upgrade Now",
"dismiss": "Dismiss banner"
},
"payment": {
"preparing": "Preparing your checkout...",
"upgradeTitle": "Upgrade to {{planName}}",
"success": "Payment Successful!",
"successMessage": "Your subscription has been activated successfully. You will receive a confirmation email shortly.",
"autoClose": "This window will close automatically...",
"error": "Payment Error"
"error": "Payment Error",
"upgradeSuccess": "Payment successful! Your subscription has been upgraded. The license has been updated on your server. You will receive a confirmation email shortly.",
"paymentSuccess": "Payment successful! Retrieving your license key...",
"licenseActivated": "License activated! Your license key has been saved. A confirmation email has been sent to your registered email address.",
"licenseDelayed": "Payment successful! Your license is being generated. You will receive an email with your license key shortly. If you don't receive it within 10 minutes, please contact support.",
"licensePollingError": "Payment successful but we couldn't retrieve your license key automatically. Please check your email or contact support with your payment confirmation.",
"licenseRetrievalError": "Payment successful but license retrieval failed. You will receive your license key via email. Please contact support if you don't receive it within 10 minutes.",
"syncError": "Payment successful but license sync failed. Your license will be updated shortly. Please contact support if issues persist.",
"licenseSaveError": "Failed to save license key. Please contact support with your license key to complete activation.",
"paymentCanceled": "Payment was canceled. No charges were made.",
"syncingLicense": "Syncing your upgraded license...",
"generatingLicense": "Generating your license key...",
"upgradeComplete": "Upgrade Complete",
"upgradeCompleteMessage": "Your subscription has been upgraded successfully. Your existing license key has been updated.",
"stripeNotConfigured": "Stripe Not Configured",
"stripeNotConfiguredMessage": "Stripe payment integration is not configured. Please contact your administrator.",
"monthly": "Monthly",
"yearly": "Yearly",
"billingPeriod": "Billing Period",
"enterpriseNote": "Seats can be adjusted in checkout (1-1000).",
"installationId": "Installation ID",
"licenseKey": "Your License Key",
"licenseInstructions": "Enter this key in Settings → Admin Plan → License Key section",
"canCloseWindow": "You can now close this window.",
"licenseKeyProcessing": "License Key Processing",
"licenseDelayedMessage": "Your license key is being generated. Please check your email shortly or contact support."
},
"firstLogin": {
"title": "First Time Login",
@@ -5403,5 +5557,142 @@
"offline": "Backend Offline",
"starting": "Backend starting up...",
"wait": "Please wait for the backend to finish launching and try again."
},
"setup": {
"welcome": "Welcome to Stirling PDF",
"description": "Get started by choosing how you want to use Stirling PDF",
"step1": {
"label": "Choose Mode",
"description": "Offline or Server"
},
"step2": {
"label": "Select Server",
"description": "Self-hosted server"
},
"step3": {
"label": "Login",
"description": "Enter credentials"
},
"mode": {
"offline": {
"title": "Use Offline",
"description": "Run locally without an internet connection"
},
"server": {
"title": "Connect to Server",
"description": "Connect to a remote Stirling PDF server"
}
},
"server": {
"title": "Connect to Server",
"subtitle": "Enter your self-hosted server URL",
"type": {
"saas": "Stirling PDF SaaS",
"selfhosted": "Self-hosted server"
},
"url": {
"label": "Server URL",
"description": "Enter the full URL of your self-hosted Stirling PDF server"
},
"error": {
"emptyUrl": "Please enter a server URL",
"unreachable": "Could not connect to server",
"testFailed": "Connection test failed"
},
"testing": "Testing connection..."
},
"login": {
"title": "Sign In",
"subtitle": "Enter your credentials to continue",
"connectingTo": "Connecting to:",
"username": {
"label": "Username",
"placeholder": "Enter your username"
},
"password": {
"label": "Password",
"placeholder": "Enter your password"
},
"error": {
"emptyUsername": "Please enter your username",
"emptyPassword": "Please enter your password"
},
"submit": "Login"
}
},
"settings": {
"connection": {
"title": "Connection Mode",
"mode": {
"offline": "Offline",
"server": "Server"
},
"server": "Server",
"user": "Logged in as",
"switchToServer": "Connect to Server",
"switchToOffline": "Switch to Offline",
"logout": "Logout",
"selectServer": "Select Server",
"login": "Login"
},
"general": {
"title": "General",
"description": "Configure general application preferences.",
"user": "User",
"logout": "Log out",
"enableFeatures": {
"dismiss": "Dismiss",
"title": "For System Administrators",
"intro": "Enable user authentication, team management, and workspace features for your organisation.",
"action": "Configure",
"and": "and",
"benefit": "Enables user roles, team collaboration, admin controls, and enterprise features.",
"learnMore": "Learn more in documentation"
},
"defaultToolPickerMode": "Default tool picker mode",
"defaultToolPickerModeDescription": "Choose whether the tool picker opens in fullscreen or sidebar by default",
"mode": {
"sidebar": "Sidebar",
"fullscreen": "Fullscreen"
},
"autoUnzipTooltip": "Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.",
"autoUnzip": "Auto-unzip API responses",
"autoUnzipDescription": "Automatically extract files from ZIP responses",
"autoUnzipFileLimitTooltip": "Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.",
"autoUnzipFileLimit": "Auto-unzip file limit",
"autoUnzipFileLimitDescription": "Maximum number of files to extract from ZIP",
"defaultPdfEditor": "Default PDF editor",
"defaultPdfEditorActive": "Stirling PDF is your default PDF editor",
"defaultPdfEditorInactive": "Another application is set as default",
"defaultPdfEditorChecking": "Checking...",
"defaultPdfEditorSet": "Already Default",
"setAsDefault": "Set as Default",
"updates": {
"title": "Software Updates",
"description": "Check for updates and view version information",
"currentVersion": "Current Version",
"latestVersion": "Latest Version",
"checkForUpdates": "Check for Updates",
"viewDetails": "View Details"
}
},
"hotkeys": {
"errorConflict": "Shortcut already used by {{tool}}.",
"searchPlaceholder": "Search tools...",
"none": "Not assigned",
"customBadge": "Custom",
"defaultLabel": "Default: {{shortcut}}",
"capturing": "Press keys… (Esc to cancel)",
"change": "Change shortcut",
"reset": "Reset",
"shortcut": "Shortcut",
"noShortcut": "No shortcut set"
}
},
"auth": {
"sessionExpired": "Session Expired",
"pleaseLoginAgain": "Please login again.",
"accessDenied": "Access Denied",
"insufficientPermissions": "You do not have permission to perform this action."
}
}
+403 -4
View File
@@ -589,10 +589,29 @@ version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]]
name = "cookie_store"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9"
dependencies = [
"cookie",
"document-features",
"idna",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -767,6 +786,12 @@ dependencies = [
"syn 2.0.108",
]
[[package]]
name = "data-url"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376"
[[package]]
name = "deranged"
version = "0.5.5"
@@ -871,6 +896,15 @@ dependencies = [
"syn 2.0.108",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "dpi"
version = "0.1.2"
@@ -1365,8 +1399,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi 0.11.1+wasi-snapshot-preview1",
"wasm-bindgen",
]
[[package]]
@@ -1376,9 +1412,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasip2",
"wasm-bindgen",
]
[[package]]
@@ -1548,6 +1586,25 @@ dependencies = [
"tracing",
]
[[package]]
name = "h2"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
dependencies = [
"atomic-waker",
"bytes",
"fnv",
"futures-core",
"futures-sink",
"http 1.3.1",
"indexmap 2.12.0",
"slab",
"tokio",
"tokio-util",
"tracing",
]
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -1677,7 +1734,7 @@ dependencies = [
"futures-channel",
"futures-core",
"futures-util",
"h2",
"h2 0.3.27",
"http 0.2.12",
"http-body 0.4.6",
"httparse",
@@ -1701,6 +1758,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2 0.4.12",
"http 1.3.1",
"http-body 1.0.1",
"httparse",
@@ -1712,6 +1770,23 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-rustls"
version = "0.27.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
dependencies = [
"http 1.3.1",
"hyper 1.7.0",
"hyper-util",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
name = "hyper-tls"
version = "0.5.0"
@@ -1744,9 +1819,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2 0.6.1",
"system-configuration 0.6.1",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -2057,6 +2134,16 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "keyring"
version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
dependencies = [
"log",
"zeroize",
]
[[package]]
name = "kuchikiki"
version = "0.8.8-speedreader"
@@ -2137,6 +2224,12 @@ version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -2155,6 +2248,12 @@ dependencies = [
"value-bag",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "mac"
version = "0.1.1"
@@ -3092,6 +3191,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "ptr_meta"
version = "0.1.4"
@@ -3112,6 +3217,16 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "publicsuffix"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [
"idna",
"psl-types",
]
[[package]]
name = "quick-xml"
version = "0.38.3"
@@ -3121,6 +3236,61 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.6.1",
"thiserror 2.0.17",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.2",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.17",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.1",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.41"
@@ -3167,6 +3337,16 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.3",
]
[[package]]
name = "rand_chacha"
version = "0.2.2"
@@ -3187,6 +3367,16 @@ dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.3",
]
[[package]]
name = "rand_core"
version = "0.5.1"
@@ -3205,6 +3395,15 @@ dependencies = [
"getrandom 0.2.16",
]
[[package]]
name = "rand_core"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_hc"
version = "0.2.0"
@@ -3318,7 +3517,7 @@ dependencies = [
"encoding_rs",
"futures-core",
"futures-util",
"h2",
"h2 0.3.27",
"http 0.2.12",
"http-body 0.4.6",
"hyper 0.14.32",
@@ -3336,7 +3535,7 @@ dependencies = [
"serde_json",
"serde_urlencoded",
"sync_wrapper 0.1.2",
"system-configuration",
"system-configuration 0.5.1",
"tokio",
"tokio-native-tls",
"tower-service",
@@ -3355,22 +3554,32 @@ checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f"
dependencies = [
"base64 0.22.1",
"bytes",
"cookie",
"cookie_store",
"encoding_rs",
"futures-core",
"futures-util",
"h2 0.4.12",
"http 1.3.1",
"http-body 1.0.1",
"http-body-util",
"hyper 1.7.0",
"hyper-rustls",
"hyper-util",
"js-sys",
"log",
"mime",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
"serde_urlencoded",
"sync_wrapper 1.0.2",
"tokio",
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
@@ -3380,6 +3589,21 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams",
"web-sys",
"webpki-roots",
]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.16",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]]
@@ -3427,6 +3651,12 @@ dependencies = [
"serde_json",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -3449,6 +3679,20 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.23.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f"
dependencies = [
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pemfile"
version = "1.0.4"
@@ -3458,6 +3702,27 @@ dependencies = [
"base64 0.21.7",
]
[[package]]
name = "rustls-pki-types"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a"
dependencies = [
"web-time",
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -3952,6 +4217,7 @@ version = "0.1.0"
dependencies = [
"core-foundation 0.10.1",
"core-services",
"keyring",
"log",
"reqwest 0.11.27",
"serde",
@@ -3959,9 +4225,11 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-fs",
"tauri-plugin-http",
"tauri-plugin-log",
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tauri-plugin-store",
"tokio",
]
@@ -3996,6 +4264,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "swift-rs"
version = "1.0.7"
@@ -4063,7 +4337,18 @@ checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
dependencies = [
"bitflags 1.3.2",
"core-foundation 0.9.4",
"system-configuration-sys",
"system-configuration-sys 0.5.0",
]
[[package]]
name = "system-configuration"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.9.4",
"system-configuration-sys 0.6.0",
]
[[package]]
@@ -4076,6 +4361,16 @@ dependencies = [
"libc",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -4305,6 +4600,30 @@ dependencies = [
"url",
]
[[package]]
name = "tauri-plugin-http"
version = "2.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c00685aceab12643cf024f712ab0448ba8fcadf86f2391d49d2e5aa732aacc70"
dependencies = [
"bytes",
"cookie_store",
"data-url",
"http 1.3.1",
"regex",
"reqwest 0.12.24",
"schemars 0.8.22",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.17",
"tokio",
"url",
"urlpattern",
]
[[package]]
name = "tauri-plugin-log"
version = "2.7.1"
@@ -4363,6 +4682,22 @@ dependencies = [
"zbus",
]
[[package]]
name = "tauri-plugin-store"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59a77036340a97eb5bbe1b3209c31e5f27f75e6f92a52fd9dd4b211ef08bf310"
dependencies = [
"dunce",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.17",
"tokio",
"tracing",
]
[[package]]
name = "tauri-runtime"
version = "2.9.1"
@@ -4596,9 +4931,21 @@ dependencies = [
"mio",
"pin-project-lite",
"socket2 0.6.1",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.108",
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
@@ -4609,6 +4956,16 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-rustls"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
dependencies = [
"rustls",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.16"
@@ -4898,6 +5255,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.7"
@@ -5131,6 +5494,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webkit2gtk"
version = "2.0.1"
@@ -5175,6 +5548,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-roots"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.0"
@@ -5360,6 +5742,17 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-registry"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
dependencies = [
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -5962,6 +6355,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.2"
+3
View File
@@ -28,7 +28,10 @@ tauri = { version = "2.9.0", features = [ "devtools"] }
tauri-plugin-log = "2.0.0-rc"
tauri-plugin-shell = "2.1.0"
tauri-plugin-fs = "2.4.4"
tauri-plugin-http = "2.4.4"
tauri-plugin-single-instance = "2.0.1"
tauri-plugin-store = "2.1.0"
keyring = "3.6.1"
tokio = { version = "1.0", features = ["time"] }
reqwest = { version = "0.11", features = ["json"] }
+13 -4
View File
@@ -7,9 +7,18 @@
],
"permissions": [
"core:default",
{
"identifier": "fs:allow-read-file",
"allow": [{ "path": "**" }]
}
"http:default",
{
"identifier": "http:allow-fetch",
"allow": [
{ "url": "http://localhost:*" },
{ "url": "http://127.0.0.1:*" },
{ "url": "https://*" }
]
},
{
"identifier": "fs:allow-read-file",
"allow": [{ "path": "**" }]
}
]
}
+215
View File
@@ -0,0 +1,215 @@
use keyring::Entry;
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use tauri_plugin_store::StoreExt;
const STORE_FILE: &str = "connection.json";
const USER_INFO_KEY: &str = "user_info";
const KEYRING_SERVICE: &str = "stirling-pdf";
const KEYRING_TOKEN_KEY: &str = "auth-token";
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UserInfo {
pub username: String,
pub email: Option<String>,
}
fn get_keyring_entry() -> Result<Entry, String> {
Entry::new(KEYRING_SERVICE, KEYRING_TOKEN_KEY)
.map_err(|e| format!("Failed to access keyring: {}", e))
}
#[tauri::command]
pub async fn save_auth_token(_app_handle: AppHandle, token: String) -> Result<(), String> {
log::info!("Saving auth token to keyring");
let entry = get_keyring_entry()?;
entry
.set_password(&token)
.map_err(|e| format!("Failed to save token to keyring: {}", e))?;
log::info!("Auth token saved successfully");
Ok(())
}
#[tauri::command]
pub async fn get_auth_token(_app_handle: AppHandle) -> Result<Option<String>, String> {
log::debug!("Retrieving auth token from keyring");
let entry = get_keyring_entry()?;
match entry.get_password() {
Ok(token) => Ok(Some(token)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(format!("Failed to retrieve token: {}", e)),
}
}
#[tauri::command]
pub async fn clear_auth_token(_app_handle: AppHandle) -> Result<(), String> {
log::info!("Clearing auth token from keyring");
let entry = get_keyring_entry()?;
// Delete the token - ignore error if it doesn't exist
match entry.delete_credential() {
Ok(_) => {
log::info!("Auth token cleared successfully");
Ok(())
}
Err(keyring::Error::NoEntry) => {
log::info!("Auth token was already cleared");
Ok(())
}
Err(e) => Err(format!("Failed to clear token: {}", e)),
}
}
#[tauri::command]
pub async fn save_user_info(
app_handle: AppHandle,
username: String,
email: Option<String>,
) -> Result<(), String> {
log::info!("Saving user info for: {}", username);
let user_info = UserInfo { username, email };
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
store.set(
USER_INFO_KEY,
serde_json::to_value(&user_info)
.map_err(|e| format!("Failed to serialize user info: {}", e))?,
);
store
.save()
.map_err(|e| format!("Failed to save store: {}", e))?;
log::info!("User info saved successfully");
Ok(())
}
#[tauri::command]
pub async fn get_user_info(app_handle: AppHandle) -> Result<Option<UserInfo>, String> {
log::debug!("Retrieving user info");
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
let user_info: Option<UserInfo> = store
.get(USER_INFO_KEY)
.and_then(|v| serde_json::from_value(v.clone()).ok());
Ok(user_info)
}
#[tauri::command]
pub async fn clear_user_info(app_handle: AppHandle) -> Result<(), String> {
log::info!("Clearing user info");
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
store.delete(USER_INFO_KEY);
store
.save()
.map_err(|e| format!("Failed to save store: {}", e))?;
log::info!("User info cleared successfully");
Ok(())
}
// Response types for Spring Boot login
#[derive(Debug, Deserialize)]
struct SpringBootSession {
access_token: String,
}
#[derive(Debug, Deserialize)]
struct SpringBootUser {
username: String,
email: Option<String>,
}
#[derive(Debug, Deserialize)]
struct SpringBootLoginResponse {
session: SpringBootSession,
user: SpringBootUser,
}
#[derive(Debug, Serialize)]
pub struct LoginResponse {
pub token: String,
pub username: String,
pub email: Option<String>,
}
/// Login command - makes HTTP request from Rust to bypass CORS
/// Supports Spring Boot authentication (self-hosted)
#[tauri::command]
pub async fn login(
server_url: String,
username: String,
password: String,
) -> Result<LoginResponse, String> {
log::info!("Login attempt for user: {} to server: {}", username, server_url);
// Build login URL
let login_url = format!("{}/api/v1/auth/login", server_url.trim_end_matches('/'));
log::debug!("Login URL: {}", login_url);
// Create HTTP client
let client = reqwest::Client::new();
// Make login request
let response = client
.post(&login_url)
.json(&serde_json::json!({
"username": username,
"password": password,
}))
.send()
.await
.map_err(|e| format!("Network error: {}", e))?;
let status = response.status();
log::debug!("Login response status: {}", status);
if !status.is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
log::error!("Login failed with status {}: {}", status, error_text);
return Err(if status.as_u16() == 401 {
"Invalid username or password".to_string()
} else if status.as_u16() == 403 {
"Access denied".to_string()
} else {
format!("Login failed: {}", status)
});
}
// Parse Spring Boot response format
let login_response: SpringBootLoginResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
log::info!("Login successful for user: {}", login_response.user.username);
Ok(LoginResponse {
token: login_response.session.access_token,
username: login_response.user.username,
email: login_response.user.email,
})
}
+104 -51
View File
@@ -3,10 +3,12 @@ use tauri::Manager;
use std::sync::Mutex;
use std::path::PathBuf;
use crate::utils::add_log;
use crate::state::connection_state::{AppConnectionState, ConnectionMode};
// Store backend process handle globally
// Store backend process handle and port globally
static BACKEND_PROCESS: Mutex<Option<tauri_plugin_shell::process::CommandChild>> = Mutex::new(None);
static BACKEND_STARTING: Mutex<bool> = Mutex::new(false);
static BACKEND_PORT: Mutex<Option<u16>> = Mutex::new(None);
// Helper function to reset starting flag
fn reset_starting_flag() {
@@ -14,6 +16,20 @@ fn reset_starting_flag() {
*starting_guard = false;
}
// Extract port number from "Stirling-PDF running on port: PORT" log line
fn extract_port_from_running_log(log_line: &str) -> Option<u16> {
// Look for pattern: "running on port: PORT"
if let Some(start) = log_line.find("running on port: ") {
let after_prefix = &log_line[start + 17..]; // Skip "running on port: "
// Take digits until whitespace or end of line
let port_str: String = after_prefix.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
return port_str.parse::<u16>().ok();
}
None
}
// Check if backend is already running or starting
fn check_backend_status() -> Result<(), String> {
// Check if backend is already running
@@ -24,7 +40,7 @@ fn check_backend_status() -> Result<(), String> {
return Err("Backend already running".to_string());
}
}
// Check and set starting flag to prevent multiple simultaneous starts
{
let mut starting_guard = BACKEND_STARTING.lock().unwrap();
@@ -34,7 +50,7 @@ fn check_backend_status() -> Result<(), String> {
}
*starting_guard = true;
}
Ok(())
}
@@ -46,13 +62,13 @@ fn find_bundled_jre(resource_dir: &PathBuf) -> Result<PathBuf, String> {
} else {
jre_dir.join("bin").join("java")
};
if !java_executable.exists() {
let error_msg = format!("❌ Bundled JRE not found at: {:?}", java_executable);
add_log(error_msg.clone());
return Err(error_msg);
}
add_log(format!("✅ Found bundled JRE: {:?}", java_executable));
Ok(java_executable)
}
@@ -77,20 +93,20 @@ fn find_stirling_jar(resource_dir: &PathBuf) -> Result<PathBuf, String> {
.unwrap_or(false)
})
.collect();
if jar_files.is_empty() {
let error_msg = "No Stirling-PDF JAR found in libs directory.".to_string();
add_log(error_msg.clone());
return Err(error_msg);
}
// Sort by filename to get the latest version (case-insensitive)
jar_files.sort_by(|a, b| {
let name_a = a.file_name().to_string_lossy().to_ascii_lowercase();
let name_b = b.file_name().to_string_lossy().to_ascii_lowercase();
name_b.cmp(&name_a) // Reverse order to get latest first
});
let jar_path = jar_files[0].path();
add_log(format!("📋 Selected JAR: {:?}", jar_path.file_name().unwrap()));
Ok(jar_path)
@@ -123,23 +139,23 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(home).join(".config").join("Stirling-PDF")
};
// Create subdirectories for different purposes
let config_dir = app_data_dir.join("configs");
let log_dir = app_data_dir.join("logs");
let work_dir = app_data_dir.join("workspace");
// Create all necessary directories
std::fs::create_dir_all(&app_data_dir).ok();
std::fs::create_dir_all(&log_dir).ok();
std::fs::create_dir_all(&work_dir).ok();
std::fs::create_dir_all(&config_dir).ok();
add_log(format!("📁 App data directory: {}", app_data_dir.display()));
add_log(format!("📁 Log directory: {}", log_dir.display()));
add_log(format!("📁 Working directory: {}", work_dir.display()));
add_log(format!("📁 Config directory: {}", config_dir.display()));
// Define all Java options with Tauri-specific paths
let log_path_option = format!("-Dlogging.file.path={}", log_dir.display());
@@ -150,10 +166,13 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
"-DSTIRLING_PDF_TAURI_MODE=true",
&log_path_option,
"-Dlogging.file.name=stirling-pdf.log",
"-Dserver.port=0", // Let OS assign an available port
"-Dsecurity.enableLogin=false", // Disable login for desktop mode
"-Dsecurity.csrfDisabled=true", // Disable CSRF for desktop mode
"-jar",
jar_path.to_str().unwrap()
jar_path.to_str().unwrap(),
];
// Log the equivalent command for external testing
let java_command = format!(
"TAURI_PARENT_PID={} \"{}\" {}",
@@ -163,14 +182,14 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
);
add_log(format!("🔧 Equivalent command: {}", java_command));
add_log(format!("📁 Backend logs will be in: {}", log_dir.display()));
// Additional macOS-specific checks
if cfg!(target_os = "macos") {
// Check if java executable has execute permissions
if let Ok(metadata) = std::fs::metadata(java_path) {
let permissions = metadata.permissions();
add_log(format!("🔍 Java executable permissions: {:?}", permissions));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
@@ -181,7 +200,7 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
}
}
}
// Check if we can read the JAR file
if let Ok(metadata) = std::fs::metadata(jar_path) {
add_log(format!("📦 JAR file size: {} bytes", metadata.len()));
@@ -189,7 +208,7 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
add_log("⚠️ Cannot read JAR file metadata".to_string());
}
}
let sidecar_command = app
.shell()
.command(java_path.to_str().unwrap())
@@ -199,9 +218,9 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
.env("STIRLING_PDF_CONFIG_DIR", config_dir.to_str().unwrap())
.env("STIRLING_PDF_LOG_DIR", log_dir.to_str().unwrap())
.env("STIRLING_PDF_WORK_DIR", work_dir.to_str().unwrap());
add_log("⚙️ Starting backend with bundled JRE...".to_string());
let (rx, child) = sidecar_command
.spawn()
.map_err(|e| {
@@ -209,18 +228,18 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
add_log(error_msg.clone());
error_msg
})?;
// Store the process handle
{
let mut process_guard = BACKEND_PROCESS.lock().unwrap();
*process_guard = Some(child);
}
add_log("✅ Backend started with bundled JRE, monitoring output...".to_string());
// Start monitoring output
monitor_backend_output(rx);
Ok(())
}
@@ -229,7 +248,7 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_sh
tokio::spawn(async move {
let mut _startup_detected = false;
let mut error_count = 0;
while let Some(event) = rx.recv().await {
match event {
tauri_plugin_shell::process::CommandEvent::Stdout(output) => {
@@ -237,17 +256,22 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_sh
// Strip exactly one trailing newline to avoid double newlines
let output_str = output_str.strip_suffix('\n').unwrap_or(&output_str);
add_log(format!("📤 Backend: {}", output_str));
// Look for startup indicators
if output_str.contains("Started SPDFApplication") ||
output_str.contains("Navigate to "){
// Look for actual runtime port from web server initialization
// Format: "Stirling-PDF running on port: PORT"
if output_str.contains("running on port:") {
_startup_detected = true;
add_log(format!("🎉 Backend startup detected: {}", output_str));
if let Some(port) = extract_port_from_running_log(&output_str) {
let mut port_guard = BACKEND_PORT.lock().unwrap();
*port_guard = Some(port);
add_log(format!("🎉 Backend started on port: {}", port));
add_log(format!("🔌 Navigate to: http://localhost:{}/", port));
}
}
// Look for port binding
if output_str.contains("8080") {
add_log(format!("🔌 Port 8080 related output: {}", output_str));
if output_str.contains("Started SPDFApplication") {
_startup_detected = true;
add_log(format!("🎉 Backend startup completed: {}", output_str));
}
}
tauri_plugin_shell::process::CommandEvent::Stderr(output) => {
@@ -255,13 +279,13 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_sh
// Strip exactly one trailing newline to avoid double newlines
let output_str = output_str.strip_suffix('\n').unwrap_or(&output_str);
add_log(format!("📥 Backend Error: {}", output_str));
// Look for error indicators
if output_str.contains("ERROR") || output_str.contains("Exception") || output_str.contains("FATAL") {
error_count += 1;
add_log(format!("⚠️ Backend error #{}: {}", error_count, output_str));
}
// Look for specific common issues
if output_str.contains("Address already in use") {
add_log("🚨 CRITICAL: Port 8080 is already in use by another process!".to_string());
@@ -299,7 +323,7 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_sh
}
}
}
if error_count > 0 {
println!("⚠️ Backend process ended with {} errors detected", error_count);
}
@@ -308,14 +332,36 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_sh
// Command to start the backend with bundled JRE
#[tauri::command]
pub async fn start_backend(app: tauri::AppHandle) -> Result<String, String> {
pub async fn start_backend(
app: tauri::AppHandle,
connection_state: tauri::State<'_, AppConnectionState>,
) -> Result<String, String> {
add_log("🚀 start_backend() called - Attempting to start backend with bundled JRE...".to_string());
// Check connection mode
let mode = {
let state = connection_state.0.lock().map_err(|e| {
let error_msg = format!("❌ Failed to access connection state: {}", e);
add_log(error_msg.clone());
error_msg
})?;
state.mode.clone()
};
match mode {
ConnectionMode::Offline => {
add_log("🔌 Running in Offline mode - starting local backend".to_string());
}
ConnectionMode::Server => {
add_log("🌐 Running in Server mode - starting local backend (for hybrid execution support)".to_string());
}
}
// Check if backend is already running or starting
if let Err(msg) = check_backend_status() {
return Ok(msg);
}
// Use Tauri's resource API to find the bundled JRE and JAR
let resource_dir = app.path().resource_dir().map_err(|e| {
let error_msg = format!("❌ Failed to get resource directory: {}", e);
@@ -323,53 +369,60 @@ pub async fn start_backend(app: tauri::AppHandle) -> Result<String, String> {
reset_starting_flag();
error_msg
})?;
add_log(format!("🔍 Resource directory: {:?}", resource_dir));
// Find the bundled JRE
let java_executable = find_bundled_jre(&resource_dir).map_err(|e| {
reset_starting_flag();
e
})?;
// Find the Stirling-PDF JAR
let jar_path = find_stirling_jar(&resource_dir).map_err(|e| {
reset_starting_flag();
e
})?;
// Normalize the paths to remove Windows UNC prefix
let normalized_java_path = normalize_path(&java_executable);
let normalized_jar_path = normalize_path(&jar_path);
add_log(format!("📦 Found JAR file: {:?}", jar_path));
add_log(format!("📦 Normalized JAR path: {:?}", normalized_jar_path));
add_log(format!("📦 Normalized Java path: {:?}", normalized_java_path));
// Create and start the Java command
run_stirling_pdf_jar(&app, &normalized_java_path, &normalized_jar_path).map_err(|e| {
reset_starting_flag();
e
})?;
// Wait for the backend to start
println!("⏳ Waiting for backend startup...");
tokio::time::sleep(std::time::Duration::from_millis(10000)).await;
// Reset the starting flag since startup is complete
reset_starting_flag();
add_log("✅ Backend startup sequence completed, starting flag cleared".to_string());
Ok("Backend startup initiated successfully with bundled JRE".to_string())
}
// Get the dynamically assigned backend port
#[tauri::command]
pub fn get_backend_port() -> Option<u16> {
let port_guard = BACKEND_PORT.lock().unwrap();
*port_guard
}
// Cleanup function to stop backend on app exit
pub fn cleanup_backend() {
let mut process_guard = BACKEND_PROCESS.lock().unwrap();
if let Some(child) = process_guard.take() {
let pid = child.pid();
add_log(format!("🧹 App shutting down, cleaning up backend process (PID: {})", pid));
match child.kill() {
Ok(_) => {
add_log(format!("✅ Backend process (PID: {}) terminated during cleanup", pid));
@@ -380,4 +433,4 @@ pub fn cleanup_backend() {
}
}
}
}
}
@@ -0,0 +1,111 @@
use crate::state::connection_state::{
AppConnectionState,
ConnectionMode,
ServerConfig,
};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, State};
use tauri_plugin_store::StoreExt;
const STORE_FILE: &str = "connection.json";
const FIRST_LAUNCH_KEY: &str = "setup_completed";
const CONNECTION_MODE_KEY: &str = "connection_mode";
const SERVER_CONFIG_KEY: &str = "server_config";
#[derive(Debug, Serialize, Deserialize)]
pub struct ConnectionConfig {
pub mode: ConnectionMode,
pub server_config: Option<ServerConfig>,
}
#[tauri::command]
pub async fn get_connection_config(
app_handle: AppHandle,
state: State<'_, AppConnectionState>,
) -> Result<ConnectionConfig, String> {
// Try to load from store
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
let mode = store
.get(CONNECTION_MODE_KEY)
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or(ConnectionMode::Offline);
let server_config: Option<ServerConfig> = store
.get(SERVER_CONFIG_KEY)
.and_then(|v| serde_json::from_value(v.clone()).ok());
// Update in-memory state
if let Ok(mut conn_state) = state.0.lock() {
conn_state.mode = mode.clone();
conn_state.server_config = server_config.clone();
}
Ok(ConnectionConfig {
mode,
server_config,
})
}
#[tauri::command]
pub async fn set_connection_mode(
app_handle: AppHandle,
state: State<'_, AppConnectionState>,
mode: ConnectionMode,
server_config: Option<ServerConfig>,
) -> Result<(), String> {
log::info!("Setting connection mode: {:?}", mode);
// Update in-memory state
if let Ok(mut conn_state) = state.0.lock() {
conn_state.mode = mode.clone();
conn_state.server_config = server_config.clone();
}
// Save to store
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
store.set(
CONNECTION_MODE_KEY,
serde_json::to_value(&mode).map_err(|e| format!("Failed to serialize mode: {}", e))?,
);
if let Some(config) = &server_config {
store.set(
SERVER_CONFIG_KEY,
serde_json::to_value(config)
.map_err(|e| format!("Failed to serialize config: {}", e))?,
);
} else {
store.delete(SERVER_CONFIG_KEY);
}
// Mark setup as completed
store.set(FIRST_LAUNCH_KEY, serde_json::json!(true));
store
.save()
.map_err(|e| format!("Failed to save store: {}", e))?;
log::info!("Connection mode saved successfully");
Ok(())
}
#[tauri::command]
pub async fn is_first_launch(app_handle: AppHandle) -> Result<bool, String> {
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
let setup_completed = store
.get(FIRST_LAUNCH_KEY)
.and_then(|v| v.as_bool())
.unwrap_or(false);
Ok(!setup_completed)
}
+13 -33
View File
@@ -1,36 +1,16 @@
// Command to check if backend is healthy
use reqwest;
#[tauri::command]
pub async fn check_backend_health() -> Result<bool, String> {
let client = reqwest::Client::builder()
pub async fn check_backend_health(port: u16) -> Result<bool, String> {
let url = format!("http://localhost:{}/api/v1/info/status", port);
match reqwest::Client::new()
.get(&url)
.timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
match client.get("http://localhost:8080/api/v1/info/status").send().await {
Ok(response) => {
let status = response.status();
if status.is_success() {
match response.text().await {
Ok(_body) => {
println!("✅ Backend health check successful");
Ok(true)
}
Err(e) => {
println!("⚠️ Failed to read health response: {}", e);
Ok(false)
}
}
} else {
println!("⚠️ Health check failed with status: {}", status);
Ok(false)
}
}
Err(e) => {
// Only log connection errors if they're not the common "connection refused" during startup
if !e.to_string().contains("connection refused") && !e.to_string().contains("No connection could be made") {
println!("❌ Health check error: {}", e);
}
Ok(false)
}
.send()
.await
{
Ok(response) => Ok(response.status().is_success()),
Err(_) => Ok(false), // Return false instead of error for connection failures
}
}
}
+20 -4
View File
@@ -1,9 +1,25 @@
pub mod backend;
pub mod health;
pub mod files;
pub mod connection;
pub mod auth;
pub mod default_app;
pub mod health;
pub use backend::{start_backend, cleanup_backend};
pub use health::check_backend_health;
pub use files::{get_opened_files, clear_opened_files, add_opened_file};
pub use backend::{cleanup_backend, get_backend_port, start_backend};
pub use files::{add_opened_file, clear_opened_files, get_opened_files};
pub use connection::{
get_connection_config,
is_first_launch,
set_connection_mode,
};
pub use auth::{
clear_auth_token,
clear_user_info,
get_auth_token,
get_user_info,
login,
save_auth_token,
save_user_info,
};
pub use default_app::{is_default_pdf_handler, set_as_default_pdf_handler};
pub use health::check_backend_health;
+34 -7
View File
@@ -1,18 +1,31 @@
use tauri::{RunEvent, WindowEvent, Emitter, Manager};
use tauri::{Manager, RunEvent, WindowEvent, Emitter};
mod utils;
mod commands;
mod state;
use commands::{
start_backend,
check_backend_health,
get_opened_files,
clear_opened_files,
cleanup_backend,
add_opened_file,
check_backend_health,
cleanup_backend,
clear_auth_token,
clear_opened_files,
clear_user_info,
is_default_pdf_handler,
get_auth_token,
get_backend_port,
get_connection_config,
get_opened_files,
get_user_info,
is_first_launch,
login,
save_auth_token,
save_user_info,
set_connection_mode,
set_as_default_pdf_handler,
start_backend,
};
use state::connection_state::AppConnectionState;
use utils::{add_log, get_tauri_logs};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -20,6 +33,9 @@ pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_store::Builder::new().build())
.manage(AppConnectionState::default())
.plugin(tauri_plugin_single_instance::init(|app, args, _cwd| {
// This callback runs when a second instance tries to start
add_log(format!("📂 Second instance detected with args: {:?}", args));
@@ -60,12 +76,23 @@ pub fn run() {
})
.invoke_handler(tauri::generate_handler![
start_backend,
check_backend_health,
get_backend_port,
get_opened_files,
clear_opened_files,
get_tauri_logs,
get_connection_config,
set_connection_mode,
is_default_pdf_handler,
set_as_default_pdf_handler,
is_first_launch,
check_backend_health,
login,
save_auth_token,
get_auth_token,
clear_auth_token,
save_user_info,
get_user_info,
clear_user_info,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
@@ -0,0 +1,45 @@
use serde::{Deserialize, Serialize};
use std::sync::Mutex;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ConnectionMode {
Offline,
Server,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum ServerType {
SaaS,
SelfHosted,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
pub url: String,
pub server_type: ServerType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConnectionState {
pub mode: ConnectionMode,
pub server_config: Option<ServerConfig>,
}
impl Default for ConnectionState {
fn default() -> Self {
Self {
mode: ConnectionMode::Offline,
server_config: None,
}
}
}
pub struct AppConnectionState(pub Mutex<ConnectionState>);
impl Default for AppConnectionState {
fn default() -> Self {
Self(Mutex::new(ConnectionState::default()))
}
}
+1
View File
@@ -0,0 +1 @@
pub mod connection_state;
+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,
@@ -1,9 +1,10 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { Stack, Alert, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { DrawingControls } from '@app/components/annotation/shared/DrawingControls';
import { ColorPicker } from '@app/components/annotation/shared/ColorPicker';
import { usePDFAnnotation } from '@app/components/annotation/providers/PDFAnnotationProvider';
import { useSignature } from '@app/contexts/SignatureContext';
export interface AnnotationToolConfig {
enableDrawing?: boolean;
@@ -32,10 +33,34 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
undo,
redo
} = usePDFAnnotation();
const { historyApiRef } = useSignature();
const [selectedColor, setSelectedColor] = useState('#000000');
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [signatureData, setSignatureData] = useState<string | null>(null);
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
const historyApiInstance = historyApiRef.current;
useEffect(() => {
if (!historyApiInstance) {
setHistoryAvailability({ canUndo: false, canRedo: false });
return;
}
const updateAvailability = () => {
setHistoryAvailability({
canUndo: historyApiInstance.canUndo?.() ?? false,
canRedo: historyApiInstance.canRedo?.() ?? false,
});
};
const unsubscribe = historyApiInstance.subscribe?.(updateAvailability);
updateAvailability();
return () => {
unsubscribe?.();
};
}, [historyApiInstance]);
const handleSignatureDataChange = (data: string | null) => {
setSignatureData(data);
@@ -54,6 +79,8 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
<DrawingControls
onUndo={undo}
onRedo={redo}
canUndo={historyAvailability.canUndo}
canRedo={historyAvailability.canRedo}
onPlaceSignature={config.showPlaceButton ? handlePlaceSignature : undefined}
hasSignatureData={!!signatureData}
disabled={disabled}
@@ -86,4 +113,4 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
/>
</Stack>
);
};
};
@@ -1,5 +1,6 @@
import React from 'react';
import { Modal, Stack, ColorPicker as MantineColorPicker, Group, Button, ColorSwatch } from '@mantine/core';
import { useTranslation } from 'react-i18next';
interface ColorPickerProps {
isOpen: boolean;
@@ -14,13 +15,16 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
onClose,
selectedColor,
onColorChange,
title = "Choose Color"
title
}) => {
const { t } = useTranslation();
const resolvedTitle = title ?? t('colorPicker.title', 'Choose colour');
return (
<Modal
opened={isOpen}
onClose={onClose}
title={title}
title={resolvedTitle}
size="sm"
centered
>
@@ -36,7 +40,7 @@ export const ColorPicker: React.FC<ColorPickerProps> = ({
/>
<Group justify="flex-end">
<Button onClick={onClose}>
Done
{t('common.done', 'Done')}
</Button>
</Group>
</Stack>
@@ -64,4 +68,4 @@ export const ColorSwatchButton: React.FC<ColorSwatchButtonProps> = ({
onClick={onClick}
/>
);
};
};
@@ -1,5 +1,6 @@
import React, { useRef, useState } from 'react';
import { Paper, Button, Modal, Stack, Text, Popover, ColorPicker as MantineColorPicker } from '@mantine/core';
import React, { useEffect, useRef, useState } from 'react';
import { Paper, Button, Modal, Stack, Text, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ColorSwatchButton } from '@app/components/annotation/shared/ColorPicker';
import PenSizeSelector from '@app/components/tools/sign/PenSizeSelector';
import SignaturePad from 'signature_pad';
@@ -20,6 +21,7 @@ interface DrawingCanvasProps {
modalWidth?: number;
modalHeight?: number;
additionalButtons?: React.ReactNode;
initialSignatureData?: string;
}
export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
@@ -34,12 +36,14 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
disabled = false,
width = 400,
height = 150,
initialSignatureData,
}) => {
const { t } = useTranslation();
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const modalCanvasRef = useRef<HTMLCanvasElement>(null);
const padRef = useRef<SignaturePad | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const [colorPickerOpen, setColorPickerOpen] = useState(false);
const [savedSignatureData, setSavedSignatureData] = useState<string | null>(null);
const initPad = (canvas: HTMLCanvasElement) => {
if (!padRef.current) {
@@ -55,6 +59,18 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
minDistance: 5,
velocityFilterWeight: 0.7,
});
// Restore saved signature data if it exists
if (savedSignatureData) {
const img = new Image();
img.onload = () => {
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
}
};
img.src = savedSignatureData;
}
}
};
@@ -104,36 +120,35 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
return trimmedCanvas.toDataURL('image/png');
};
const renderPreview = (dataUrl: string) => {
const canvas = previewCanvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const img = new Image();
img.onload = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const scale = Math.min(canvas.width / img.width, canvas.height / img.height);
const scaledWidth = img.width * scale;
const scaledHeight = img.height * scale;
const x = (canvas.width - scaledWidth) / 2;
const y = (canvas.height - scaledHeight) / 2;
ctx.drawImage(img, x, y, scaledWidth, scaledHeight);
};
img.src = dataUrl;
};
const closeModal = () => {
if (padRef.current && !padRef.current.isEmpty()) {
const canvas = modalCanvasRef.current;
if (canvas) {
const trimmedPng = trimCanvas(canvas);
const untrimmedPng = canvas.toDataURL('image/png');
setSavedSignatureData(untrimmedPng); // Save untrimmed for restoration
onSignatureDataChange(trimmedPng);
// Update preview canvas with proper aspect ratio
const img = new Image();
img.onload = () => {
if (previewCanvasRef.current) {
const ctx = previewCanvasRef.current.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
// Calculate scaling to fit within preview canvas while maintaining aspect ratio
const scale = Math.min(
previewCanvasRef.current.width / img.width,
previewCanvasRef.current.height / img.height
);
const scaledWidth = img.width * scale;
const scaledHeight = img.height * scale;
const x = (previewCanvasRef.current.width - scaledWidth) / 2;
const y = (previewCanvasRef.current.height - scaledHeight) / 2;
ctx.drawImage(img, x, y, scaledWidth, scaledHeight);
}
}
};
img.src = trimmedPng;
renderPreview(trimmedPng);
if (onDrawingComplete) {
onDrawingComplete();
@@ -157,6 +172,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
ctx.clearRect(0, 0, previewCanvasRef.current.width, previewCanvasRef.current.height);
}
}
setSavedSignatureData(null); // Clear saved signature
onSignatureDataChange(null);
};
@@ -173,67 +189,73 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
}
};
useEffect(() => {
updatePenColor(selectedColor);
}, [selectedColor]);
useEffect(() => {
updatePenSize(penSize);
}, [penSize]);
useEffect(() => {
const canvas = previewCanvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
if (!initialSignatureData) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
setSavedSignatureData(null);
return;
}
renderPreview(initialSignatureData);
setSavedSignatureData(initialSignatureData);
}, [initialSignatureData]);
return (
<>
<Paper withBorder p="md">
<Stack gap="sm">
<Text fw={500}>Draw your signature</Text>
<PrivateContent>
<canvas
ref={previewCanvasRef}
width={width}
height={height}
style={{
border: '1px solid #ccc',
borderRadius: '4px',
cursor: disabled ? 'default' : 'pointer',
backgroundColor: '#ffffff',
width: '100%',
}}
onClick={disabled ? undefined : openModal}
/>
<Text fw={500}>{t('sign.canvas.heading', 'Draw your signature')}</Text>
<canvas
ref={previewCanvasRef}
width={width}
height={height}
style={{
border: '1px solid #ccc',
borderRadius: '4px',
cursor: disabled ? 'default' : 'pointer',
backgroundColor: '#ffffff',
width: '100%',
}}
onClick={disabled ? undefined : openModal}
/>
</PrivateContent>
<Text size="sm" c="dimmed" ta="center">
Click to open drawing canvas
{t('sign.canvas.clickToOpen', 'Click to open the drawing canvas')}
</Text>
</Stack>
</Paper>
<Modal opened={modalOpen} onClose={closeModal} title="Draw Your Signature" size="auto" centered>
<Modal opened={modalOpen} onClose={closeModal} title={t('sign.canvas.modalTitle', 'Draw your signature')} size="auto" centered>
<Stack gap="md">
<div style={{ display: 'flex', gap: '20px', alignItems: 'flex-end' }}>
<div>
<Text size="sm" fw={500} mb="xs">Color</Text>
<Popover
opened={colorPickerOpen}
onChange={setColorPickerOpen}
position="bottom-start"
withArrow
withinPortal={false}
>
<Popover.Target>
<div>
<ColorSwatchButton
color={selectedColor}
onClick={() => setColorPickerOpen(!colorPickerOpen)}
/>
</div>
</Popover.Target>
<Popover.Dropdown>
<MantineColorPicker
format="hex"
value={selectedColor}
onChange={(color) => {
onColorSwatchClick();
updatePenColor(color);
}}
swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']}
/>
</Popover.Dropdown>
</Popover>
</div>
<div>
<Text size="sm" fw={500} mb="xs">Pen Size</Text>
<Group gap="lg" align="flex-end" wrap="wrap">
<Stack gap={4} style={{ minWidth: 120 }}>
<Text size="sm" fw={500}>
{t('sign.canvas.colorLabel', 'Colour')}
</Text>
<ColorSwatchButton
color={selectedColor}
onClick={onColorSwatchClick}
/>
</Stack>
<Stack gap={4} style={{ minWidth: 120 }}>
<Text size="sm" fw={500}>
{t('sign.canvas.penSizeLabel', 'Pen size')}
</Text>
<PenSizeSelector
value={penSize}
inputValue={penSizeInput}
@@ -242,12 +264,12 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
updatePenSize(size);
}}
onInputChange={onPenSizeInputChange}
placeholder="Size"
placeholder={t('sign.canvas.penSizePlaceholder', 'Size')}
size="compact-sm"
style={{ width: '60px' }}
style={{ width: '80px' }}
/>
</div>
</div>
</Stack>
</Group>
<PrivateContent>
<canvas
@@ -262,8 +284,8 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
touchAction: 'none',
backgroundColor: 'white',
width: '100%',
maxWidth: '800px',
height: '400px',
maxWidth: '50rem',
height: '25rem',
cursor: 'crosshair',
}}
/>
@@ -271,10 +293,10 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Button variant="subtle" color="red" onClick={clear}>
Clear Canvas
{t('sign.canvas.clear', 'Clear canvas')}
</Button>
<Button onClick={closeModal}>
Done
{t('common.done', 'Done')}
</Button>
</div>
</Stack>
@@ -1,6 +1,7 @@
import React from 'react';
import { Group, Button } from '@mantine/core';
import { Group, Button, ActionIcon, Tooltip } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { LocalIcon } from '@app/components/shared/LocalIcon';
interface DrawingControlsProps {
onUndo?: () => void;
@@ -8,8 +9,11 @@ interface DrawingControlsProps {
onPlaceSignature?: () => void;
hasSignatureData?: boolean;
disabled?: boolean;
canUndo?: boolean;
canRedo?: boolean;
showPlaceButton?: boolean;
placeButtonText?: string;
additionalControls?: React.ReactNode;
}
export const DrawingControls: React.FC<DrawingControlsProps> = ({
@@ -18,30 +22,48 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
onPlaceSignature,
hasSignatureData = false,
disabled = false,
canUndo = true,
canRedo = true,
showPlaceButton = true,
placeButtonText = "Update and Place"
placeButtonText = "Update and Place",
additionalControls,
}) => {
const { t } = useTranslation();
const undoDisabled = disabled || !canUndo;
const redoDisabled = disabled || !canRedo;
return (
<Group gap="sm">
{/* Undo/Redo Controls */}
<Button
variant="outline"
onClick={onUndo}
disabled={disabled}
flex={1}
>
{t('sign.undo', 'Undo')}
</Button>
<Button
variant="outline"
onClick={onRedo}
disabled={disabled}
flex={1}
>
{t('sign.redo', 'Redo')}
</Button>
<Group gap="xs" wrap="nowrap" align="center">
{onUndo && (
<Tooltip label={t('sign.undo', 'Undo')}>
<ActionIcon
variant="subtle"
size="lg"
aria-label={t('sign.undo', 'Undo')}
onClick={onUndo}
disabled={undoDisabled}
color={undoDisabled ? 'gray' : 'blue'}
>
<LocalIcon icon="undo" width={20} height={20} style={{ color: 'currentColor' }} />
</ActionIcon>
</Tooltip>
)}
{onRedo && (
<Tooltip label={t('sign.redo', 'Redo')}>
<ActionIcon
variant="subtle"
size="lg"
aria-label={t('sign.redo', 'Redo')}
onClick={onRedo}
disabled={redoDisabled}
color={redoDisabled ? 'gray' : 'blue'}
>
<LocalIcon icon="redo" width={20} height={20} style={{ color: 'currentColor' }} />
</ActionIcon>
</Tooltip>
)}
{additionalControls}
{/* Place Signature Button */}
{showPlaceButton && onPlaceSignature && (
@@ -50,11 +72,11 @@ export const DrawingControls: React.FC<DrawingControlsProps> = ({
color="blue"
onClick={onPlaceSignature}
disabled={disabled || !hasSignatureData}
flex={1}
ml="auto"
>
{placeButtonText}
</Button>
)}
</Group>
);
};
};
@@ -34,12 +34,18 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
const [fontSizeInput, setFontSizeInput] = useState(fontSize.toString());
const fontSizeCombobox = useCombobox();
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const [colorInput, setColorInput] = useState(textColor);
// Sync font size input with prop changes
useEffect(() => {
setFontSizeInput(fontSize.toString());
}, [fontSize]);
// Sync color input with prop changes
useEffect(() => {
setColorInput(textColor);
}, [textColor]);
const fontOptions = [
{ value: 'Helvetica', label: 'Helvetica' },
{ value: 'Times-Roman', label: 'Times' },
@@ -50,10 +56,15 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
const fontSizeOptions = ['8', '12', '16', '20', '24', '28', '32', '36', '40', '48', '56', '64', '72', '80', '96', '112', '128', '144', '160', '176', '192', '200'];
// Validate hex color
const isValidHexColor = (color: string): boolean => {
return /^#[0-9A-Fa-f]{6}$/.test(color);
};
return (
<Stack gap="sm">
<TextInput
label={label || t('sign.text.name', 'Signer Name')}
label={label || t('sign.text.name', 'Signer name')}
placeholder={placeholder || t('sign.text.placeholder', 'Enter your full name')}
value={text}
onChange={(e) => onTextChange(e.target.value)}
@@ -63,7 +74,7 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
{/* Font Selection */}
<Select
label="Font"
label={t('sign.text.fontLabel', 'Font')}
value={fontFamily}
onChange={(value) => onFontFamilyChange(value || 'Helvetica')}
data={fontOptions}
@@ -88,8 +99,8 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
>
<Combobox.Target>
<TextInput
label="Font Size"
placeholder="Type or select font size (8-200)"
label={t('sign.text.fontSizeLabel', 'Font size')}
placeholder={t('sign.text.fontSizePlaceholder', 'Type or select font size (8-200)')}
value={fontSizeInput}
onChange={(event) => {
const value = event.currentTarget.value;
@@ -135,14 +146,29 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
{onTextColorChange && (
<Box>
<TextInput
label="Text Color"
value={textColor}
readOnly
label={t('sign.text.colorLabel', 'Text colour')}
value={colorInput}
placeholder="#000000"
disabled={disabled}
onClick={() => !disabled && setIsColorPickerOpen(true)}
style={{ cursor: disabled ? 'default' : 'pointer' }}
onChange={(e) => {
const value = e.currentTarget.value;
setColorInput(value);
// Update color if valid hex
if (isValidHexColor(value)) {
onTextColorChange(value);
}
}}
onBlur={() => {
// Revert to valid color on blur if invalid
if (!isValidHexColor(colorInput)) {
setColorInput(textColor);
}
}}
style={{ width: '100%' }}
rightSection={
<Box
onClick={() => !disabled && setIsColorPickerOpen(true)}
style={{
width: 24,
height: 24,
@@ -169,4 +195,4 @@ export const TextInputWithFont: React.FC<TextInputWithFontProps> = ({
)}
</Stack>
);
};
};
@@ -0,0 +1,415 @@
import React, { useState, useEffect } from 'react';
import { Modal, Stack, Text, Badge, Button, Group, Loader, Center, Divider, Box, Collapse } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { updateService, UpdateSummary, FullUpdateInfo, MachineInfo } from '@app/services/updateService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import DownloadIcon from '@mui/icons-material/Download';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
interface UpdateModalProps {
opened: boolean;
onClose: () => void;
currentVersion: string;
updateSummary: UpdateSummary;
machineInfo: MachineInfo;
}
const UpdateModal: React.FC<UpdateModalProps> = ({
opened,
onClose,
currentVersion,
updateSummary,
machineInfo,
}) => {
const { t } = useTranslation();
const [fullUpdateInfo, setFullUpdateInfo] = useState<FullUpdateInfo | null>(null);
const [loading, setLoading] = useState(true);
const [expandedVersions, setExpandedVersions] = useState<Set<number>>(new Set([0]));
useEffect(() => {
if (opened) {
setLoading(true);
setExpandedVersions(new Set([0]));
updateService.getFullUpdateInfo(currentVersion, machineInfo).then((info) => {
setFullUpdateInfo(info);
setLoading(false);
});
}
}, [opened, currentVersion, machineInfo]);
const toggleVersion = (index: number) => {
setExpandedVersions((prev) => {
const newSet = new Set(prev);
if (newSet.has(index)) {
newSet.delete(index);
} else {
newSet.add(index);
}
return newSet;
});
};
const getPriorityColor = (priority: string): string => {
switch (priority?.toLowerCase()) {
case 'urgent':
return 'red';
case 'normal':
return 'blue';
case 'minor':
return 'cyan';
case 'low':
return 'gray';
default:
return 'gray';
}
};
const getPriorityLabel = (priority: string): string => {
const key = priority?.toLowerCase();
return t(`update.priority.${key}`, priority || 'Normal');
};
const downloadUrl = updateService.getDownloadUrl(machineInfo);
return (
<Modal
opened={opened}
onClose={onClose}
title={
<Text fw={600} size="lg">
{t('update.modalTitle', 'Update Available')}
</Text>
}
centered
size="xl"
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
styles={{
body: {
maxHeight: '75vh',
overflowY: 'auto',
},
}}
>
<Stack gap="lg" pt="md">
{/* Version Summary Section */}
<Box>
<Group justify="space-between" align="flex-start" wrap="nowrap" mb="md">
<Stack gap={4} style={{ flex: 1 }}>
<Text size="xs" c="dimmed" tt="uppercase" fw={500}>
{t('update.current', 'Current Version')}
</Text>
<Text fw={600} size="xl">
{currentVersion}
</Text>
</Stack>
<Stack gap={4} style={{ flex: 1 }} ta="center">
<Text size="xs" c="dimmed" tt="uppercase" fw={500}>
{t('update.priorityLabel', 'Priority')}
</Text>
<Badge
color={getPriorityColor(updateSummary.max_priority)}
size="lg"
variant="filled"
style={{ alignSelf: 'center' }}
>
{getPriorityLabel(updateSummary.max_priority)}
</Badge>
</Stack>
<Stack gap={4} style={{ flex: 1 }} ta="right">
<Text size="xs" c="dimmed" tt="uppercase" fw={500}>
{t('update.latest', 'Latest Version')}
</Text>
<Text fw={600} size="xl" c="blue">
{updateSummary.latest_version}
</Text>
</Stack>
</Group>
{updateSummary.latest_stable_version && (
<Box
style={{
background: 'var(--mantine-color-green-0)',
padding: '10px 16px',
borderRadius: '8px',
border: '1px solid var(--mantine-color-green-2)',
}}
>
<Group gap="xs" justify="center">
<Text size="sm" fw={500}>
{t('update.latestStable', 'Latest Stable')}:
</Text>
<Text size="sm" fw={600} c="green">
{updateSummary.latest_stable_version}
</Text>
</Group>
</Box>
)}
</Box>
{/* Recommended action */}
{updateSummary.recommended_action && (
<Box
style={{
background: 'var(--mantine-color-blue-light)',
padding: '12px 16px',
borderRadius: '8px',
border: '1px solid var(--mantine-color-blue-outline)',
}}
>
<Group gap="xs" wrap="nowrap" align="flex-start">
<InfoOutlinedIcon style={{ fontSize: 18, color: 'var(--mantine-color-blue-filled)', marginTop: 2 }} />
<Box style={{ flex: 1 }}>
<Text size="xs" fw={600} mb={4} tt="uppercase">
{t('update.recommendedAction', 'Recommended Action')}
</Text>
<Text size="sm">
{updateSummary.recommended_action}
</Text>
</Box>
</Group>
</Box>
)}
{/* Breaking changes warning */}
{updateSummary.any_breaking && (
<Box
style={{
background: 'var(--mantine-color-orange-light)',
padding: '12px 16px',
borderRadius: '8px',
border: '1px solid var(--mantine-color-orange-outline)',
}}
>
<Group gap="xs" wrap="nowrap" align="flex-start">
<WarningAmberIcon style={{ fontSize: 18, color: 'var(--mantine-color-orange-filled)', marginTop: 2 }} />
<Box style={{ flex: 1 }}>
<Text size="xs" fw={600} mb={4} tt="uppercase">
{t('update.breakingChangesDetected', 'Breaking Changes Detected')}
</Text>
<Text size="sm">
{t(
'update.breakingChangesMessage',
'Some versions contain breaking changes. Please review the migration guides below before updating.'
)}
</Text>
</Box>
</Group>
</Box>
)}
{/* Migration guides */}
{updateSummary.migration_guides && updateSummary.migration_guides.length > 0 && (
<>
<Divider />
<Stack gap="xs">
<Text fw={600} size="sm" tt="uppercase" c="dimmed">
{t('update.migrationGuides', 'Migration Guides')}
</Text>
{updateSummary.migration_guides.map((guide, idx) => (
<Box
key={idx}
style={{
border: '1px solid var(--mantine-color-gray-3)',
padding: '12px 16px',
borderRadius: '8px',
background: 'var(--mantine-color-gray-0)',
}}
>
<Group justify="space-between" align="center" wrap="nowrap">
<Box style={{ flex: 1 }}>
<Text fw={600} size="sm">
{t('update.version', 'Version')} {guide.version}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{guide.notes}
</Text>
</Box>
<Button
component="a"
href={guide.url}
target="_blank"
variant="light"
size="xs"
rightSection={<OpenInNewIcon style={{ fontSize: 14 }} />}
>
{t('update.viewGuide', 'View Guide')}
</Button>
</Group>
</Box>
))}
</Stack>
</>
)}
{/* Version details */}
<Divider />
{loading ? (
<Center py="xl">
<Stack align="center" gap="sm">
<Loader size="md" />
<Text size="sm" c="dimmed">
{t('update.loadingDetailedInfo', 'Loading detailed information...')}
</Text>
</Stack>
</Center>
) : fullUpdateInfo && fullUpdateInfo.new_versions && fullUpdateInfo.new_versions.length > 0 ? (
<Stack gap="xs">
<Group justify="space-between" align="center">
<Text fw={600} size="sm" tt="uppercase" c="dimmed">
{t('update.availableUpdates', 'Available Updates')}
</Text>
<Badge variant="light" color="gray">
{fullUpdateInfo.new_versions.length} {fullUpdateInfo.new_versions.length === 1 ? 'version' : 'versions'}
</Badge>
</Group>
<Stack gap="xs">
{fullUpdateInfo.new_versions.map((version, index) => {
const isExpanded = expandedVersions.has(index);
return (
<Box
key={index}
style={{
border: '1px solid var(--mantine-color-gray-3)',
borderRadius: '8px',
overflow: 'hidden',
}}
>
<Group
justify="space-between"
align="center"
p="md"
style={{
cursor: 'pointer',
background: isExpanded ? 'var(--mantine-color-gray-0)' : 'transparent',
transition: 'background 0.15s ease',
}}
onClick={() => toggleVersion(index)}
>
<Group gap="md" style={{ flex: 1 }}>
<Box>
<Text fw={600} size="sm" c="dimmed" mb={2}>
{t('update.version', 'Version')}
</Text>
<Text fw={700} size="lg">
{version.version}
</Text>
</Box>
<Badge color={getPriorityColor(version.priority)} size="md">
{getPriorityLabel(version.priority)}
</Badge>
</Group>
<Group gap="xs">
<Button
component="a"
href={`https://github.com/Stirling-Tools/Stirling-PDF/releases/tag/v${version.version}`}
target="_blank"
variant="light"
size="xs"
onClick={(e) => e.stopPropagation()}
rightSection={<OpenInNewIcon style={{ fontSize: 14 }} />}
>
{t('update.releaseNotes', 'Release Notes')}
</Button>
{isExpanded ? (
<ExpandLessIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
) : (
<ExpandMoreIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
)}
</Group>
</Group>
<Collapse in={isExpanded}>
<Box p="md" pt={0} style={{ borderTop: '1px solid var(--mantine-color-gray-2)' }}>
<Stack gap="md" mt="md">
<Box>
<Text fw={600} size="sm" mb={6}>
{version.announcement.title}
</Text>
<Text size="sm" c="dimmed" style={{ lineHeight: 1.6 }}>
{version.announcement.message}
</Text>
</Box>
{version.compatibility.breaking_changes && (
<Box
style={{
background: 'var(--mantine-color-orange-light)',
padding: '12px',
borderRadius: '6px',
border: '1px solid var(--mantine-color-orange-outline)',
}}
>
<Group gap="xs" align="flex-start" wrap="nowrap" mb="xs">
<WarningAmberIcon style={{ fontSize: 16, color: 'var(--mantine-color-orange-filled)', marginTop: 2 }} />
<Text size="xs" fw={600} tt="uppercase">
{t('update.breakingChanges', 'Breaking Changes')}
</Text>
</Group>
<Text size="sm" mb="xs">
{version.compatibility.breaking_description ||
t('update.breakingChangesDefault', 'This version contains breaking changes.')}
</Text>
{version.compatibility.migration_guide_url && (
<Button
component="a"
href={version.compatibility.migration_guide_url}
target="_blank"
variant="light"
color="orange"
size="xs"
rightSection={<OpenInNewIcon style={{ fontSize: 14 }} />}
>
{t('update.migrationGuide', 'Migration Guide')}
</Button>
)}
</Box>
)}
</Stack>
</Box>
</Collapse>
</Box>
);
})}
</Stack>
</Stack>
) : null}
{/* Action buttons */}
<Divider />
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose}>
{t('update.close', 'Close')}
</Button>
<Button
variant="light"
component="a"
href="https://github.com/Stirling-Tools/Stirling-PDF/releases"
target="_blank"
rightSection={<OpenInNewIcon style={{ fontSize: 16 }} />}
>
{t('update.viewAllReleases', 'View All Releases')}
</Button>
{downloadUrl && (
<Button
component="a"
href={downloadUrl}
target="_blank"
color="green"
leftSection={<DownloadIcon style={{ fontSize: 16 }} />}
>
{t('update.downloadLatest', 'Download Latest')}
</Button>
)}
</Group>
</Stack>
</Modal>
);
};
export default UpdateModal;
@@ -1,10 +1,12 @@
import React, { useState, useEffect } from 'react';
import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl, Code, Group, Anchor, ActionIcon } from '@mantine/core';
import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl, Code, Group, Anchor, ActionIcon, Button, Badge, Alert } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { usePreferences } from '@app/contexts/PreferencesContext';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import type { ToolPanelMode } from '@app/constants/toolPanel';
import LocalIcon from '@app/components/shared/LocalIcon';
import { updateService, UpdateSummary } from '@app/services/updateService';
import UpdateModal from '@app/components/shared/UpdateModal';
const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4;
const BANNER_DISMISSED_KEY = 'stirlingpdf_features_banner_dismissed';
@@ -22,12 +24,44 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
// Check localStorage on mount
return localStorage.getItem(BANNER_DISMISSED_KEY) === 'true';
});
const [updateSummary, setUpdateSummary] = useState<UpdateSummary | null>(null);
const [updateModalOpened, setUpdateModalOpened] = useState(false);
const [checkingUpdate, setCheckingUpdate] = useState(false);
// Sync local state with preference changes
useEffect(() => {
setFileLimitInput(preferences.autoUnzipFileLimit);
}, [preferences.autoUnzipFileLimit]);
// Check for updates on mount
useEffect(() => {
if (config?.appVersion && config?.machineType) {
checkForUpdate();
}
}, [config?.appVersion, config?.machineType]);
const checkForUpdate = async () => {
if (!config?.appVersion || !config?.machineType) {
return;
}
setCheckingUpdate(true);
const machineInfo = {
machineType: config.machineType,
activeSecurity: config.activeSecurity ?? false,
licenseType: config.license ?? 'NORMAL',
};
const summary = await updateService.getUpdateSummary(config.appVersion, machineInfo);
if (summary) {
const isNewerVersion = updateService.compareVersions(summary.latest_version, config.appVersion) > 0;
if (isNewerVersion) {
setUpdateSummary(summary);
}
}
setCheckingUpdate(false);
};
// Check if login is disabled
const loginDisabled = !config?.enableLogin;
@@ -170,6 +204,108 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
</Tooltip>
</Stack>
</Paper>
{/* Update Check Section */}
{config?.appVersion && (
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<Group justify="space-between" align="center">
<div>
<Text fw={600} size="sm">
{t('settings.general.updates.title', 'Software Updates')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.updates.description', 'Check for updates and view version information')}
</Text>
</div>
{updateSummary && (
<Badge
color={updateSummary.max_priority === 'urgent' ? 'red' : 'blue'}
variant="filled"
>
{updateSummary.max_priority === 'urgent'
? t('update.urgentUpdateAvailable', 'Urgent Update')
: t('update.updateAvailable', 'Update Available')}
</Badge>
)}
</Group>
</div>
<Group justify="space-between" align="center">
<div>
<Text size="sm" c="dimmed">
{t('settings.general.updates.currentVersion', 'Current Version')}:{' '}
<Text component="span" fw={500}>
{config.appVersion}
</Text>
</Text>
{updateSummary && (
<Text size="sm" c="dimmed" mt={4}>
{t('settings.general.updates.latestVersion', 'Latest Version')}:{' '}
<Text component="span" fw={500} c="blue">
{updateSummary.latest_version}
</Text>
</Text>
)}
</div>
<Group gap="sm">
<Button
size="sm"
variant="default"
onClick={checkForUpdate}
loading={checkingUpdate}
leftSection={<LocalIcon icon="refresh-rounded" width="1rem" height="1rem" />}
>
{t('settings.general.updates.checkForUpdates', 'Check for Updates')}
</Button>
{updateSummary && (
<Button
size="sm"
color={updateSummary.max_priority === 'urgent' ? 'red' : 'blue'}
onClick={() => setUpdateModalOpened(true)}
leftSection={<LocalIcon icon="system-update-rounded" width="1rem" height="1rem" />}
>
{t('settings.general.updates.viewDetails', 'View Details')}
</Button>
)}
</Group>
</Group>
{updateSummary?.any_breaking && (
<Alert
color="orange"
title={t('update.breakingChangesDetected', 'Breaking Changes Detected')}
styles={{
title: { fontWeight: 600 }
}}
>
<Text size="sm">
{t(
'update.breakingChangesMessage',
'Some versions contain breaking changes. Please review the migration guides before updating.'
)}
</Text>
</Alert>
)}
</Stack>
</Paper>
)}
{/* Update Modal */}
{updateSummary && config?.appVersion && config?.machineType && (
<UpdateModal
opened={updateModalOpened}
onClose={() => setUpdateModalOpened(false)}
currentVersion={config.appVersion}
updateSummary={updateSummary}
machineInfo={{
machineType: config.machineType,
activeSecurity: config.activeSecurity ?? false,
licenseType: config.license ?? 'NORMAL',
}}
/>
)}
</Stack>
);
};
@@ -0,0 +1,292 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ActionIcon, Alert, Badge, Box, Card, Group, Stack, Text, TextInput, Tooltip } from '@mantine/core';
import { LocalIcon } from '@app/components/shared/LocalIcon';
import { MAX_SAVED_SIGNATURES, SavedSignature, SavedSignatureType } from '@app/hooks/tools/sign/useSavedSignatures';
interface SavedSignaturesSectionProps {
signatures: SavedSignature[];
disabled?: boolean;
isAtCapacity: boolean;
onUseSignature: (signature: SavedSignature) => void;
onDeleteSignature: (signature: SavedSignature) => void;
onRenameSignature: (id: string, label: string) => void;
}
const typeBadgeColor: Record<SavedSignatureType, string> = {
canvas: 'indigo',
image: 'teal',
text: 'grape',
};
export const SavedSignaturesSection = ({
signatures,
disabled = false,
isAtCapacity,
onUseSignature,
onDeleteSignature,
onRenameSignature,
}: SavedSignaturesSectionProps) => {
const { t } = useTranslation();
const [labelDrafts, setLabelDrafts] = useState<Record<string, string>>({});
const [activeIndex, setActiveIndex] = useState(0);
const activeSignature = signatures[activeIndex];
const appliedSignatureIdRef = useRef<string | null>(null);
const onUseSignatureRef = useRef(onUseSignature);
useEffect(() => {
onUseSignatureRef.current = onUseSignature;
}, [onUseSignature]);
useEffect(() => {
setLabelDrafts(prev => {
const nextDrafts: Record<string, string> = {};
signatures.forEach(sig => {
nextDrafts[sig.id] = prev[sig.id] ?? sig.label ?? '';
});
return nextDrafts;
});
}, [signatures]);
useEffect(() => {
if (signatures.length === 0) {
setActiveIndex(0);
return;
}
setActiveIndex(prev => Math.min(prev, Math.max(signatures.length - 1, 0)));
}, [signatures.length]);
const handleNavigate = useCallback(
(direction: 'prev' | 'next') => {
setActiveIndex(prev => {
if (direction === 'prev') {
return Math.max(0, prev - 1);
}
return Math.min(signatures.length - 1, prev + 1);
});
},
[signatures.length]
);
const renderPreview = (signature: SavedSignature) => {
if (signature.type === 'text') {
return (
<Box
component="div"
style={{
fontFamily: signature.fontFamily,
fontSize: `${signature.fontSize}px`,
color: signature.textColor,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '120px',
borderRadius: '0.5rem',
backgroundColor: '#ffffff',
padding: '0.5rem',
textAlign: 'center',
overflow: 'hidden',
}}
>
<Text
size="lg"
style={{
fontFamily: signature.fontFamily,
color: signature.textColor,
whiteSpace: 'nowrap',
}}
>
{signature.signerName}
</Text>
</Box>
);
}
return (
<Box
component="div"
style={{
backgroundColor: '#ffffff',
borderRadius: '0.5rem',
height: '120px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0.5rem',
}}
>
<Box
component="img"
src={signature.dataUrl}
alt={signature.label}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
}}
/>
</Box>
);
};
const emptyState = (
<Card withBorder>
<Stack gap="xs">
<Text fw={500}>{t('sign.saved.emptyTitle', 'No saved signatures yet')}</Text>
<Text size="sm" c="dimmed">
{t(
'sign.saved.emptyDescription',
'Draw, upload, or type a signature above, then use "Save to library" to keep up to {{max}} favourites ready to use.',
{ max: MAX_SAVED_SIGNATURES }
)}
</Text>
</Stack>
</Card>
);
const typeLabel = (type: SavedSignatureType) => {
switch (type) {
case 'canvas':
return t('sign.saved.type.canvas', 'Drawing');
case 'image':
return t('sign.saved.type.image', 'Upload');
case 'text':
return t('sign.saved.type.text', 'Text');
default:
return type;
}
};
const handleLabelBlur = (signature: SavedSignature) => {
const nextValue = labelDrafts[signature.id]?.trim() ?? '';
if (!nextValue || nextValue === signature.label) {
setLabelDrafts(prev => ({ ...prev, [signature.id]: signature.label }));
return;
}
onRenameSignature(signature.id, nextValue);
};
const handleLabelChange = (event: React.ChangeEvent<HTMLInputElement>, signature: SavedSignature) => {
const { value } = event.currentTarget;
setLabelDrafts(prev => ({ ...prev, [signature.id]: value }));
};
const handleLabelKeyDown = (event: React.KeyboardEvent<HTMLInputElement>, signature: SavedSignature) => {
if (event.key === 'Enter') {
event.currentTarget.blur();
}
if (event.key === 'Escape') {
setLabelDrafts(prev => ({ ...prev, [signature.id]: signature.label }));
event.currentTarget.blur();
}
};
useEffect(() => {
if (!activeSignature || disabled) {
appliedSignatureIdRef.current = null;
return;
}
if (appliedSignatureIdRef.current === activeSignature.id) {
return;
}
appliedSignatureIdRef.current = activeSignature.id;
onUseSignatureRef.current(activeSignature);
}, [activeSignature, disabled]);
return (
<Stack gap="sm">
<Group justify="space-between" align="flex-start">
<Stack gap={0}>
<Text fw={600} size="md">
{t('sign.saved.heading', 'Saved signatures')}
</Text>
<Text size="sm" c="dimmed">
{t('sign.saved.description', 'Reuse saved signatures at any time.')}
</Text>
</Stack>
</Group>
{isAtCapacity && (
<Alert color="yellow" title={t('sign.saved.limitTitle', 'Limit reached')}>
<Text size="sm">
{t('sign.saved.limitDescription', 'Remove a saved signature before adding new ones (max {{max}}).', {
max: MAX_SAVED_SIGNATURES,
})}
</Text>
</Alert>
)}
{signatures.length === 0 ? (
emptyState
) : (
<Stack gap="xs">
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{t('sign.saved.carouselPosition', '{{current}} of {{total}}', {
current: activeIndex + 1,
total: signatures.length,
})}
</Text>
<Group gap={4}>
<ActionIcon
variant="light"
aria-label={t('sign.saved.prev', 'Previous')}
onClick={() => handleNavigate('prev')}
disabled={disabled || activeIndex === 0}
>
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
</ActionIcon>
<ActionIcon
variant="light"
aria-label={t('sign.saved.next', 'Next')}
onClick={() => handleNavigate('next')}
disabled={disabled || activeIndex >= signatures.length - 1}
>
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
</ActionIcon>
</Group>
</Group>
{activeSignature && (
<Card withBorder padding="sm" key={activeSignature.id}>
<Stack gap="sm">
<Group justify="space-between" align="center">
<Badge color={typeBadgeColor[activeSignature.type]} variant="light">
{typeLabel(activeSignature.type)}
</Badge>
<Tooltip label={t('sign.saved.delete', 'Remove')}>
<ActionIcon
variant="subtle"
color="red"
aria-label={t('sign.saved.delete', 'Remove')}
onClick={() => onDeleteSignature(activeSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
</ActionIcon>
</Tooltip>
</Group>
{renderPreview(activeSignature)}
<TextInput
label={t('sign.saved.label', 'Label')}
value={labelDrafts[activeSignature.id] ?? activeSignature.label}
onChange={event => handleLabelChange(event, activeSignature)}
onBlur={() => handleLabelBlur(activeSignature)}
onKeyDown={event => handleLabelKeyDown(event, activeSignature)}
disabled={disabled}
/>
</Stack>
</Card>
)}
</Stack>
)}
</Stack>
);
};
export default SavedSignaturesSection;
File diff suppressed because it is too large Load Diff
@@ -15,6 +15,7 @@ import { createStirlingFilesAndStubs } from '@app/services/fileStubHelpers';
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
import { isStirlingFile } from '@app/types/fileContext';
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
import { SignaturePlacementOverlay } from '@app/components/viewer/SignaturePlacementOverlay';
import { useWheelZoom } from '@app/hooks/useWheelZoom';
export interface EmbedPdfViewerProps {
@@ -67,7 +68,7 @@ const EmbedPdfViewerContent = ({
}, [rotationState.rotation]);
// Get signature context
const { signatureApiRef, historyApiRef } = useSignature();
const { signatureApiRef, historyApiRef, signatureConfig, isPlacementMode } = useSignature();
// Get current file from FileContext
const { selectors, state } = useFileState();
@@ -85,6 +86,9 @@ const EmbedPdfViewerContent = ({
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
const isPlacementOverlayActive = Boolean(
isSignatureMode && shouldEnableAnnotations && isPlacementMode && signatureConfig
);
// Track which file tab is active
const [internalActiveFileIndex, setInternalActiveFileIndex] = useState(0);
@@ -324,6 +328,11 @@ const EmbedPdfViewerContent = ({
// Future: Handle signature completion
}}
/>
<SignaturePlacementOverlay
containerRef={pdfContainerRef}
isActive={isPlacementOverlayActive}
signatureConfig={signatureConfig}
/>
</Box>
</>
)}
@@ -1,14 +1,16 @@
import { useImperativeHandle, forwardRef, useEffect } from 'react';
import { useImperativeHandle, forwardRef, useEffect, useRef } from 'react';
import { useHistoryCapability } from '@embedpdf/plugin-history/react';
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
import { useSignature } from '@app/contexts/SignatureContext';
import { uuidV4 } from '@embedpdf/models';
import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models';
import type { HistoryAPI } from '@app/components/viewer/viewerTypes';
import { ANNOTATION_RECREATION_DELAY_MS, ANNOTATION_VERIFICATION_DELAY_MS } from '@app/constants/app';
export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge(_, ref) {
const { provides: historyApi } = useHistoryCapability();
const { provides: annotationApi } = useAnnotationCapability();
const { getImageData, storeImageData } = useSignature();
const restoringIds = useRef<Set<string>>(new Set());
// Monitor annotation events to detect when annotations are restored
useEffect(() => {
@@ -18,17 +20,58 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
const annotation = event.annotation;
// Store image data for all STAMP annotations immediately when created or modified
if (annotation && annotation.type === 13 && annotation.id && annotation.imageSrc) {
if (annotation && annotation.type === PdfAnnotationSubtype.STAMP && annotation.id && annotation.imageSrc) {
const storedImageData = getImageData(annotation.id);
if (!storedImageData || storedImageData !== annotation.imageSrc) {
if (!storedImageData) {
storeImageData(annotation.id, annotation.imageSrc);
}
}
if (annotation && annotation.type === PdfAnnotationSubtype.STAMP && annotation.id) {
// Prevent infinite loops when we recreate annotations
if (restoringIds.current.has(annotation.id)) {
restoringIds.current.delete(annotation.id);
return;
}
const storedImageData = getImageData(annotation.id);
// If EmbedPDF cropped the image (imageSrc changed), recreate annotation using stored data
if (storedImageData && annotation.imageSrc && annotation.imageSrc !== storedImageData) {
const newId = uuidV4();
restoringIds.current.add(newId);
storeImageData(newId, storedImageData);
const pageIndex = event.pageIndex ?? annotation.pageIndex ?? annotation.object?.pageIndex ?? 0;
const rect = annotation.rect || annotation.bounds || annotation.rectangle || annotation.position;
try {
annotationApi.deleteAnnotation(pageIndex, annotation.id);
setTimeout(() => {
annotationApi.createAnnotation(pageIndex, {
type: annotation.type,
rect,
author: annotation.author || 'Digital Signature',
subject: annotation.subject || 'Digital Signature',
pageIndex,
id: newId,
created: annotation.created || new Date(),
imageSrc: storedImageData,
contents: storedImageData,
data: storedImageData,
appearance: storedImageData,
});
}, ANNOTATION_RECREATION_DELAY_MS);
} catch (restoreError) {
console.error('HistoryAPI: Failed to restore cropped signature:', restoreError);
}
return;
}
}
// Handle annotation restoration after undo operations
if (event.type === 'create' && event.committed) {
// Check if this is a STAMP annotation (signature) that might need image data restoration
if (annotation && annotation.type === 13 && annotation.id) {
if (annotation && annotation.type === PdfAnnotationSubtype.STAMP && annotation.id) {
getImageData(annotation.id);
// Delay the check to allow the annotation to be fully created
@@ -61,12 +104,12 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
// Small delay to ensure deletion completes
setTimeout(() => {
annotationApi.createAnnotation(event.pageIndex, restoredData);
}, 50);
}, ANNOTATION_RECREATION_DELAY_MS);
} catch (error) {
console.error('HistoryAPI: Failed to restore annotation:', error);
}
}
}, 100);
}, ANNOTATION_VERIFICATION_DELAY_MS);
}
}
};
@@ -102,6 +145,21 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
canRedo: () => {
return historyApi ? historyApi.canRedo() : false;
},
subscribe: (listener: () => void) => {
if (!historyApi?.onHistoryChange) {
return () => {};
}
const wrapped = () => listener();
const unsubscribe = historyApi.onHistoryChange(wrapped);
listener();
if (typeof unsubscribe === 'function') {
return unsubscribe;
}
return () => {};
},
}), [historyApi]);
return null; // This is a bridge component with no UI
@@ -43,23 +43,32 @@ export function PdfViewerToolbar({
// Register for immediate scroll updates and sync with actual scroll state
useEffect(() => {
registerImmediateScrollUpdate((currentPage, _totalPages) => {
const unregister = registerImmediateScrollUpdate((currentPage, _totalPages) => {
setPageInput(currentPage);
});
setPageInput(scrollState.currentPage);
}, [registerImmediateScrollUpdate]);
return () => {
unregister?.();
};
}, [registerImmediateScrollUpdate, scrollState.currentPage]);
// Register for immediate zoom updates and sync with actual zoom state
useEffect(() => {
registerImmediateZoomUpdate(setDisplayZoomPercent);
const unregister = registerImmediateZoomUpdate(setDisplayZoomPercent);
setDisplayZoomPercent(zoomState.zoomPercent || 140);
}, [zoomState.zoomPercent, registerImmediateZoomUpdate]);
return () => {
unregister?.();
};
}, [registerImmediateZoomUpdate, zoomState.zoomPercent]);
useEffect(() => {
registerImmediateSpreadUpdate((_mode, isDual) => {
const unregister = registerImmediateSpreadUpdate((_mode, isDual) => {
setIsDualPageActive(isDual);
});
setIsDualPageActive(spreadState.isDualPage);
return () => {
unregister?.();
};
}, [registerImmediateSpreadUpdate, spreadState.isDualPage]);
const handleZoomOut = () => {
@@ -1,12 +1,211 @@
import { useImperativeHandle, forwardRef, useEffect } from 'react';
import { useImperativeHandle, forwardRef, useEffect, useCallback, useRef, useState } from 'react';
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models';
import { useSignature } from '@app/contexts/SignatureContext';
import type { SignatureAPI } from '@app/components/viewer/viewerTypes';
import type { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
import { useViewer } from '@app/contexts/ViewerContext';
// Minimum allowed width/height (in pixels) for a signature image or text stamp.
// This prevents rendering issues and ensures signatures are always visible and usable.
const MIN_SIGNATURE_DIMENSION = 12;
// Use 2x oversampling to improve text rendering quality (anti-aliasing) when generating signature images.
// This provides a good balance between visual fidelity and performance/memory usage.
const TEXT_OVERSAMPLE_FACTOR = 2;
type TextStampImageResult = {
dataUrl: string;
pixelWidth: number;
pixelHeight: number;
displayWidth: number;
displayHeight: number;
};
const extractDataUrl = (value: unknown, depth = 0, visited: Set<unknown> = new Set()): string | undefined => {
if (!value || depth > 6) return undefined;
// Prevent circular references
if (typeof value === 'object' && visited.has(value)) {
return undefined;
}
if (typeof value === 'string') {
return value.startsWith('data:image') ? value : undefined;
}
if (typeof value === 'object') {
visited.add(value);
if (Array.isArray(value)) {
for (const entry of value) {
const result = extractDataUrl(entry, depth + 1, visited);
if (result) return result;
}
} else {
for (const key of Object.keys(value as Record<string, unknown>)) {
const result = extractDataUrl((value as Record<string, unknown>)[key], depth + 1, visited);
if (result) return result;
}
}
}
return undefined;
};
const createTextStampImage = (
config: SignParameters,
displaySize?: { width: number; height: number } | null
): TextStampImageResult | null => {
const text = (config.signerName ?? '').trim();
if (!text) {
return null;
}
const fontSize = config.fontSize ?? 16;
const fontFamily = config.fontFamily ?? 'Helvetica';
const textColor = config.textColor ?? '#000000';
const paddingX = Math.max(4, Math.round(fontSize * 0.8));
const paddingY = Math.max(4, Math.round(fontSize * 0.6));
const measureCanvas = document.createElement('canvas');
const measureCtx = measureCanvas.getContext('2d');
if (!measureCtx) {
return null;
}
measureCtx.font = `${fontSize}px ${fontFamily}`;
const metrics = measureCtx.measureText(text);
const textWidth = Math.ceil(metrics.width);
const naturalWidth = Math.max(MIN_SIGNATURE_DIMENSION, textWidth + paddingX * 2);
const naturalHeight = Math.max(MIN_SIGNATURE_DIMENSION, Math.ceil(fontSize + paddingY * 2));
const scale =
displaySize && naturalWidth > 0 && naturalHeight > 0
? Math.min(displaySize.width / naturalWidth, displaySize.height / naturalHeight)
: 1;
const displayWidth = Math.max(MIN_SIGNATURE_DIMENSION, naturalWidth * scale);
const displayHeight = Math.max(MIN_SIGNATURE_DIMENSION, naturalHeight * scale);
const canvasWidth = Math.max(
MIN_SIGNATURE_DIMENSION,
Math.round(displayWidth * TEXT_OVERSAMPLE_FACTOR)
);
const canvasHeight = Math.max(
MIN_SIGNATURE_DIMENSION,
Math.round(displayHeight * TEXT_OVERSAMPLE_FACTOR)
);
const canvas = document.createElement('canvas');
canvas.width = canvasWidth;
canvas.height = canvasHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
return null;
}
const effectiveScale = scale * TEXT_OVERSAMPLE_FACTOR;
ctx.scale(effectiveScale, effectiveScale);
ctx.fillStyle = textColor;
ctx.font = `${fontSize}px ${fontFamily}`;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
const horizontalPadding = paddingX;
const verticalCenter = naturalHeight / 2;
ctx.fillText(text, horizontalPadding, verticalCenter);
return {
dataUrl: canvas.toDataURL('image/png'),
pixelWidth: canvasWidth,
pixelHeight: canvasHeight,
displayWidth,
displayHeight,
};
};
export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPIBridge(_, ref) {
const { provides: annotationApi } = useAnnotationCapability();
const { signatureConfig, storeImageData, isPlacementMode } = useSignature();
const { signatureConfig, storeImageData, isPlacementMode, placementPreviewSize } = useSignature();
const { getZoomState, registerImmediateZoomUpdate } = useViewer();
const [currentZoom, setCurrentZoom] = useState(() => getZoomState()?.currentZoom ?? 1);
const lastStampImageRef = useRef<string | null>(null);
useEffect(() => {
setCurrentZoom(getZoomState()?.currentZoom ?? 1);
const unregister = registerImmediateZoomUpdate(percent => {
setCurrentZoom(Math.max(percent / 100, 0.01));
});
return () => {
unregister?.();
};
}, [getZoomState, registerImmediateZoomUpdate]);
const cssToPdfSize = useCallback(
(size: { width: number; height: number }) => {
const zoom = currentZoom || 1;
const factor = 1 / zoom;
return {
width: size.width * factor,
height: size.height * factor,
};
},
[currentZoom]
);
const applyStampDefaults = useCallback(
(imageSrc: string, subject: string, size?: { width: number; height: number }) => {
if (!annotationApi) return;
annotationApi.setActiveTool(null);
annotationApi.setActiveTool('stamp');
const stampTool = annotationApi.getActiveTool();
if (stampTool && stampTool.id === 'stamp') {
annotationApi.setToolDefaults('stamp', {
imageSrc,
subject,
...(size ? { imageSize: { width: size.width, height: size.height } } : {}),
});
}
},
[annotationApi]
);
const configureStampDefaults = useCallback(async () => {
if (!annotationApi || !signatureConfig) {
return;
}
try {
if (signatureConfig.signatureType === 'text' && signatureConfig.signerName) {
const textStamp = createTextStampImage(signatureConfig, placementPreviewSize);
if (textStamp) {
const displaySize =
placementPreviewSize ?? {
width: textStamp.displayWidth,
height: textStamp.displayHeight,
};
const pdfSize = cssToPdfSize(displaySize);
lastStampImageRef.current = textStamp.dataUrl;
applyStampDefaults(textStamp.dataUrl, `Text Signature - ${signatureConfig.signerName}`, pdfSize);
}
return;
}
if (signatureConfig.signatureData) {
const pdfSize = placementPreviewSize ? cssToPdfSize(placementPreviewSize) : undefined;
lastStampImageRef.current = signatureConfig.signatureData;
applyStampDefaults(signatureConfig.signatureData, `Digital Signature - ${signatureConfig.reason || 'Document signing'}`, pdfSize);
return;
}
} catch (error) {
console.error('Error preparing signature defaults:', error);
}
}, [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults, cssToPdfSize]);
// Enable keyboard deletion of selected annotations
@@ -108,58 +307,9 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
activateSignaturePlacementMode: () => {
if (!annotationApi || !signatureConfig) return;
try {
if (signatureConfig.signatureType === 'text' && signatureConfig.signerName) {
// Skip native text tools - always use stamp for consistent sizing
const activatedTool = null;
if (!activatedTool) {
// Create text image as stamp with actual pixel size matching desired display size
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (ctx) {
const baseFontSize = signatureConfig.fontSize || 16;
const fontFamily = signatureConfig.fontFamily || 'Helvetica';
const textColor = signatureConfig.textColor || '#000000';
// Canvas pixel size = display size (EmbedPDF uses pixel dimensions directly)
canvas.width = Math.max(200, signatureConfig.signerName.length * baseFontSize * 0.6);
canvas.height = baseFontSize + 20;
ctx.fillStyle = textColor;
ctx.font = `${baseFontSize}px ${fontFamily}`;
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(signatureConfig.signerName, 10, canvas.height / 2);
const dataURL = canvas.toDataURL();
// Deactivate and reactivate to force refresh
annotationApi.setActiveTool(null);
annotationApi.setActiveTool('stamp');
const stampTool = annotationApi.getActiveTool();
if (stampTool && stampTool.id === 'stamp') {
annotationApi.setToolDefaults('stamp', {
imageSrc: dataURL,
subject: `Text Signature - ${signatureConfig.signerName}`,
});
}
}
}
} else if (signatureConfig.signatureData) {
// Use stamp tool for image/canvas signatures
annotationApi.setActiveTool('stamp');
const activeTool = annotationApi.getActiveTool();
if (activeTool && activeTool.id === 'stamp') {
annotationApi.setToolDefaults('stamp', {
imageSrc: signatureConfig.signatureData,
subject: `Digital Signature - ${signatureConfig.reason || 'Document signing'}`,
});
}
}
} catch (error) {
configureStampDefaults().catch((error) => {
console.error('Error activating signature tool:', error);
}
});
},
updateDrawSettings: (color: string, size: number) => {
@@ -196,7 +346,7 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
if (pageAnnotationsTask) {
pageAnnotationsTask.toPromise().then((pageAnnotations: any) => {
const annotation = pageAnnotations?.find((ann: any) => ann.id === annotationId);
if (annotation && annotation.type === 13 && annotation.imageSrc) {
if (annotation && annotation.type === PdfAnnotationSubtype.STAMP && annotation.imageSrc) {
// Store image data before deletion
storeImageData(annotationId, annotation.imageSrc);
}
@@ -230,7 +380,61 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
return [];
}
},
}), [annotationApi, signatureConfig]);
}), [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults]);
useEffect(() => {
if (!annotationApi?.onAnnotationEvent) {
return;
}
const unsubscribe = annotationApi.onAnnotationEvent(event => {
if (event.type !== 'create' && event.type !== 'update') {
return;
}
const annotation: any = event.annotation;
const annotationId: string | undefined = annotation?.id;
if (!annotationId) {
return;
}
const directData =
extractDataUrl(annotation.imageSrc) ||
extractDataUrl(annotation.imageData) ||
extractDataUrl(annotation.appearance) ||
extractDataUrl(annotation.stampData) ||
extractDataUrl(annotation.contents) ||
extractDataUrl(annotation.data) ||
extractDataUrl(annotation.customData) ||
extractDataUrl(annotation.asset);
const dataToStore = directData || lastStampImageRef.current;
if (dataToStore) {
storeImageData(annotationId, dataToStore);
}
});
return () => {
unsubscribe?.();
};
}, [annotationApi, storeImageData]);
useEffect(() => {
if (!isPlacementMode) {
return;
}
let cancelled = false;
configureStampDefaults().catch((error) => {
if (!cancelled) {
console.error('Error updating signature defaults:', error);
}
});
return () => {
cancelled = true;
};
}, [isPlacementMode, configureStampDefaults, placementPreviewSize, signatureConfig]);
return null; // This is a bridge component with no UI
@@ -0,0 +1,171 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Box } from '@mantine/core';
import type { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
import { buildSignaturePreview, SignaturePreview } from '@app/utils/signaturePreview';
import { useSignature } from '@app/contexts/SignatureContext';
import {
MAX_PREVIEW_WIDTH_RATIO,
MAX_PREVIEW_HEIGHT_RATIO,
MAX_PREVIEW_WIDTH_REM,
MAX_PREVIEW_HEIGHT_REM,
MIN_SIGNATURE_DIMENSION_REM,
OVERLAY_EDGE_PADDING_REM,
} from '@app/constants/signConstants';
// Convert rem to pixels using browser's base font size (typically 16px)
const remToPx = (rem: number) => rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
interface SignaturePlacementOverlayProps {
containerRef: React.RefObject<HTMLElement | null>;
isActive: boolean;
signatureConfig: SignParameters | null;
}
export const SignaturePlacementOverlay: React.FC<SignaturePlacementOverlayProps> = ({
containerRef,
isActive,
signatureConfig,
}) => {
const [preview, setPreview] = useState<SignaturePreview | null>(null);
const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null);
const { setPlacementPreviewSize } = useSignature();
useEffect(() => {
let cancelled = false;
const buildPreview = async () => {
try {
const value = await buildSignaturePreview(signatureConfig ?? null);
if (!cancelled) {
setPreview(value);
}
} catch (error) {
console.error('Failed to build signature preview:', error);
if (!cancelled) {
setPreview(null);
}
}
};
buildPreview();
return () => {
cancelled = true;
};
}, [signatureConfig]);
useEffect(() => {
const element = containerRef.current;
if (!isActive || !element) {
setCursor(null);
return;
}
const handleMove = (event: MouseEvent) => {
const rect = element.getBoundingClientRect();
setCursor({
x: event.clientX - rect.left,
y: event.clientY - rect.top,
});
};
const handleLeave = () => setCursor(null);
element.addEventListener('mousemove', handleMove);
element.addEventListener('mouseleave', handleLeave);
return () => {
element.removeEventListener('mousemove', handleMove);
element.removeEventListener('mouseleave', handleLeave);
};
}, [containerRef, isActive]);
const scaledSize = useMemo(() => {
if (!preview || !containerRef.current) {
return null;
}
const container = containerRef.current;
const containerWidth = container.clientWidth || 1;
const containerHeight = container.clientHeight || 1;
const maxWidth = Math.min(containerWidth * MAX_PREVIEW_WIDTH_RATIO, remToPx(MAX_PREVIEW_WIDTH_REM));
const maxHeight = Math.min(containerHeight * MAX_PREVIEW_HEIGHT_RATIO, remToPx(MAX_PREVIEW_HEIGHT_REM));
const scale = Math.min(
1,
maxWidth / Math.max(preview.width, 1),
maxHeight / Math.max(preview.height, 1)
);
return {
width: Math.max(remToPx(MIN_SIGNATURE_DIMENSION_REM), preview.width * scale),
height: Math.max(remToPx(MIN_SIGNATURE_DIMENSION_REM), preview.height * scale),
};
}, [preview, containerRef]);
useEffect(() => {
if (!isActive || !scaledSize) {
setPlacementPreviewSize(null);
} else {
setPlacementPreviewSize(scaledSize);
}
}, [isActive, scaledSize, setPlacementPreviewSize]);
useEffect(() => {
return () => {
setPlacementPreviewSize(null);
};
}, [setPlacementPreviewSize]);
const display = useMemo(() => {
if (!preview || !scaledSize || !cursor || !containerRef.current) {
return null;
}
const container = containerRef.current;
const containerWidth = container.clientWidth || 1;
const containerHeight = container.clientHeight || 1;
const width = scaledSize.width;
const height = scaledSize.height;
const edgePadding = remToPx(OVERLAY_EDGE_PADDING_REM);
const clampedLeft = Math.max(edgePadding, Math.min(cursor.x - width / 2, containerWidth - width - edgePadding));
const clampedTop = Math.max(edgePadding, Math.min(cursor.y - height / 2, containerHeight - height - edgePadding));
return {
left: clampedLeft,
top: clampedTop,
width,
height,
dataUrl: preview.dataUrl,
};
}, [preview, scaledSize, cursor, containerRef]);
if (!isActive || !display || !preview) {
return null;
}
return (
<Box
style={{
position: 'absolute',
pointerEvents: 'none',
left: `${display.left}px`,
top: `${display.top}px`,
width: `${display.width}px`,
height: `${display.height}px`,
backgroundImage: `url(${display.dataUrl})`,
backgroundSize: '100% 100%',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
boxShadow: '0 0 0 1px rgba(30, 136, 229, 0.55), 0 6px 18px rgba(30, 136, 229, 0.25)',
borderRadius: '4px',
transition: 'transform 70ms ease-out',
transform: 'translateZ(0)',
opacity: 0.6,
}}
/>
);
};
@@ -214,4 +214,4 @@ export function ZoomAPIBridge() {
}, [zoom, zoomState, registerBridge, triggerImmediateZoomUpdate]);
return null;
}
}
@@ -21,4 +21,5 @@ export interface HistoryAPI {
redo: () => void;
canUndo: () => boolean;
canRedo: () => boolean;
subscribe?: (listener: () => void) => () => void;
}
+5
View File
@@ -3,6 +3,11 @@
// When no subpath, use empty string instead of '.' to avoid relative path issues
export const BASE_PATH = (import.meta.env.BASE_URL || '/').replace(/\/$/, '').replace(/^\.$/, '');
// EmbedPDF needs time to remove annotations internally before a recreation runs.
// Without the buffer we occasionally end up with duplicate annotations or stale image data.
export const ANNOTATION_RECREATION_DELAY_MS = 50;
export const ANNOTATION_VERIFICATION_DELAY_MS = 100;
/** For in-app navigations when you must touch window.location (rare). */
export const withBasePath = (path: string): string => {
const clean = path.startsWith('/') ? path : `/${path}`;
@@ -0,0 +1,15 @@
// Timeout delays (ms) to allow PDF viewer to complete rendering before activating placement mode
export const PLACEMENT_ACTIVATION_DELAY = 60; // Standard delay for signature changes
export const FILE_SWITCH_ACTIVATION_DELAY = 80; // Slightly longer delay when switching files
// Signature preview sizing
export const MAX_PREVIEW_WIDTH_RATIO = 0.35; // Max preview width as percentage of container
export const MAX_PREVIEW_HEIGHT_RATIO = 0.35; // Max preview height as percentage of container
export const MAX_PREVIEW_WIDTH_REM = 15; // Absolute max width in rem
export const MAX_PREVIEW_HEIGHT_REM = 10; // Absolute max height in rem
export const MIN_SIGNATURE_DIMENSION_REM = 0.75; // Min dimension for visibility
export const OVERLAY_EDGE_PADDING_REM = 0.25; // Padding from container edges
// Text signature padding (relative to font size)
export const HORIZONTAL_PADDING_RATIO = 0.8;
export const VERTICAL_PADDING_RATIO = 0.6;
@@ -39,6 +39,9 @@ export interface AppConfig {
license?: string;
SSOAutoLogin?: boolean;
serverCertificateEnabled?: boolean;
appVersion?: string;
machineType?: string;
activeSecurity?: boolean;
error?: string;
}
@@ -10,6 +10,8 @@ interface SignatureState {
isPlacementMode: boolean;
// Whether signatures have been applied (allows export)
signaturesApplied: boolean;
// Size (in screen units) we want newly placed signatures to use
placementPreviewSize: { width: number; height: number } | null;
}
// Signature actions interface
@@ -26,6 +28,7 @@ interface SignatureActions {
storeImageData: (id: string, data: string) => void;
getImageData: (id: string) => string | undefined;
setSignaturesApplied: (applied: boolean) => void;
setPlacementPreviewSize: (size: { width: number; height: number } | null) => void;
}
// Combined context interface
@@ -42,6 +45,7 @@ const initialState: SignatureState = {
signatureConfig: null,
isPlacementMode: false,
signaturesApplied: true, // Start as true (no signatures placed yet)
placementPreviewSize: null,
};
// Provider component
@@ -131,6 +135,27 @@ export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children
}));
}, []);
const setPlacementPreviewSize = useCallback((size: { width: number; height: number } | null) => {
setState(prev => {
const prevSize = prev.placementPreviewSize;
const same =
(prevSize === null && size === null) ||
(prevSize !== null &&
size !== null &&
Math.abs(prevSize.width - size.width) < 0.5 &&
Math.abs(prevSize.height - size.height) < 0.5);
if (same) {
return prev;
}
return {
...prev,
placementPreviewSize: size,
};
});
}, []);
// No auto-activation - all modes use manual buttons
const contextValue: SignatureContextValue = {
@@ -149,6 +174,7 @@ export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children
storeImageData,
getImageData,
setSignaturesApplied,
setPlacementPreviewSize,
};
return (
+15 -6
View File
@@ -41,14 +41,23 @@ import {
import { SpreadMode } from '@embedpdf/plugin-spread/react';
function useImmediateNotifier<Args extends unknown[]>() {
const callbackRef = useRef<((...args: Args) => void) | null>(null);
const callbacksRef = useRef(new Set<(...args: Args) => void>());
const register = useCallback((callback: (...args: Args) => void) => {
callbackRef.current = callback;
callbacksRef.current.add(callback);
return () => {
callbacksRef.current.delete(callback);
};
}, []);
const trigger = useCallback((...args: Args) => {
callbackRef.current?.(...args);
callbacksRef.current.forEach(cb => {
try {
cb(...args);
} catch (error) {
console.error('Immediate callback error:', error);
}
});
}, []);
return { register, trigger };
@@ -97,9 +106,9 @@ interface ViewerContextType {
hasBookmarkSupport: () => boolean;
// Immediate update callbacks
registerImmediateZoomUpdate: (callback: (percent: number) => void) => void;
registerImmediateScrollUpdate: (callback: (currentPage: number, totalPages: number) => void) => void;
registerImmediateSpreadUpdate: (callback: (mode: SpreadMode, isDualPage: boolean) => void) => void;
registerImmediateZoomUpdate: (callback: (percent: number) => void) => () => void;
registerImmediateScrollUpdate: (callback: (currentPage: number, totalPages: number) => void) => () => void;
registerImmediateSpreadUpdate: (callback: (mode: SpreadMode, isDualPage: boolean) => void) => () => void;
// Internal - for bridges to trigger immediate updates
triggerImmediateScrollUpdate: (currentPage: number, totalPages: number) => void;
@@ -5,7 +5,6 @@ import type { ToolOperationHook } from '@app/hooks/tools/shared/useToolOperation
import type { StirlingFile } from '@app/types/fileContext';
import { extractErrorMessage } from '@app/utils/toolErrorHandler';
import type { ShowJSParameters } from '@app/hooks/tools/showJS/useShowJSParameters';
import type { ResponseType } from 'axios';
export interface ShowJSOperationHook extends ToolOperationHook<ShowJSParameters> {
scriptText: string | null;
@@ -71,8 +70,7 @@ export const useShowJSOperation = (): ShowJSOperationHook => {
const response = await apiClient.post('/api/v1/misc/show-javascript', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
responseType: 'text' as ResponseType,
transformResponse: [(data) => data],
responseType: 'text',
});
const text: string = typeof response.data === 'string' ? response.data : '';
@@ -0,0 +1,212 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
const STORAGE_KEY = 'stirling:saved-signatures:v1';
export const MAX_SAVED_SIGNATURES = 10;
export type SavedSignatureType = 'canvas' | 'image' | 'text';
export type SavedSignaturePayload =
| {
type: 'canvas';
dataUrl: string;
}
| {
type: 'image';
dataUrl: string;
}
| {
type: 'text';
signerName: string;
fontFamily: string;
fontSize: number;
textColor: string;
};
export type SavedSignature = SavedSignaturePayload & {
id: string;
label: string;
createdAt: number;
updatedAt: number;
};
export type AddSignatureResult =
| { success: true; signature: SavedSignature }
| { success: false; reason: 'limit' | 'invalid' };
const isSupportedEnvironment = () => typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
const safeParse = (raw: string | null): SavedSignature[] => {
if (!raw) {
return [];
}
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((entry: any): entry is SavedSignature => {
if (!entry || typeof entry !== 'object') {
return false;
}
if (typeof entry.id !== 'string' || typeof entry.label !== 'string') {
return false;
}
if (typeof entry.type !== 'string') {
return false;
}
if (entry.type === 'text') {
return (
typeof entry.signerName === 'string' &&
typeof entry.fontFamily === 'string' &&
typeof entry.fontSize === 'number' &&
typeof entry.textColor === 'string'
);
}
return typeof entry.dataUrl === 'string';
});
} catch {
return [];
}
};
const readFromStorage = (): SavedSignature[] => {
if (!isSupportedEnvironment()) {
return [];
}
try {
return safeParse(window.localStorage.getItem(STORAGE_KEY));
} catch {
return [];
}
};
const writeToStorage = (entries: SavedSignature[]) => {
if (!isSupportedEnvironment()) {
return;
}
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
} catch {
// Swallow storage errors silently; we still keep state in memory.
}
};
const generateId = () => crypto.randomUUID();
export const useSavedSignatures = () => {
const [savedSignatures, setSavedSignatures] = useState<SavedSignature[]>(() => readFromStorage());
useEffect(() => {
if (!isSupportedEnvironment()) {
return;
}
const syncFromStorage = () => {
setSavedSignatures(readFromStorage());
};
window.addEventListener('storage', syncFromStorage);
return () => window.removeEventListener('storage', syncFromStorage);
}, []);
useEffect(() => {
writeToStorage(savedSignatures);
}, [savedSignatures]);
const isAtCapacity = savedSignatures.length >= MAX_SAVED_SIGNATURES;
const addSignature = useCallback(
(payload: SavedSignaturePayload, label?: string): AddSignatureResult => {
if (
(payload.type === 'text' && !payload.signerName.trim()) ||
((payload.type === 'canvas' || payload.type === 'image') && !payload.dataUrl)
) {
return { success: false, reason: 'invalid' };
}
let createdSignature: SavedSignature | null = null;
setSavedSignatures(prev => {
if (prev.length >= MAX_SAVED_SIGNATURES) {
return prev;
}
const timestamp = Date.now();
const nextEntry: SavedSignature = {
...payload,
id: generateId(),
label: (label || 'Signature').trim() || 'Signature',
createdAt: timestamp,
updatedAt: timestamp,
};
createdSignature = nextEntry;
return [nextEntry, ...prev];
});
return createdSignature
? { success: true, signature: createdSignature }
: { success: false, reason: 'limit' };
},
[]
);
const removeSignature = useCallback((id: string) => {
setSavedSignatures(prev => prev.filter(entry => entry.id !== id));
}, []);
const updateSignatureLabel = useCallback((id: string, nextLabel: string) => {
setSavedSignatures(prev =>
prev.map(entry =>
entry.id === id
? { ...entry, label: nextLabel.trim() || entry.label || 'Signature', updatedAt: Date.now() }
: entry
)
);
}, []);
const replaceSignature = useCallback((id: string, payload: SavedSignaturePayload) => {
setSavedSignatures(prev =>
prev.map(entry =>
entry.id === id
? {
...entry,
...payload,
updatedAt: Date.now(),
}
: entry
)
);
}, []);
const clearSignatures = useCallback(() => {
setSavedSignatures([]);
}, []);
const byTypeCounts = useMemo(() => {
return savedSignatures.reduce<Record<SavedSignatureType, number>>(
(acc, entry) => {
acc[entry.type] += 1;
return acc;
},
{ canvas: 0, image: 0, text: 0 }
);
}, [savedSignatures]);
return {
savedSignatures,
isAtCapacity,
addSignature,
removeSignature,
updateSignatureLabel,
replaceSignature,
clearSignatures,
byTypeCounts,
};
};
export type UseSavedSignaturesReturn = ReturnType<typeof useSavedSignatures>;
@@ -23,7 +23,7 @@ export const buildSignFormData = (params: SignParameters, file: File): FormData
}
// Add signature type
formData.append('signatureType', params.signatureType || 'draw');
formData.append('signatureType', params.signatureType || 'canvas');
// Add other parameters
if (params.reason) {
@@ -56,4 +56,4 @@ export const useSignOperation = (): ToolOperationHook<SignParameters> => {
...signOperationConfig,
getErrorMessage: createStandardErrorHandler(t('sign.error.failed', 'An error occurred while signing the PDF.'))
});
};
};
@@ -9,7 +9,7 @@ export interface SignaturePosition {
}
export interface SignParameters {
signatureType: 'image' | 'text' | 'draw' | 'canvas';
signatureType: 'image' | 'text' | 'canvas';
signatureData?: string; // Base64 encoded image or text content
signaturePosition?: SignaturePosition;
reason?: string;
@@ -60,4 +60,4 @@ export const useSignParameters = () => {
endpointName: 'add-signature',
validateFn: validateSignParameters,
});
};
};
@@ -87,7 +87,7 @@ export function useTooltipPosition({
if (sidebarTooltip) {
// Require sidebar refs and state for proper positioning
if (!sidebarRefs || !sidebarState) {
console.warn('⚠️ Sidebar tooltip requires sidebarRefs and sidebarState props');
console.warn('Sidebar tooltip requires sidebarRefs and sidebarState props');
setPositionReady(false);
return;
}
@@ -97,7 +97,7 @@ export function useTooltipPosition({
// Only show tooltip if we have the tool panel active
if (!sidebarInfo.isToolPanelActive) {
console.log('🚫 Not showing tooltip - tool panel not active');
console.log('Not showing tooltip - tool panel not active');
setPositionReady(false);
return;
}
+12 -3
View File
@@ -1,5 +1,14 @@
import { AxiosInstance } from 'axios';
import type { AxiosInstance } from 'axios';
import { getBrowserId } from '@app/utils/browserIdentifier';
export function setupApiInterceptors(_client: AxiosInstance): void {
// Core version: no interceptors to add
export function setupApiInterceptors(client: AxiosInstance): void {
// Add browser ID header for WAU tracking
client.interceptors.request.use(
(config) => {
const browserId = getBrowserId();
config.headers['X-Browser-Id'] = browserId;
return config;
},
(error) => Promise.reject(error)
);
}
@@ -0,0 +1,20 @@
import { createClient, SupabaseClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY;
// Check if Supabase is configured
export const isSupabaseConfigured = !!(supabaseUrl && supabaseAnonKey);
// Create client only if configured, otherwise export null
export const supabase: SupabaseClient | null = isSupabaseConfigured
? createClient(supabaseUrl, supabaseAnonKey)
: null;
// Log warning if not configured (for self-hosted installations)
if (!isSupabaseConfigured) {
console.warn(
'Supabase is not configured. Checkout and billing features will be disabled. ' +
'Static plan information will be displayed instead.'
);
}
+186
View File
@@ -0,0 +1,186 @@
export interface UpdateSummary {
latest_version: string;
latest_stable_version?: string;
max_priority: 'urgent' | 'normal' | 'minor' | 'low';
recommended_action?: string;
any_breaking: boolean;
migration_guides?: Array<{
version: string;
notes: string;
url: string;
}>;
}
export interface VersionUpdate {
version: string;
priority: 'urgent' | 'normal' | 'minor' | 'low';
announcement: {
title: string;
message: string;
};
compatibility: {
breaking_changes: boolean;
breaking_description?: string;
migration_guide_url?: string;
};
}
export interface FullUpdateInfo {
latest_version: string;
latest_stable_version?: string;
new_versions: VersionUpdate[];
}
export interface MachineInfo {
machineType: string;
activeSecurity: boolean;
licenseType: string;
}
export class UpdateService {
private readonly baseUrl = 'https://supabase.stirling.com/functions/v1/updates';
/**
* Compare two version strings
* @returns 1 if v1 > v2, -1 if v1 < v2, 0 if equal
*/
compareVersions(version1: string, version2: string): number {
const v1 = version1.split('.');
const v2 = version2.split('.');
for (let i = 0; i < v1.length || i < v2.length; i++) {
const n1 = parseInt(v1[i]) || 0;
const n2 = parseInt(v2[i]) || 0;
if (n1 > n2) {
return 1;
} else if (n1 < n2) {
return -1;
}
}
return 0;
}
/**
* Get download URL based on machine type and security settings
*/
getDownloadUrl(machineInfo: MachineInfo): string | null {
// Only show download for non-Docker installations
if (machineInfo.machineType === 'Docker' || machineInfo.machineType === 'Kubernetes') {
return null;
}
const baseUrl = 'https://files.stirlingpdf.com/';
// Determine file based on machine type and security
if (machineInfo.machineType === 'Server-jar') {
return baseUrl + (machineInfo.activeSecurity ? 'Stirling-PDF-with-login.jar' : 'Stirling-PDF.jar');
}
// Client installations
if (machineInfo.machineType.startsWith('Client-')) {
const os = machineInfo.machineType.replace('Client-', ''); // win, mac, unix
const type = machineInfo.activeSecurity ? '-server-security' : '-server';
if (os === 'unix') {
return baseUrl + os + type + '.jar';
} else if (os === 'win') {
return baseUrl + os + '-installer.exe';
} else if (os === 'mac') {
return baseUrl + os + '-installer.dmg';
}
}
return null;
}
/**
* Fetch update summary from API
*/
async getUpdateSummary(currentVersion: string, machineInfo: MachineInfo): Promise<UpdateSummary | null> {
// Map Java License enum to API types
let type = 'normal';
if (machineInfo.licenseType === 'PRO') {
type = 'pro';
} else if (machineInfo.licenseType === 'ENTERPRISE') {
type = 'enterprise';
}
const url = `${this.baseUrl}?from=${currentVersion}&type=${type}&login=${machineInfo.activeSecurity}&summary=true`;
console.log('Fetching update summary from:', url);
try {
const response = await fetch(url);
console.log('Response status:', response.status);
if (response.status === 200) {
const data = await response.json();
return data as UpdateSummary;
} else {
console.error('Failed to fetch update summary from Supabase:', response.status);
return null;
}
} catch (error) {
console.error('Failed to fetch update summary from Supabase:', error);
return null;
}
}
/**
* Fetch full update information with detailed version info
*/
async getFullUpdateInfo(currentVersion: string, machineInfo: MachineInfo): Promise<FullUpdateInfo | null> {
// Map Java License enum to API types
let type = 'normal';
if (machineInfo.licenseType === 'PRO') {
type = 'pro';
} else if (machineInfo.licenseType === 'ENTERPRISE') {
type = 'enterprise';
}
const url = `${this.baseUrl}?from=${currentVersion}&type=${type}&login=${machineInfo.activeSecurity}&summary=false`;
console.log('Fetching full update info from:', url);
try {
const response = await fetch(url);
console.log('Full update response status:', response.status);
if (response.status === 200) {
const data = await response.json();
return data as FullUpdateInfo;
} else {
console.error('Failed to fetch full update info from Supabase:', response.status);
return null;
}
} catch (error) {
console.error('Failed to fetch full update info from Supabase:', error);
return null;
}
}
/**
* Get current version from GitHub build.gradle as fallback
*/
async getCurrentVersionFromGitHub(): Promise<string> {
const url = 'https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/master/build.gradle';
try {
const response = await fetch(url);
if (response.status === 200) {
const text = await response.text();
const versionRegex = /version\s*=\s*['"](\d+\.\d+\.\d+)['"]/;
const match = versionRegex.exec(text);
if (match) {
return match[1];
}
}
throw new Error('Version number not found');
} catch (error) {
console.error('Failed to fetch latest version from build.gradle:', error);
return '';
}
}
}
export const updateService = new UpdateService();
+1 -1
View File
@@ -15,7 +15,7 @@ export const Z_INDEX_HOVER_ACTION_MENU = 100;
export const Z_INDEX_SELECTION_BOX = 1000;
export const Z_INDEX_DROP_INDICATOR = 1001;
export const Z_INDEX_DRAG_BADGE = 1001;
// Modal that appears on top of config modal (e.g., restart confirmation)
// Modal that appears on top of config modal (e.g., restart confirmation, update modal)
export const Z_INDEX_OVER_CONFIG_MODAL = 2000;
// Toast notifications and error displays - Always on top (higher than rainbow theme at 10000)
+28 -2
View File
@@ -115,6 +115,32 @@ const Sign = (props: BaseToolProps) => {
// Deactivate signature placement mode after everything completes
handleDeactivateSignature();
const hasSignatureReady = (() => {
const params = base.params.parameters;
switch (params.signatureType) {
case 'canvas':
case 'image':
return Boolean(params.signatureData);
case 'text':
return Boolean(params.signerName && params.signerName.trim() !== '');
default:
return false;
}
})();
if (hasSignatureReady) {
if (typeof window !== 'undefined') {
// TODO: Ideally, we should trigger handleActivateSignaturePlacement when the viewer is ready.
// However, due to current architectural constraints, we use a 150ms delay to allow the viewer to reload.
// This value was empirically determined to be sufficient for most environments, but should be revisited.
window.setTimeout(() => {
handleActivateSignaturePlacement();
}, 150);
} else {
handleActivateSignaturePlacement();
}
}
// File has been consumed - viewer should reload automatically via key prop
} else {
console.error('Signature flattening failed');
@@ -122,7 +148,7 @@ const Sign = (props: BaseToolProps) => {
} catch (error) {
console.error('Error saving signed document:', error);
}
}, [exportActions, base.selectedFiles, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode, setSignaturesApplied, getScrollState, handleDeactivateSignature, setHasUnsavedChanges, unregisterUnsavedChangesChecker, activeFileIndex, setActiveFileIndex]);
}, [exportActions, base.selectedFiles, base.params.parameters, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode, setSignaturesApplied, getScrollState, handleDeactivateSignature, handleActivateSignaturePlacement, setHasUnsavedChanges, unregisterUnsavedChangesChecker, activeFileIndex, setActiveFileIndex]);
const getSteps = () => {
const steps = [];
@@ -179,4 +205,4 @@ Sign.getDefaultParameters = () => ({
signerName: '',
});
export default Sign as ToolComponent;
export default Sign as ToolComponent;
@@ -0,0 +1,46 @@
/**
* Browser identifier utility for anonymous usage tracking
* Generates and persists a unique UUID in localStorage for WAU tracking
*/
const BROWSER_ID_KEY = 'stirling_browser_id';
/**
* Gets or creates a unique browser identifier
* Used for Weekly Active Users (WAU) tracking in no-login mode
*/
export function getBrowserId(): string {
try {
// Try to get existing ID from localStorage
let browserId = localStorage.getItem(BROWSER_ID_KEY);
if (!browserId) {
// Generate new UUID v4
browserId = generateUUID();
localStorage.setItem(BROWSER_ID_KEY, browserId);
}
return browserId;
} catch (error) {
// Fallback to session-based ID if localStorage is unavailable
console.warn('localStorage unavailable, using session-based ID', error);
return `session_${generateUUID()}`;
}
}
/**
* Generates a UUID v4
*/
function generateUUID(): string {
// Use crypto.randomUUID if available (modern browsers)
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
// Fallback to manual UUID generation
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
@@ -1,4 +1,5 @@
import { PDFDocument, rgb } from 'pdf-lib';
import { PdfAnnotationSubtype } from '@embedpdf/models';
import { generateThumbnailWithMetadata } from '@app/utils/thumbnailUtils';
import { createProcessedFile, createChildStub } from '@app/contexts/file/fileActions';
import { createStirlingFile, StirlingFile, FileId, StirlingFileStub } from '@app/types/fileContext';
@@ -228,7 +229,7 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
size: 12,
color: rgb(0, 0, 0)
});
} else if (annotation.type === 14 || annotation.type === 15) {
} else if (annotation.type === PdfAnnotationSubtype.INK || annotation.type === PdfAnnotationSubtype.LINE) {
// Handle ink annotations (drawn signatures)
page.drawRectangle({
x: pdfX,
@@ -0,0 +1,84 @@
import { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
import { HORIZONTAL_PADDING_RATIO, VERTICAL_PADDING_RATIO } from '@app/constants/signConstants';
export interface SignaturePreview {
dataUrl: string;
width: number;
height: number;
}
const loadImage = (src: string): Promise<HTMLImageElement> =>
new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
export const buildSignaturePreview = async (config: SignParameters | null): Promise<SignaturePreview | null> => {
if (typeof window === 'undefined' || typeof document === 'undefined') {
return null;
}
if (!config) {
return null;
}
if (config.signatureType === 'text') {
const text = config.signerName?.trim();
if (!text) {
return null;
}
const fontSize = config.fontSize ?? 16;
const fontFamily = config.fontFamily ?? 'Helvetica';
const textColor = config.textColor ?? '#000000';
const paddingX = Math.round(fontSize * HORIZONTAL_PADDING_RATIO);
const paddingY = Math.round(fontSize * VERTICAL_PADDING_RATIO);
const measureCanvas = document.createElement('canvas');
const measureCtx = measureCanvas.getContext('2d');
if (!measureCtx) {
return null;
}
measureCtx.font = `${fontSize}px ${fontFamily}`;
const metrics = measureCtx.measureText(text);
const textWidth = Math.ceil(metrics.width);
const width = Math.max(1, textWidth + paddingX * 2);
const height = Math.max(1, Math.ceil(fontSize + paddingY * 2));
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
return null;
}
ctx.fillStyle = textColor;
ctx.font = `${fontSize}px ${fontFamily}`;
ctx.textBaseline = 'middle';
ctx.textAlign = 'left';
ctx.fillText(text, paddingX, height / 2);
const dataUrl = canvas.toDataURL('image/png');
return { dataUrl, width, height };
}
const dataUrl = config.signatureData;
if (!dataUrl) {
return null;
}
const image = await loadImage(dataUrl);
return {
dataUrl,
width: image.naturalWidth || image.width,
height: image.naturalHeight || image.height,
};
};
@@ -1,15 +1,66 @@
import { ReactNode } from "react";
import { ReactNode, useEffect, useState } from "react";
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
import { DesktopConfigSync } from '@app/components/DesktopConfigSync';
import { DesktopBannerInitializer } from '@app/components/DesktopBannerInitializer';
import { SetupWizard } from '@app/components/SetupWizard';
import { useFirstLaunchCheck } from '@app/hooks/useFirstLaunchCheck';
import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig';
import { connectionModeService } from '@desktop/services/connectionModeService';
import { tauriBackendService } from '@app/services/tauriBackendService';
/**
* Desktop application providers
* Wraps proprietary providers and adds desktop-specific configuration
* - Enables retry logic for app config (needed for Tauri mode when backend is starting)
* - Shows setup wizard on first launch
*/
export function AppProviders({ children }: { children: ReactNode }) {
const { isFirstLaunch, setupComplete } = useFirstLaunchCheck();
const [connectionMode, setConnectionMode] = useState<'offline' | 'server' | null>(null);
// Load connection mode on mount
useEffect(() => {
void connectionModeService.getCurrentMode().then(setConnectionMode);
}, []);
// Initialize backend health monitoring for server mode
useEffect(() => {
if (setupComplete && !isFirstLaunch && connectionMode === 'server') {
console.log('[AppProviders] Initializing external backend monitoring for server mode');
void tauriBackendService.initializeExternalBackend();
}
}, [setupComplete, isFirstLaunch, connectionMode]);
// Only start bundled backend if in offline mode and setup is complete
const shouldStartBackend = setupComplete && !isFirstLaunch && connectionMode === 'offline';
useBackendInitializer(shouldStartBackend);
// Show setup wizard on first launch
if (isFirstLaunch && !setupComplete) {
return (
<ProprietaryAppProviders
appConfigRetryOptions={{
maxRetries: 5,
initialDelay: 1000,
}}
appConfigProviderProps={{
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
bootstrapMode: 'non-blocking',
autoFetch: false,
}}
>
<SetupWizard
onComplete={() => {
// Reload the page to reinitialize with new connection config
window.location.reload();
}}
/>
</ProprietaryAppProviders>
);
}
// Normal app flow
return (
<ProprietaryAppProviders
appConfigRetryOptions={{
@@ -0,0 +1,287 @@
import React, { useState, useEffect } from 'react';
import { Stack, Card, Badge, Button, Text, Group, Modal, TextInput, Radio } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import {
connectionModeService,
ConnectionConfig,
ServerConfig,
} from '@app/services/connectionModeService';
import { authService, UserInfo } from '@app/services/authService';
import { LoginForm } from '@app/components/SetupWizard/LoginForm';
import { STIRLING_SAAS_URL } from '@app/constants/connection';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
export const ConnectionSettings: React.FC = () => {
const { t } = useTranslation();
const [config, setConfig] = useState<ConnectionConfig | null>(null);
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
const [loading, setLoading] = useState(false);
const [showServerModal, setShowServerModal] = useState(false);
const [showLoginModal, setShowLoginModal] = useState(false);
const [newServerConfig, setNewServerConfig] = useState<ServerConfig | null>(null);
// Load current config on mount
useEffect(() => {
const loadConfig = async () => {
const currentConfig = await connectionModeService.getCurrentConfig();
setConfig(currentConfig);
if (currentConfig.mode === 'server') {
const user = await authService.getUserInfo();
setUserInfo(user);
}
};
loadConfig();
}, []);
const handleSwitchToOffline = async () => {
try {
setLoading(true);
await connectionModeService.switchToOffline();
// Reload config
const newConfig = await connectionModeService.getCurrentConfig();
setConfig(newConfig);
setUserInfo(null);
// Reload the page to start the local backend
window.location.reload();
} catch (error) {
console.error('Failed to switch to offline:', error);
} finally {
setLoading(false);
}
};
const handleSwitchToServer = () => {
setShowServerModal(true);
};
const handleServerConfigSubmit = (serverConfig: ServerConfig) => {
setNewServerConfig(serverConfig);
setShowServerModal(false);
setShowLoginModal(true);
};
const handleLogin = async (username: string, password: string) => {
if (!newServerConfig) return;
try {
setLoading(true);
// Login
await authService.login(newServerConfig.url, username, password);
// Switch to server mode
await connectionModeService.switchToServer(newServerConfig);
// Reload config and user info
const newConfig = await connectionModeService.getCurrentConfig();
setConfig(newConfig);
const user = await authService.getUserInfo();
setUserInfo(user);
setShowLoginModal(false);
setNewServerConfig(null);
// Reload the page to stop local backend and initialize external backend monitoring
window.location.reload();
} catch (error) {
console.error('Login failed:', error);
throw error; // Let LoginForm handle the error
} finally {
setLoading(false);
}
};
const handleLogout = async () => {
try {
setLoading(true);
await authService.logout();
// Switch to offline mode
await connectionModeService.switchToOffline();
// Reload config
const newConfig = await connectionModeService.getCurrentConfig();
setConfig(newConfig);
setUserInfo(null);
// Reload the page to clear all state and reconnect to local backend
window.location.reload();
} catch (error) {
console.error('Logout failed:', error);
} finally {
setLoading(false);
}
};
if (!config) {
return <Text>{t('common.loading', 'Loading...')}</Text>;
}
return (
<>
<Card shadow="sm" padding="lg" radius="md" withBorder>
<Stack gap="md">
<Group justify="space-between">
<Text fw={600}>{t('settings.connection.title', 'Connection Mode')}</Text>
<Badge color={config.mode === 'offline' ? 'blue' : 'green'} variant="light">
{config.mode === 'offline'
? t('settings.connection.mode.offline', 'Offline')
: t('settings.connection.mode.server', 'Server')}
</Badge>
</Group>
{config.mode === 'server' && config.server_config && (
<>
<div>
<Text size="sm" fw={500}>
{t('settings.connection.server', 'Server')}
</Text>
<Text size="sm" c="dimmed">
{config.server_config.url}
</Text>
</div>
{userInfo && (
<div>
<Text size="sm" fw={500}>
{t('settings.connection.user', 'Logged in as')}
</Text>
<Text size="sm" c="dimmed">
{userInfo.username}
{userInfo.email && ` (${userInfo.email})`}
</Text>
</div>
)}
</>
)}
<Group mt="md">
{config.mode === 'offline' ? (
<Button onClick={handleSwitchToServer} disabled={loading}>
{t('settings.connection.switchToServer', 'Connect to Server')}
</Button>
) : (
<>
<Button onClick={handleSwitchToOffline} variant="default" disabled={loading}>
{t('settings.connection.switchToOffline', 'Switch to Offline')}
</Button>
<Button onClick={handleLogout} color="red" variant="light" disabled={loading}>
{t('settings.connection.logout', 'Logout')}
</Button>
</>
)}
</Group>
</Stack>
</Card>
{/* Server selection modal */}
<Modal
opened={showServerModal}
onClose={() => setShowServerModal(false)}
title={t('settings.connection.selectServer', 'Select Server')}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
<ServerSelectionInSettings onSubmit={handleServerConfigSubmit} />
</Modal>
{/* Login modal */}
<Modal
opened={showLoginModal}
onClose={() => {
setShowLoginModal(false);
setNewServerConfig(null);
}}
title={t('settings.connection.login', 'Login')}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
{newServerConfig && (
<LoginForm
serverUrl={newServerConfig.url}
onLogin={handleLogin}
loading={loading}
/>
)}
</Modal>
</>
);
};
// Mini server selection component for settings
const ServerSelectionInSettings: React.FC<{ onSubmit: (config: ServerConfig) => void }> = ({
onSubmit,
}) => {
const { t } = useTranslation();
const [serverType, setServerType] = useState<'saas' | 'selfhosted'>('saas');
const [customUrl, setCustomUrl] = useState('');
const [testing, setTesting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async () => {
const url = serverType === 'saas' ? STIRLING_SAAS_URL : customUrl.trim();
if (!url) {
setError(t('setup.server.error.emptyUrl', 'Please enter a server URL'));
return;
}
setTesting(true);
setError(null);
try {
const isReachable = await connectionModeService.testConnection(url);
if (!isReachable) {
setError(t('setup.server.error.unreachable', 'Could not connect to server'));
setTesting(false);
return;
}
onSubmit({
url,
server_type: serverType,
});
} catch (err) {
setError(err instanceof Error ? err.message : t('setup.server.error.testFailed', 'Connection test failed'));
setTesting(false);
}
};
return (
<Stack gap="md">
<Radio.Group value={serverType} onChange={(value) => setServerType(value as 'saas' | 'selfhosted')}>
<Stack gap="xs">
<Radio value="saas" label={t('setup.server.type.saas', 'Stirling PDF SaaS')} />
<Radio value="selfhosted" label={t('setup.server.type.selfhosted', 'Self-hosted server')} />
</Stack>
</Radio.Group>
{serverType === 'selfhosted' && (
<TextInput
label={t('setup.server.url.label', 'Server URL')}
placeholder="https://your-server.com"
value={customUrl}
onChange={(e) => {
setCustomUrl(e.target.value);
setError(null);
}}
disabled={testing}
error={error}
/>
)}
{error && !customUrl && (
<Text c="red" size="sm">
{error}
</Text>
)}
<Button onClick={handleSubmit} loading={testing} fullWidth>
{testing ? t('setup.server.testing', 'Testing...') : t('common.continue', 'Continue')}
</Button>
</Stack>
);
};
@@ -0,0 +1,85 @@
import React, { useState } from 'react';
import { Stack, TextInput, PasswordInput, Button, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
interface LoginFormProps {
serverUrl: string;
onLogin: (username: string, password: string) => Promise<void>;
loading: boolean;
}
export const LoginForm: React.FC<LoginFormProps> = ({ serverUrl, onLogin, loading }) => {
const { t } = useTranslation();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [validationError, setValidationError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validation
if (!username.trim()) {
setValidationError(t('setup.login.error.emptyUsername', 'Please enter your username'));
return;
}
if (!password) {
setValidationError(t('setup.login.error.emptyPassword', 'Please enter your password'));
return;
}
setValidationError(null);
await onLogin(username.trim(), password);
};
return (
<form onSubmit={handleSubmit}>
<Stack gap="md">
<Text size="sm" c="dimmed">
{t('setup.login.connectingTo', 'Connecting to:')} <strong>{serverUrl}</strong>
</Text>
<TextInput
label={t('setup.login.username.label', 'Username')}
placeholder={t('setup.login.username.placeholder', 'Enter your username')}
value={username}
onChange={(e) => {
setUsername(e.target.value);
setValidationError(null);
}}
disabled={loading}
required
/>
<PasswordInput
label={t('setup.login.password.label', 'Password')}
placeholder={t('setup.login.password.placeholder', 'Enter your password')}
value={password}
onChange={(e) => {
setPassword(e.target.value);
setValidationError(null);
}}
disabled={loading}
required
/>
{validationError && (
<Text c="red" size="sm">
{validationError}
</Text>
)}
<Button
type="submit"
loading={loading}
disabled={loading}
mt="md"
fullWidth
color="#AF3434"
>
{t('setup.login.submit', 'Login')}
</Button>
</Stack>
</form>
);
};
@@ -0,0 +1,66 @@
import React from 'react';
import { Stack, Button, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import CloudIcon from '@mui/icons-material/Cloud';
import ComputerIcon from '@mui/icons-material/Computer';
interface ModeSelectionProps {
onSelect: (mode: 'offline' | 'server') => void;
loading: boolean;
}
export const ModeSelection: React.FC<ModeSelectionProps> = ({ onSelect, loading }) => {
const { t } = useTranslation();
return (
<Stack gap="md">
<Button
size="xl"
variant="default"
onClick={() => onSelect('offline')}
disabled={loading}
leftSection={<ComputerIcon />}
styles={{
root: {
height: 'auto',
padding: '1.25rem',
},
inner: {
justifyContent: 'flex-start',
},
}}
>
<div style={{ textAlign: 'left', flex: 1 }}>
<Text fw={600} size="md">{t('setup.mode.offline.title', 'Use Offline')}</Text>
<Text size="sm" c="dimmed" fw={400}>
{t('setup.mode.offline.description', 'Run locally without an internet connection')}
</Text>
</div>
</Button>
<Button
size="xl"
variant="default"
onClick={() => onSelect('server')}
disabled={loading}
leftSection={<CloudIcon />}
styles={{
root: {
height: 'auto',
padding: '1.25rem',
},
inner: {
justifyContent: 'flex-start',
},
}}
>
<div style={{ textAlign: 'left', flex: 1 }}>
<Text fw={600} size="md">{t('setup.mode.server.title', 'Connect to Server')}</Text>
<Text size="sm" c="dimmed" fw={400}>
{t('setup.mode.server.description', 'Connect to a remote Stirling PDF server')}
</Text>
</div>
</Button>
</Stack>
);
};
@@ -0,0 +1,92 @@
import React, { useState } from 'react';
import { Stack, Button, TextInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ServerConfig } from '@app/services/connectionModeService';
import { connectionModeService } from '@app/services/connectionModeService';
interface ServerSelectionProps {
onSelect: (config: ServerConfig) => void;
loading: boolean;
}
export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, loading }) => {
const { t } = useTranslation();
const [customUrl, setCustomUrl] = useState('');
const [testing, setTesting] = useState(false);
const [testError, setTestError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const url = customUrl.trim();
if (!url) {
setTestError(t('setup.server.error.emptyUrl', 'Please enter a server URL'));
return;
}
// Test connection before proceeding
setTesting(true);
setTestError(null);
try {
const isReachable = await connectionModeService.testConnection(url);
if (!isReachable) {
setTestError(t('setup.server.error.unreachable', 'Could not connect to server'));
setTesting(false);
return;
}
// Connection successful
onSelect({
url,
server_type: 'selfhosted',
});
} catch (error) {
console.error('Connection test failed:', error);
setTestError(
error instanceof Error
? error.message
: t('setup.server.error.testFailed', 'Connection test failed')
);
} finally {
setTesting(false);
}
};
return (
<form onSubmit={handleSubmit}>
<Stack gap="md">
<TextInput
label={t('setup.server.url.label', 'Server URL')}
placeholder="https://your-server.com"
value={customUrl}
onChange={(e) => {
setCustomUrl(e.target.value);
setTestError(null);
}}
disabled={loading || testing}
error={testError}
description={t(
'setup.server.url.description',
'Enter the full URL of your self-hosted Stirling PDF server'
)}
/>
<Button
type="submit"
loading={testing || loading}
disabled={loading}
mt="md"
fullWidth
color="#AF3434"
>
{testing
? t('setup.server.testing', 'Testing connection...')
: t('common.continue', 'Continue')}
</Button>
</Stack>
</form>
);
};
@@ -0,0 +1,20 @@
.setup-container {
position: relative;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f5f5f5 0%, #e8e8e8 100%);
padding: 2rem;
}
.setup-wrapper {
width: 100%;
max-width: 600px;
}
.setup-card {
background-color: white;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.12);
}
@@ -0,0 +1,184 @@
import React, { useState } from 'react';
import { Container, Paper, Stack, Title, Text, Button, Image } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ModeSelection } from '@app/components/SetupWizard/ModeSelection';
import { ServerSelection } from '@app/components/SetupWizard/ServerSelection';
import { LoginForm } from '@app/components/SetupWizard/LoginForm';
import { connectionModeService, ServerConfig } from '@app/services/connectionModeService';
import { authService } from '@app/services/authService';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { BASE_PATH } from '@app/constants/app';
import '@app/components/SetupWizard/SetupWizard.css';
enum SetupStep {
ModeSelection,
ServerSelection,
Login,
}
interface SetupWizardProps {
onComplete: () => void;
}
export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
const { t } = useTranslation();
const [activeStep, setActiveStep] = useState<SetupStep>(SetupStep.ModeSelection);
const [_selectedMode, setSelectedMode] = useState<'offline' | 'server' | null>(null);
const [serverConfig, setServerConfig] = useState<ServerConfig | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleModeSelection = (mode: 'offline' | 'server') => {
setSelectedMode(mode);
setError(null);
if (mode === 'offline') {
handleOfflineSetup();
} else {
setActiveStep(SetupStep.ServerSelection);
}
};
const handleOfflineSetup = async () => {
try {
setLoading(true);
setError(null);
await connectionModeService.switchToOffline();
await tauriBackendService.startBackend();
onComplete();
} catch (err) {
console.error('Failed to set up offline mode:', err);
setError(err instanceof Error ? err.message : 'Failed to set up offline mode');
setLoading(false);
}
};
const handleServerSelection = (config: ServerConfig) => {
setServerConfig(config);
setError(null);
setActiveStep(SetupStep.Login);
};
const handleLogin = async (username: string, password: string) => {
if (!serverConfig) {
setError('No server configured');
return;
}
try {
setLoading(true);
setError(null);
await authService.login(serverConfig.url, username, password);
await connectionModeService.switchToServer(serverConfig);
await tauriBackendService.initializeExternalBackend();
onComplete();
} catch (err) {
console.error('Login failed:', err);
setError(err instanceof Error ? err.message : 'Login failed');
setLoading(false);
}
};
const handleBack = () => {
setError(null);
if (activeStep === SetupStep.Login) {
setActiveStep(SetupStep.ServerSelection);
} else if (activeStep === SetupStep.ServerSelection) {
setActiveStep(SetupStep.ModeSelection);
setSelectedMode(null);
setServerConfig(null);
}
};
const getStepTitle = () => {
switch (activeStep) {
case SetupStep.ModeSelection:
return t('setup.welcome', 'Welcome to Stirling PDF');
case SetupStep.ServerSelection:
return t('setup.server.title', 'Connect to Server');
case SetupStep.Login:
return t('setup.login.title', 'Sign In');
default:
return '';
}
};
const getStepSubtitle = () => {
switch (activeStep) {
case SetupStep.ModeSelection:
return t('setup.description', 'Get started by choosing how you want to use Stirling PDF');
case SetupStep.ServerSelection:
return t('setup.server.subtitle', 'Enter your self-hosted server URL');
case SetupStep.Login:
return t('setup.login.subtitle', 'Enter your credentials to continue');
default:
return '';
}
};
return (
<div className="setup-container">
<Container size="sm" className="setup-wrapper">
<Paper shadow="xl" p="xl" radius="lg" className="setup-card">
<Stack gap="lg">
{/* Logo Header */}
<Stack gap="xs" align="center">
<Image
src={`${BASE_PATH}/branding/StirlingPDFLogoBlackText.svg`}
alt="Stirling PDF"
h={32}
fit="contain"
/>
<Title order={1} ta="center" style={{ fontSize: '2rem', fontWeight: 800 }}>
{getStepTitle()}
</Title>
<Text size="sm" c="dimmed" ta="center">
{getStepSubtitle()}
</Text>
</Stack>
{/* Error Message */}
{error && (
<Paper p="md" bg="red.0" style={{ border: '1px solid var(--mantine-color-red-3)' }}>
<Text size="sm" c="red.7" ta="center">
{error}
</Text>
</Paper>
)}
{/* Step Content */}
{activeStep === SetupStep.ModeSelection && (
<ModeSelection onSelect={handleModeSelection} loading={loading} />
)}
{activeStep === SetupStep.ServerSelection && (
<ServerSelection onSelect={handleServerSelection} loading={loading} />
)}
{activeStep === SetupStep.Login && (
<LoginForm
serverUrl={serverConfig?.url || ''}
onLogin={handleLogin}
loading={loading}
/>
)}
{/* Back Button */}
{activeStep > SetupStep.ModeSelection && !loading && (
<Button
variant="subtle"
onClick={handleBack}
fullWidth
mt="md"
>
{t('common.back', 'Back')}
</Button>
)}
</Stack>
</Paper>
</Container>
</div>
);
};
@@ -0,0 +1,30 @@
import { createConfigNavSections as createProprietaryConfigNavSections } from '@proprietary/components/shared/config/configNavSections';
import { ConfigNavSection } from '@core/components/shared/config/configNavSections';
import { ConnectionSettings } from '@app/components/ConnectionSettings';
/**
* Desktop extension of createConfigNavSections that adds connection settings
*/
export const createConfigNavSections = (
isAdmin: boolean = false,
runningEE: boolean = false,
loginEnabled: boolean = false
): ConfigNavSection[] => {
// Get the proprietary sections (includes core Preferences + admin sections)
const sections = createProprietaryConfigNavSections(isAdmin, runningEE, loginEnabled);
// Add Connection section at the beginning (after Preferences)
sections.splice(1, 0, {
title: 'Connection',
items: [
{
key: 'connectionMode',
label: 'Connection Mode',
icon: 'cloud-rounded',
component: <ConnectionSettings />,
},
],
});
return sections;
};
@@ -0,0 +1,8 @@
import { VALID_NAV_KEYS as CORE_NAV_KEYS } from '@core/components/shared/config/types';
export const VALID_NAV_KEYS = [
...CORE_NAV_KEYS,
'connectionMode',
] as const;
export type NavKey = typeof VALID_NAV_KEYS[number];
@@ -0,0 +1,5 @@
/**
* Connection-related constants for desktop app
*/
export const STIRLING_SAAS_URL = 'https://stirling.com/app';
@@ -1,19 +1,15 @@
import { useEffect } from 'react';
import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
import { useEffect, useState } from 'react';
import { useOpenedFile } from '@app/hooks/useOpenedFile';
import { fileOpenService } from '@app/services/fileOpenService';
import { useFileManagement } from '@app/contexts/file/fileHooks';
/**
* App initialization hook
* Desktop version: Handles Tauri-specific initialization
* - Starts the backend on app startup
* Desktop version: Handles Tauri-specific file initialization
* Requires FileContext - must be used inside FileContextProvider
* - Handles files opened with the app (adds directly to FileContext)
*/
export function useAppInitialization(): void {
// Initialize backend on app startup
useBackendInitializer();
// Get file management actions
const { addFiles } = useFileManagement();
@@ -59,3 +55,11 @@ export function useAppInitialization(): void {
loadOpenedFiles();
}, [openedFilePaths, openedFileLoading, addFiles]);
}
export function useSetupCompletion(): (completed: boolean) => void {
const [, setSetupComplete] = useState(false);
return (completed: boolean) => {
setSetupComplete(completed);
};
}
@@ -5,12 +5,18 @@ import { tauriBackendService } from '@app/services/tauriBackendService';
/**
* Hook to initialize backend and monitor health
* @param enabled - Whether to initialize the backend (default: true)
*/
export function useBackendInitializer() {
export function useBackendInitializer(enabled = true) {
const { status, checkHealth } = useBackendHealth();
const { backendUrl } = useEndpointConfig();
useEffect(() => {
// Skip if disabled
if (!enabled) {
return;
}
// Skip if backend already running
if (tauriBackendService.isBackendRunning()) {
void checkHealth();
@@ -36,5 +42,5 @@ export function useBackendInitializer() {
if (status !== 'healthy' && status !== 'starting') {
void initializeBackend();
}
}, [status, backendUrl, checkHealth]);
}, [enabled, status, backendUrl, checkHealth]);
}
@@ -1,9 +1,10 @@
import { useMemo, useState, useEffect, useCallback, useRef } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { isAxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import apiClient from '@app/services/apiClient';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { isBackendNotReadyError } from '@app/constants/backendErrors';
import { connectionModeService } from '@desktop/services/connectionModeService';
interface EndpointConfig {
backendUrl: string;
@@ -235,17 +236,34 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
};
}
// Default backend URL from environment variables
const DEFAULT_BACKEND_URL =
import.meta.env.VITE_DESKTOP_BACKEND_URL
|| import.meta.env.VITE_API_BASE_URL
|| '';
/**
* Desktop override exposing the backend URL used by the embedded server.
* Desktop override exposing the backend URL based on connection mode.
* - Offline mode: Uses local bundled backend (from env vars)
* - Server mode: Uses configured server URL from connection config
*/
export function useEndpointConfig(): EndpointConfig {
const backendUrl = useMemo(() => {
const runtimeEnv = typeof process !== 'undefined' ? process.env : undefined;
const [backendUrl, setBackendUrl] = useState<string>(DEFAULT_BACKEND_URL);
return runtimeEnv?.STIRLING_BACKEND_URL
|| import.meta.env.VITE_DESKTOP_BACKEND_URL
|| import.meta.env.VITE_API_BASE_URL
|| 'http://localhost:8080';
useEffect(() => {
connectionModeService.getCurrentConfig()
.then((config) => {
if (config.mode === 'server' && config.server_config?.url) {
setBackendUrl(config.server_config.url);
} else {
// Offline mode - use default from env vars
setBackendUrl(DEFAULT_BACKEND_URL);
}
})
.catch((err) => {
console.error('Failed to get connection config:', err);
// Keep current URL on error
});
}, []);
return { backendUrl };
@@ -0,0 +1,44 @@
import { useEffect, useRef, useState } from 'react';
import { connectionModeService } from '@app/services/connectionModeService';
import { authService } from '@app/services/authService';
/**
* First launch check hook
* Checks if this is the first time the app is being launched
* Does not require FileContext - can be used early in the provider hierarchy
*/
export function useFirstLaunchCheck(): { isFirstLaunch: boolean; setupComplete: boolean } {
const [isFirstLaunch, setIsFirstLaunch] = useState(false);
const [setupComplete, setSetupComplete] = useState(false);
const setupCheckCompleteRef = useRef(false);
// Check if this is first launch
useEffect(() => {
const checkFirstLaunch = async () => {
try {
const firstLaunch = await connectionModeService.isFirstLaunch();
setIsFirstLaunch(firstLaunch);
if (!firstLaunch) {
// Not first launch - initialize auth state
await authService.initializeAuthState();
setSetupComplete(true);
}
setupCheckCompleteRef.current = true;
} catch (error) {
console.error('Failed to check first launch:', error);
// On error, assume not first launch and proceed
setIsFirstLaunch(false);
setSetupComplete(true);
setupCheckCompleteRef.current = true;
}
};
if (!setupCheckCompleteRef.current) {
checkFirstLaunch();
}
}, []);
return { isFirstLaunch, setupComplete };
}
@@ -0,0 +1,34 @@
/**
* Desktop-specific API client using Tauri's native HTTP client
* This file overrides @core/services/apiClient.ts for desktop builds
* Bypasses CORS restrictions by using native HTTP instead of browser fetch
*/
import type { AxiosInstance } from 'axios';
import { create } from '@app/services/tauriHttpClient';
import { handleHttpError } from '@app/services/httpErrorHandler';
import { setupApiInterceptors } from '@app/services/apiClientSetup';
import { getApiBaseUrl } from '@app/services/apiClientConfig';
// Create Tauri HTTP client with default config
const apiClient = create({
baseURL: getApiBaseUrl(),
responseType: 'json',
withCredentials: true,
});
// Setup interceptors (desktop-specific auth and backend ready checks)
// Cast to AxiosInstance - Tauri client has compatible API
setupApiInterceptors(apiClient as unknown as AxiosInstance);
// ---------- Install error interceptor ----------
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
await handleHttpError(error); // Handle error (shows toast unless suppressed)
return Promise.reject(error);
}
);
// ---------- Exports ----------
export default apiClient;
@@ -2,16 +2,19 @@ import { isTauri } from '@tauri-apps/api/core';
/**
* Desktop override: Determine base URL depending on Tauri environment
*
* Note: In Tauri mode, the actual URL is determined dynamically by operationRouter
* based on connection mode and backend port. This initial baseURL is overridden
* by request interceptors in apiClientSetup.ts.
*/
export function getApiBaseUrl(): string {
if (!isTauri()) {
return import.meta.env.VITE_API_BASE_URL || '/';
}
if (import.meta.env.DEV) {
// During tauri dev we rely on Vite proxy, so use relative path to avoid CORS preflight
return '/';
}
return import.meta.env.VITE_DESKTOP_BACKEND_URL || 'http://localhost:8080';
// In Tauri mode, return empty string as placeholder
// The actual URL will be set dynamically by operationRouter based on:
// - Offline mode: dynamic port from tauriBackendService
// - Server mode: configured server URL from connectionModeService
return '';
}
+109 -19
View File
@@ -1,44 +1,134 @@
import { AxiosInstance } from 'axios';
import type { AxiosInstance, InternalAxiosRequestConfig } from 'axios';
import { alert } from '@app/components/toast';
import { setupApiInterceptors as coreSetup } from '@core/services/apiClientSetup';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { createBackendNotReadyError } from '@app/constants/backendErrors';
import { operationRouter } from '@app/services/operationRouter';
import { authService } from '@app/services/authService';
import { connectionModeService } from '@app/services/connectionModeService';
import i18n from '@app/i18n';
const BACKEND_TOAST_COOLDOWN_MS = 4000;
let lastBackendToast = 0;
// Extended config for custom properties
interface ExtendedRequestConfig extends InternalAxiosRequestConfig {
operationName?: string;
skipBackendReadyCheck?: boolean;
_retry?: boolean;
}
/**
* Desktop-specific API interceptors
* - Reuses the core interceptors
* - Blocks API calls while the bundled backend is still starting and shows
* a friendly toast for user-initiated requests (non-GET)
* - Dynamically sets base URL based on connection mode
* - Adds auth token for remote server requests
* - Blocks API calls while the bundled backend is still starting
* - Handles auth token refresh on 401 errors
*/
export function setupApiInterceptors(client: AxiosInstance): void {
coreSetup(client);
// Request interceptor: Set base URL and auth headers dynamically
client.interceptors.request.use(
(config) => {
const skipCheck = config?.skipBackendReadyCheck === true;
if (skipCheck || tauriBackendService.isBackendHealthy()) {
return config;
async (config: InternalAxiosRequestConfig) => {
const extendedConfig = config as ExtendedRequestConfig;
// Get the operation name from config if provided
const operation = extendedConfig.operationName;
// Get the appropriate base URL for this operation
const baseUrl = await operationRouter.getBaseUrl(operation);
// Build the full URL
if (extendedConfig.url && !extendedConfig.url.startsWith('http')) {
extendedConfig.url = `${baseUrl}${extendedConfig.url}`;
}
const method = (config.method || 'get').toLowerCase();
if (method !== 'get') {
const now = Date.now();
if (now - lastBackendToast > BACKEND_TOAST_COOLDOWN_MS) {
lastBackendToast = now;
alert({
alertType: 'error',
title: i18n.t('backendHealth.offline', 'Backend Offline'),
body: i18n.t('backendHealth.wait', 'Please wait for the backend to finish launching and try again.'),
isPersistentPopup: false,
});
// Debug logging
console.debug(`[apiClientSetup] Request to: ${extendedConfig.url}`);
// Add auth token for remote requests
const isRemote = await operationRouter.isRemoteMode();
if (isRemote) {
const token = await authService.getAuthToken();
if (token) {
extendedConfig.headers.Authorization = `Bearer ${token}`;
}
}
return Promise.reject(createBackendNotReadyError());
// Backend readiness check (for local backend)
const skipCheck = extendedConfig.skipBackendReadyCheck === true;
const isOffline = await operationRouter.isOfflineMode();
if (isOffline && !skipCheck && !tauriBackendService.isBackendHealthy()) {
const method = (extendedConfig.method || 'get').toLowerCase();
if (method !== 'get') {
const now = Date.now();
if (now - lastBackendToast > BACKEND_TOAST_COOLDOWN_MS) {
lastBackendToast = now;
alert({
alertType: 'error',
title: i18n.t('backendHealth.offline', 'Backend Offline'),
body: i18n.t('backendHealth.wait', 'Please wait for the backend to finish launching and try again.'),
isPersistentPopup: false,
});
}
}
return Promise.reject(createBackendNotReadyError());
}
return extendedConfig;
},
(error) => Promise.reject(error)
);
// Response interceptor: Handle auth errors
client.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config as ExtendedRequestConfig;
// Handle 401 Unauthorized - try to refresh token
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
const isRemote = await operationRouter.isRemoteMode();
if (isRemote) {
const serverConfig = await connectionModeService.getServerConfig();
if (serverConfig) {
const refreshed = await authService.refreshToken(serverConfig.url);
if (refreshed) {
// Retry the original request with new token
const token = await authService.getAuthToken();
if (token) {
originalRequest.headers.Authorization = `Bearer ${token}`;
}
return client(originalRequest);
}
}
}
// Refresh failed or not in remote mode - user needs to login again
alert({
alertType: 'error',
title: i18n.t('auth.sessionExpired', 'Session Expired'),
body: i18n.t('auth.pleaseLoginAgain', 'Please login again.'),
isPersistentPopup: false,
});
}
// Handle 403 Forbidden - unauthorized access
if (error.response?.status === 403) {
alert({
alertType: 'error',
title: i18n.t('auth.accessDenied', 'Access Denied'),
body: i18n.t('auth.insufficientPermissions', 'You do not have permission to perform this action.'),
isPersistentPopup: false,
});
}
return Promise.reject(error);
}
);
}
@@ -0,0 +1,198 @@
import { invoke } from '@tauri-apps/api/core';
import axios from 'axios';
export interface UserInfo {
username: string;
email?: string;
}
interface LoginResponse {
token: string;
username: string;
email: string | null;
}
export type AuthStatus = 'authenticated' | 'unauthenticated' | 'refreshing';
export class AuthService {
private static instance: AuthService;
private authStatus: AuthStatus = 'unauthenticated';
private userInfo: UserInfo | null = null;
private authListeners = new Set<(status: AuthStatus, userInfo: UserInfo | null) => void>();
static getInstance(): AuthService {
if (!AuthService.instance) {
AuthService.instance = new AuthService();
}
return AuthService.instance;
}
subscribeToAuth(listener: (status: AuthStatus, userInfo: UserInfo | null) => void): () => void {
this.authListeners.add(listener);
// Immediately notify new listener of current state
listener(this.authStatus, this.userInfo);
return () => {
this.authListeners.delete(listener);
};
}
private notifyListeners() {
this.authListeners.forEach(listener => listener(this.authStatus, this.userInfo));
}
private setAuthStatus(status: AuthStatus, userInfo: UserInfo | null = null) {
this.authStatus = status;
this.userInfo = userInfo;
this.notifyListeners();
}
async login(serverUrl: string, username: string, password: string): Promise<UserInfo> {
try {
console.log('Logging in to:', serverUrl);
// Call Rust login command (bypasses CORS)
const response = await invoke<LoginResponse>('login', {
serverUrl,
username,
password,
});
const { token, username: returnedUsername, email } = response;
// Save the token to keyring
await invoke('save_auth_token', { token });
// Save user info to store
await invoke('save_user_info', {
username: returnedUsername || username,
email,
});
const userInfo: UserInfo = {
username: returnedUsername || username,
email: email || undefined,
};
this.setAuthStatus('authenticated', userInfo);
console.log('Login successful');
return userInfo;
} catch (error) {
console.error('Login failed:', error);
this.setAuthStatus('unauthenticated', null);
// Rust commands return string errors
if (typeof error === 'string') {
throw new Error(error);
}
throw new Error('Login failed. Please try again.');
}
}
async logout(): Promise<void> {
try {
console.log('Logging out');
// Clear token from keyring
await invoke('clear_auth_token');
// Clear user info from store
await invoke('clear_user_info');
this.setAuthStatus('unauthenticated', null);
console.log('Logged out successfully');
} catch (error) {
console.error('Error during logout:', error);
// Still set status to unauthenticated even if clear fails
this.setAuthStatus('unauthenticated', null);
}
}
async getAuthToken(): Promise<string | null> {
try {
const token = await invoke<string | null>('get_auth_token');
return token || null;
} catch (error) {
console.error('Failed to get auth token:', error);
return null;
}
}
async isAuthenticated(): Promise<boolean> {
const token = await this.getAuthToken();
return token !== null;
}
async getUserInfo(): Promise<UserInfo | null> {
if (this.userInfo) {
return this.userInfo;
}
try {
const userInfo = await invoke<UserInfo | null>('get_user_info');
this.userInfo = userInfo;
return userInfo;
} catch (error) {
console.error('Failed to get user info:', error);
return null;
}
}
async refreshToken(serverUrl: string): Promise<boolean> {
try {
console.log('Refreshing auth token');
this.setAuthStatus('refreshing', this.userInfo);
const currentToken = await this.getAuthToken();
if (!currentToken) {
this.setAuthStatus('unauthenticated', null);
return false;
}
// Call the server's refresh endpoint
const response = await axios.post(
`${serverUrl}/api/v1/auth/refresh`,
{},
{
headers: {
Authorization: `Bearer ${currentToken}`,
},
}
);
const { token } = response.data;
// Save the new token
await invoke('save_auth_token', { token });
const userInfo = await this.getUserInfo();
this.setAuthStatus('authenticated', userInfo);
console.log('Token refreshed successfully');
return true;
} catch (error) {
console.error('Token refresh failed:', error);
this.setAuthStatus('unauthenticated', null);
// Clear stored credentials on refresh failure
await this.logout();
return false;
}
}
async initializeAuthState(): Promise<void> {
const token = await this.getAuthToken();
const userInfo = await this.getUserInfo();
if (token && userInfo) {
this.setAuthStatus('authenticated', userInfo);
} else {
this.setAuthStatus('unauthenticated', null);
}
}
}
export const authService = AuthService.getInstance();
@@ -0,0 +1,131 @@
import { invoke } from '@tauri-apps/api/core';
import { fetch } from '@tauri-apps/plugin-http';
export type ConnectionMode = 'offline' | 'server';
export type ServerType = 'saas' | 'selfhosted';
export interface ServerConfig {
url: string;
server_type: ServerType;
}
export interface ConnectionConfig {
mode: ConnectionMode;
server_config: ServerConfig | null;
}
export class ConnectionModeService {
private static instance: ConnectionModeService;
private currentConfig: ConnectionConfig | null = null;
private configLoadedOnce = false;
private modeListeners = new Set<(config: ConnectionConfig) => void>();
static getInstance(): ConnectionModeService {
if (!ConnectionModeService.instance) {
ConnectionModeService.instance = new ConnectionModeService();
}
return ConnectionModeService.instance;
}
async getCurrentConfig(): Promise<ConnectionConfig> {
if (!this.configLoadedOnce) {
await this.loadConfig();
}
return this.currentConfig || { mode: 'offline', server_config: null };
}
async getCurrentMode(): Promise<ConnectionMode> {
const config = await this.getCurrentConfig();
return config.mode;
}
async getServerConfig(): Promise<ServerConfig | null> {
const config = await this.getCurrentConfig();
return config.server_config;
}
subscribeToModeChanges(listener: (config: ConnectionConfig) => void): () => void {
this.modeListeners.add(listener);
return () => {
this.modeListeners.delete(listener);
};
}
private notifyListeners() {
if (this.currentConfig) {
this.modeListeners.forEach(listener => listener(this.currentConfig!));
}
}
private async loadConfig(): Promise<void> {
try {
const config = await invoke<ConnectionConfig>('get_connection_config');
this.currentConfig = config;
this.configLoadedOnce = true;
} catch (error) {
console.error('Failed to load connection config:', error);
// Default to offline mode on error
this.currentConfig = { mode: 'offline', server_config: null };
this.configLoadedOnce = true;
}
}
async switchToOffline(): Promise<void> {
console.log('Switching to offline mode');
await invoke('set_connection_mode', {
mode: 'offline',
serverConfig: null,
});
this.currentConfig = { mode: 'offline', server_config: null };
this.notifyListeners();
console.log('Switched to offline mode successfully');
}
async switchToServer(serverConfig: ServerConfig): Promise<void> {
console.log('Switching to server mode:', serverConfig);
await invoke('set_connection_mode', {
mode: 'server',
serverConfig,
});
this.currentConfig = { mode: 'server', server_config: serverConfig };
this.notifyListeners();
console.log('Switched to server mode successfully');
}
async testConnection(url: string): Promise<boolean> {
console.log(`[ConnectionModeService] Testing connection to: ${url}`);
try {
// Test connection by hitting the health/status endpoint
const healthUrl = `${url.replace(/\/$/, '')}/api/v1/info/status`;
const response = await fetch(healthUrl, {
method: 'GET',
connectTimeout: 10000,
});
const isOk = response.ok;
console.log(`[ConnectionModeService] Server connection test result: ${isOk}`);
return isOk;
} catch (error) {
console.warn('[ConnectionModeService] Server connection test failed:', error);
return false;
}
}
async isFirstLaunch(): Promise<boolean> {
try {
const result = await invoke<boolean>('is_first_launch');
return result;
} catch (error) {
console.error('Failed to check first launch:', error);
return false;
}
}
}
export const connectionModeService = ConnectionModeService.getInstance();
@@ -0,0 +1,99 @@
import { connectionModeService } from '@app/services/connectionModeService';
import { tauriBackendService } from '@app/services/tauriBackendService';
export type ExecutionTarget = 'local' | 'remote';
export class OperationRouter {
private static instance: OperationRouter;
static getInstance(): OperationRouter {
if (!OperationRouter.instance) {
OperationRouter.instance = new OperationRouter();
}
return OperationRouter.instance;
}
/**
* Determines where an operation should execute
* @param _operation - The operation name (for future operation classification)
* @returns 'local' or 'remote'
*/
async getExecutionTarget(_operation?: string): Promise<ExecutionTarget> {
const mode = await connectionModeService.getCurrentMode();
// Current implementation: simple mode-based routing
if (mode === 'offline') {
return 'local';
}
// In server mode, currently all operations go to remote
// Future enhancement: check if operation is "simple" and route to local if so
// Example future logic:
// if (mode === 'server' && operation && this.isSimpleOperation(operation)) {
// return 'local';
// }
return 'remote';
}
/**
* Gets the base URL for an operation based on execution target
* @param _operation - The operation name (for future operation classification)
* @returns Base URL for API calls
*/
async getBaseUrl(_operation?: string): Promise<string> {
const target = await this.getExecutionTarget(_operation);
if (target === 'local') {
// Use dynamically assigned port from backend service
const backendUrl = tauriBackendService.getBackendUrl();
if (!backendUrl) {
throw new Error('Backend URL not available - backend may still be starting');
}
// Strip trailing slash to avoid double slashes in URLs
return backendUrl.replace(/\/$/, '');
}
// Remote: get from server config
const serverConfig = await connectionModeService.getServerConfig();
if (!serverConfig) {
console.warn('No server config found');
throw new Error('Server configuration not found');
}
// Strip trailing slash to avoid double slashes in URLs
return serverConfig.url.replace(/\/$/, '');
}
/**
* Checks if we're currently in remote mode
*/
async isRemoteMode(): Promise<boolean> {
const mode = await connectionModeService.getCurrentMode();
return mode === 'server';
}
/**
* Checks if we're currently in offline mode
*/
async isOfflineMode(): Promise<boolean> {
const mode = await connectionModeService.getCurrentMode();
return mode === 'offline';
}
// Future enhancement: operation classification
// private isSimpleOperation(operation: string): boolean {
// const simpleOperations = [
// 'rotate',
// 'merge',
// 'split',
// 'extract-pages',
// 'remove-pages',
// 'reorder-pages',
// 'metadata',
// ];
// return simpleOperations.includes(operation);
// }
}
export const operationRouter = OperationRouter.getInstance();
@@ -1,4 +1,6 @@
import { invoke } from '@tauri-apps/api/core';
import { fetch } from '@tauri-apps/plugin-http';
import { connectionModeService } from '@app/services/connectionModeService';
export type BackendStatus = 'stopped' | 'starting' | 'healthy' | 'unhealthy';
@@ -6,6 +8,7 @@ export class TauriBackendService {
private static instance: TauriBackendService;
private backendStarted = false;
private backendStatus: BackendStatus = 'stopped';
private backendPort: number | null = null;
private healthMonitor: Promise<void> | null = null;
private startPromise: Promise<void> | null = null;
private statusListeners = new Set<(status: BackendStatus) => void>();
@@ -29,6 +32,14 @@ export class TauriBackendService {
return this.backendStatus === 'healthy';
}
getBackendPort(): number | null {
return this.backendPort;
}
getBackendUrl(): string | null {
return this.backendPort ? `http://localhost:${this.backendPort}` : null;
}
subscribeToStatus(listener: (status: BackendStatus) => void): () => void {
this.statusListeners.add(listener);
return () => {
@@ -44,6 +55,21 @@ export class TauriBackendService {
this.statusListeners.forEach(listener => listener(status));
}
/**
* Initialize health monitoring for an external server (server mode)
* Does not start bundled backend, but enables health checks
*/
async initializeExternalBackend(): Promise<void> {
if (this.backendStarted) {
return;
}
console.log('[TauriBackendService] Initializing external backend monitoring');
this.backendStarted = true; // Mark as active for health checks
this.setStatus('starting');
this.beginHealthMonitoring();
}
async startBackend(backendUrl?: string): Promise<void> {
if (this.backendStarted) {
return;
@@ -56,10 +82,14 @@ export class TauriBackendService {
this.setStatus('starting');
this.startPromise = invoke('start_backend', { backendUrl })
.then((result) => {
.then(async (result) => {
console.log('Backend started:', result);
this.backendStarted = true;
this.setStatus('starting');
// Poll for the dynamically assigned port
await this.waitForPort();
this.beginHealthMonitoring();
})
.catch((error) => {
@@ -74,6 +104,24 @@ export class TauriBackendService {
return this.startPromise;
}
private async waitForPort(maxAttempts = 30): Promise<void> {
console.log('[TauriBackendService] Waiting for backend port assignment...');
for (let i = 0; i < maxAttempts; i++) {
try {
const port = await invoke<number | null>('get_backend_port');
if (port) {
this.backendPort = port;
console.log(`[TauriBackendService] Backend port detected: ${port}`);
return;
}
} catch (error) {
console.error('Failed to get backend port:', error);
}
await new Promise(resolve => setTimeout(resolve, 500));
}
throw new Error('Failed to detect backend port after 15 seconds');
}
private beginHealthMonitoring() {
if (this.healthMonitor) {
return;
@@ -88,16 +136,58 @@ export class TauriBackendService {
}
async checkBackendHealth(): Promise<boolean> {
const mode = await connectionModeService.getCurrentMode();
// For remote server mode, check the configured server
if (mode !== 'offline') {
const serverConfig = await connectionModeService.getServerConfig();
if (!serverConfig) {
console.error('[TauriBackendService] Server mode but no server URL configured');
this.setStatus('unhealthy');
return false;
}
try {
const baseUrl = serverConfig.url.replace(/\/$/, '');
const healthUrl = `${baseUrl}/api/v1/info/status`;
const response = await fetch(healthUrl, {
method: 'GET',
connectTimeout: 5000,
});
const isHealthy = response.ok;
this.setStatus(isHealthy ? 'healthy' : 'unhealthy');
return isHealthy;
} catch (error) {
const errorStr = String(error);
if (!errorStr.includes('connection refused') && !errorStr.includes('No connection could be made')) {
console.error('[TauriBackendService] Server health check failed:', error);
}
this.setStatus('unhealthy');
return false;
}
}
// For offline mode, check the bundled backend via Rust
if (!this.backendStarted) {
this.setStatus('stopped');
return false;
}
if (!this.backendPort) {
console.debug('[TauriBackendService] Backend port not available yet');
return false;
}
try {
const isHealthy = await invoke<boolean>('check_backend_health');
const isHealthy = await invoke<boolean>('check_backend_health', { port: this.backendPort });
this.setStatus(isHealthy ? 'healthy' : 'unhealthy');
return isHealthy;
} catch (error) {
console.error('Health check failed:', error);
const errorStr = String(error);
if (!errorStr.includes('connection refused') && !errorStr.includes('No connection could be made')) {
console.error('[TauriBackendService] Bundled backend health check failed:', error);
}
this.setStatus('unhealthy');
return false;
}
@@ -115,6 +205,18 @@ export class TauriBackendService {
this.setStatus('unhealthy');
throw new Error('Backend failed to become healthy after 60 seconds');
}
/**
* Reset backend state (used when switching from external to local backend)
*/
reset(): void {
console.log('[TauriBackendService] Resetting backend state');
this.backendStarted = false;
this.backendPort = null;
this.setStatus('stopped');
this.healthMonitor = null;
this.startPromise = null;
}
}
export const tauriBackendService = TauriBackendService.getInstance();
@@ -0,0 +1,361 @@
import { fetch } from '@tauri-apps/plugin-http';
/**
* Tauri HTTP Client - wrapper around Tauri's native HTTP client
* Provides axios-compatible API while bypassing CORS restrictions
*/
export interface TauriHttpResponse<T = any> {
data: T;
status: number;
statusText: string;
headers: Record<string, string>;
config: TauriHttpRequestConfig;
}
export interface TauriHttpRequestConfig {
url?: string;
method?: string;
baseURL?: string;
headers?: Record<string, string>;
params?: Record<string, string | number | boolean> | any;
data?: any;
timeout?: number;
responseType?: 'json' | 'text' | 'blob' | 'arraybuffer';
withCredentials?: boolean;
// Custom properties for desktop
operationName?: string;
skipBackendReadyCheck?: boolean;
// Axios compatibility properties (ignored by Tauri HTTP)
suppressErrorToast?: boolean;
cancelToken?: any;
}
export interface TauriHttpError extends Error {
config?: TauriHttpRequestConfig;
code?: string;
request?: unknown;
response?: TauriHttpResponse;
isAxiosError: boolean;
toJSON: () => object;
}
type RequestInterceptor = (config: TauriHttpRequestConfig) => Promise<TauriHttpRequestConfig> | TauriHttpRequestConfig;
type ResponseInterceptor<T = any> = (response: TauriHttpResponse<T>) => Promise<TauriHttpResponse<T>> | TauriHttpResponse<T>;
type ErrorInterceptor = (error: any) => Promise<any>;
interface Interceptors {
request: {
handlers: RequestInterceptor[];
use: (onFulfilled: RequestInterceptor, onRejected?: ErrorInterceptor) => number;
};
response: {
handlers: { fulfilled: ResponseInterceptor; rejected?: ErrorInterceptor }[];
use: (onFulfilled: ResponseInterceptor, onRejected?: ErrorInterceptor) => number;
};
}
class TauriHttpClient {
public defaults: TauriHttpRequestConfig = {
baseURL: '',
headers: {},
timeout: 120000,
responseType: 'json',
withCredentials: true,
};
public interceptors: Interceptors = {
request: {
handlers: [],
use: (onFulfilled: RequestInterceptor, _onRejected?: ErrorInterceptor) => {
this.interceptors.request.handlers.push(onFulfilled);
return this.interceptors.request.handlers.length - 1;
},
},
response: {
handlers: [],
use: (onFulfilled: ResponseInterceptor, onRejected?: ErrorInterceptor) => {
this.interceptors.response.handlers.push({ fulfilled: onFulfilled, rejected: onRejected });
return this.interceptors.response.handlers.length - 1;
},
},
};
constructor(config?: TauriHttpRequestConfig) {
if (config) {
this.defaults = { ...this.defaults, ...config };
}
}
private createError(message: string, config?: TauriHttpRequestConfig, code?: string, response?: TauriHttpResponse): TauriHttpError {
const error = new Error(message) as TauriHttpError;
error.config = config;
error.code = code;
error.response = response;
error.isAxiosError = true;
error.toJSON = () => ({
message: error.message,
name: error.name,
config: error.config,
code: error.code,
});
return error;
}
private buildUrl(config: TauriHttpRequestConfig): string {
let url = config.url || '';
// If URL is already absolute, use it as-is
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
// Prepend baseURL if present
const baseURL = config.baseURL || this.defaults.baseURL || '';
if (baseURL) {
url = baseURL + url;
}
// Add query parameters
if (config.params && typeof config.params === 'object') {
const searchParams = new URLSearchParams();
Object.entries(config.params as Record<string, unknown>).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
searchParams.append(key, String(value));
}
});
const queryString = searchParams.toString();
if (queryString) {
url += (url.includes('?') ? '&' : '?') + queryString;
}
}
return url;
}
private async executeRequest<T = any>(config: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
// Merge with defaults
const mergedConfig: TauriHttpRequestConfig = {
...this.defaults,
...config,
headers: {
...this.defaults.headers,
...config.headers,
},
};
// Run request interceptors
let finalConfig = mergedConfig;
for (const interceptor of this.interceptors.request.handlers) {
finalConfig = await Promise.resolve(interceptor(finalConfig));
}
const url = this.buildUrl(finalConfig);
const method = (finalConfig.method || 'GET').toUpperCase();
// Prepare request body and headers
let body: BodyInit | undefined;
const headers: Record<string, string> = { ...(finalConfig.headers || {}) };
if (finalConfig.data) {
if (finalConfig.data instanceof FormData) {
// FormData can be passed directly
body = finalConfig.data;
} else if (typeof finalConfig.data === 'object') {
// Serialize as JSON
body = JSON.stringify(finalConfig.data);
if (!headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
}
} else {
body = String(finalConfig.data);
}
}
try {
// Debug logging
console.debug(`[tauriHttpClient] Fetch request:`, { url, method });
// Make the request using Tauri's native HTTP client (standard Fetch API)
const response = await fetch(url, {
method,
headers,
body,
});
// Parse response based on responseType
let data: T;
const responseType = finalConfig.responseType || 'json';
if (responseType === 'json') {
data = await response.json() as T;
} else if (responseType === 'text') {
data = (await response.text()) as T;
} else if (responseType === 'blob') {
// Standard fetch doesn't set blob.type from Content-Type header (unlike axios)
// Set it manually to match axios behavior
const blob = await response.blob();
if (!blob.type) {
const contentType = response.headers.get('content-type') || 'application/octet-stream';
data = new Blob([blob], { type: contentType }) as T;
} else {
data = blob as T;
}
} else if (responseType === 'arraybuffer') {
data = (await response.arrayBuffer()) as T;
} else {
data = await response.json() as T;
}
// Convert Headers to plain object
const responseHeaders: Record<string, string> = {};
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
const httpResponse: TauriHttpResponse<T> = {
data,
status: response.status,
statusText: response.statusText || '',
headers: responseHeaders,
config: finalConfig,
};
// Check for HTTP errors
if (!response.ok) {
const error = this.createError(
`Request failed with status code ${response.status}`,
finalConfig,
'ERR_BAD_REQUEST',
httpResponse
);
// Run error interceptors
let finalError: unknown = error;
for (const handler of this.interceptors.response.handlers) {
if (handler.rejected) {
try {
finalError = await Promise.resolve(handler.rejected(finalError));
} catch (e) {
finalError = e;
}
}
}
throw finalError;
}
// Run response interceptors
let finalResponse = httpResponse;
for (const handler of this.interceptors.response.handlers) {
finalResponse = await Promise.resolve(handler.fulfilled(finalResponse)) as TauriHttpResponse<T>;
}
return finalResponse;
} catch (error: unknown) {
// If it's already a TauriHttpError with interceptors run, re-throw
if (error && typeof error === 'object' && 'isAxiosError' in error) {
throw error;
}
// Create new error for network/other failures
const errorMessage = error instanceof Error ? error.message : 'Network Error';
const httpError = this.createError(
errorMessage,
finalConfig,
'ERR_NETWORK'
);
// Run error interceptors
let finalError: unknown = httpError;
for (const handler of this.interceptors.response.handlers) {
if (handler.rejected) {
try {
finalError = await Promise.resolve(handler.rejected(finalError));
} catch (e) {
finalError = e;
}
}
}
throw finalError;
}
}
async request<T = any>(config: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
return this.executeRequest<T>(config);
}
async get<T = any>(url: string, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
return this.executeRequest<T>({ ...config, method: 'GET', url });
}
async delete<T = any>(url: string, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
return this.executeRequest<T>({ ...config, method: 'DELETE', url });
}
async head<T = any>(url: string, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
return this.executeRequest<T>({ ...config, method: 'HEAD', url });
}
async options<T = any>(url: string, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
return this.executeRequest<T>({ ...config, method: 'OPTIONS', url });
}
async post<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
return this.executeRequest<T>({ ...config, method: 'POST', url, data });
}
async put<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
return this.executeRequest<T>({ ...config, method: 'PUT', url, data });
}
async patch<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
return this.executeRequest<T>({ ...config, method: 'PATCH', url, data });
}
// Axios compatibility methods
create(config?: TauriHttpRequestConfig): TauriHttpClient {
return new TauriHttpClient({ ...this.defaults, ...config });
}
getUri(config?: TauriHttpRequestConfig): string {
return this.buildUrl({ ...this.defaults, ...config });
}
async postForm<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
const formData = data instanceof FormData ? data : new FormData();
if (!(data instanceof FormData) && data && typeof data === 'object') {
Object.entries(data).forEach(([key, value]) => {
formData.append(key, String(value));
});
}
return this.post<T>(url, formData, config);
}
async putForm<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
const formData = data instanceof FormData ? data : new FormData();
if (!(data instanceof FormData) && data && typeof data === 'object') {
Object.entries(data).forEach(([key, value]) => {
formData.append(key, String(value));
});
}
return this.put<T>(url, formData, config);
}
async patchForm<T = any>(url: string, data?: any, config?: TauriHttpRequestConfig): Promise<TauriHttpResponse<T>> {
const formData = data instanceof FormData ? data : new FormData();
if (!(data instanceof FormData) && data && typeof data === 'object') {
Object.entries(data).forEach(([key, value]) => {
formData.append(key, String(value));
});
}
return this.patch<T>(url, formData, config);
}
}
// Factory function matching axios.create()
export function create(config?: TauriHttpRequestConfig): TauriHttpClient {
return new TauriHttpClient(config);
}
// Default instance
export default new TauriHttpClient();
@@ -1,5 +1,8 @@
import { AppProviders as CoreAppProviders, AppProvidersProps } from "@core/components/AppProviders";
import { AuthProvider } from "@app/auth/UseSession";
import { LicenseProvider } from "@app/contexts/LicenseContext";
import { CheckoutProvider } from "@app/contexts/CheckoutContext";
import UpgradeBanner from "@app/components/shared/UpgradeBanner";
export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) {
return (
@@ -8,7 +11,12 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
appConfigProviderProps={appConfigProviderProps}
>
<AuthProvider>
{children}
<LicenseProvider>
<CheckoutProvider>
<UpgradeBanner />
{children}
</CheckoutProvider>
</LicenseProvider>
</AuthProvider>
</CoreAppProviders>
);
@@ -0,0 +1,53 @@
import React, { useState } from 'react';
import { Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import licenseService from '@app/services/licenseService';
import { alert } from '@app/components/toast';
interface ManageBillingButtonProps {
returnUrl?: string;
}
export const ManageBillingButton: React.FC<ManageBillingButtonProps> = ({
returnUrl = window.location.href,
}) => {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const handleClick = async () => {
try {
setLoading(true);
// Get current license key for authentication
const licenseInfo = await licenseService.getLicenseInfo();
if (!licenseInfo.licenseKey) {
throw new Error('No license key found. Please activate a license first.');
}
// Create billing portal session with license key
const response = await licenseService.createBillingPortalSession(
returnUrl,
licenseInfo.licenseKey
);
// Open billing portal in new tab
window.open(response.url, '_blank');
setLoading(false);
} catch (error: any) {
console.error('Failed to open billing portal:', error);
alert({
alertType: 'error',
title: t('billing.portal.error', 'Failed to open billing portal'),
body: error.message || 'Please try again or contact support.',
});
setLoading(false);
}
};
return (
<Button variant="outline" onClick={handleClick} loading={loading}>
{t('billing.manageBilling', 'Manage Billing')}
</Button>
);
};
@@ -0,0 +1,507 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Modal, Button, Text, Alert, Loader, Stack, Group, Paper, SegmentedControl, Grid, Code } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { loadStripe } from '@stripe/stripe-js';
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
import licenseService, { PlanTierGroup } from '@app/services/licenseService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { pollLicenseKeyWithBackoff, activateLicenseKey, resyncExistingLicense } from '@app/utils/licenseCheckoutUtils';
// Validate Stripe key (static validation, no dynamic imports)
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
if (!STRIPE_KEY) {
console.error(
'VITE_STRIPE_PUBLISHABLE_KEY environment variable is required. ' +
'Please add it to your .env file. ' +
'Get your key from https://dashboard.stripe.com/apikeys'
);
}
if (STRIPE_KEY && !STRIPE_KEY.startsWith('pk_')) {
console.error(
`Invalid Stripe publishable key format. ` +
`Expected key starting with 'pk_', got: ${STRIPE_KEY.substring(0, 10)}...`
);
}
const stripePromise = STRIPE_KEY ? loadStripe(STRIPE_KEY) : null;
interface StripeCheckoutProps {
opened: boolean;
onClose: () => void;
planGroup: PlanTierGroup;
minimumSeats?: number;
onSuccess?: (sessionId: string) => void;
onError?: (error: string) => void;
onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void;
hostedCheckoutSuccess?: {
isUpgrade: boolean;
licenseKey?: string;
} | null;
}
type CheckoutState = {
status: 'idle' | 'loading' | 'ready' | 'success' | 'error';
clientSecret?: string;
error?: string;
sessionId?: string;
};
const StripeCheckout: React.FC<StripeCheckoutProps> = ({
opened,
onClose,
planGroup,
minimumSeats = 1,
onSuccess,
onError,
onLicenseActivated,
hostedCheckoutSuccess,
}) => {
const { t } = useTranslation();
const [state, setState] = useState<CheckoutState>({ status: 'idle' });
// Default to yearly if available (better value), otherwise monthly
const [selectedPeriod, setSelectedPeriod] = useState<'monthly' | 'yearly'>(
planGroup.yearly ? 'yearly' : 'monthly'
);
const [installationId, setInstallationId] = useState<string | null>(null);
const [currentLicenseKey, setCurrentLicenseKey] = useState<string | null>(null);
const [licenseKey, setLicenseKey] = useState<string | null>(null);
const [pollingStatus, setPollingStatus] = useState<'idle' | 'polling' | 'ready' | 'timeout'>('idle');
// Refs for polling cleanup
const isMountedRef = React.useRef(true);
const pollingTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
// Get the selected plan based on period
const selectedPlan = selectedPeriod === 'yearly' ? planGroup.yearly : planGroup.monthly;
const createCheckoutSession = async () => {
if (!selectedPlan) {
setState({
status: 'error',
error: 'Selected plan period is not available',
});
return;
}
try {
setState({ status: 'loading' });
// Fetch installation ID from backend
let fetchedInstallationId = installationId;
if (!fetchedInstallationId) {
fetchedInstallationId = await licenseService.getInstallationId();
setInstallationId(fetchedInstallationId);
}
// Fetch current license key for upgrades
let existingLicenseKey: string | undefined;
try {
const licenseInfo = await licenseService.getLicenseInfo();
if (licenseInfo && licenseInfo.licenseKey) {
existingLicenseKey = licenseInfo.licenseKey;
setCurrentLicenseKey(existingLicenseKey);
console.log('Found existing license for upgrade');
}
} catch (error) {
console.warn('Could not fetch license info, proceeding as new license:', error);
}
const response = await licenseService.createCheckoutSession({
lookup_key: selectedPlan.lookupKey,
installation_id: fetchedInstallationId,
current_license_key: existingLicenseKey,
requires_seats: selectedPlan.requiresSeats,
seat_count: Math.max(1, Math.min(minimumSeats || 1, 10000)),
});
// Check if we got a redirect URL (hosted checkout for HTTP)
if (response.url) {
console.log('Redirecting to Stripe hosted checkout:', response.url);
// Redirect to Stripe's hosted checkout page
window.location.href = response.url;
return;
}
// Otherwise, use embedded checkout (HTTPS)
setState({
status: 'ready',
clientSecret: response.clientSecret,
sessionId: response.sessionId,
});
} catch (err) {
const errorMessage =
err instanceof Error ? err.message : 'Failed to create checkout session';
setState({
status: 'error',
error: errorMessage,
});
onError?.(errorMessage);
}
};
const pollForLicenseKey = useCallback(async (installId: string) => {
// Use shared polling utility
const result = await pollLicenseKeyWithBackoff(installId, {
isMounted: () => isMountedRef.current,
onStatusChange: setPollingStatus,
});
if (result.success && result.licenseKey) {
setLicenseKey(result.licenseKey);
// Activate the license key
const activation = await activateLicenseKey(result.licenseKey, {
isMounted: () => isMountedRef.current,
onActivated: onLicenseActivated,
});
if (!activation.success) {
console.error('Failed to activate license key:', activation.error);
}
} else if (result.timedOut) {
console.warn('License key polling timed out');
} else if (result.error) {
console.error('License key polling failed:', result.error);
}
}, [onLicenseActivated]);
const handlePaymentComplete = async () => {
// Preserve state when changing status
setState(prev => ({ ...prev, status: 'success' }));
// Check if this is an upgrade (existing license key) or new plan
if (currentLicenseKey) {
// UPGRADE FLOW: Resync existing license with Keygen
console.log('Upgrade detected - resyncing existing license with Keygen');
setPollingStatus('polling');
const activation = await resyncExistingLicense({
isMounted: () => true, // Modal is open, no need to check
onActivated: onLicenseActivated,
});
if (activation.success) {
console.log(`License upgraded successfully: ${activation.licenseType}`);
setPollingStatus('ready');
} else {
console.error('Failed to sync upgraded license:', activation.error);
setPollingStatus('timeout');
}
// Notify parent (don't wait - upgrade is complete)
onSuccess?.(state.sessionId || '');
} else {
// NEW PLAN FLOW: Poll for new license key
console.log('New subscription - polling for license key');
if (installationId) {
pollForLicenseKey(installationId).finally(() => {
// Only notify parent after polling completes or times out
onSuccess?.(state.sessionId || '');
});
} else {
// No installation ID, notify immediately
onSuccess?.(state.sessionId || '');
}
}
};
const handleClose = () => {
// Clear any active polling
if (pollingTimeoutRef.current) {
clearTimeout(pollingTimeoutRef.current);
pollingTimeoutRef.current = null;
}
setState({ status: 'idle' });
setPollingStatus('idle');
setCurrentLicenseKey(null);
setLicenseKey(null);
// Reset to default period on close
setSelectedPeriod(planGroup.yearly ? 'yearly' : 'monthly');
onClose();
};
const handlePeriodChange = (value: string) => {
setSelectedPeriod(value as 'monthly' | 'yearly');
// Reset state to trigger checkout reload
setState({ status: 'idle' });
};
// Cleanup on unmount
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
if (pollingTimeoutRef.current) {
clearTimeout(pollingTimeoutRef.current);
pollingTimeoutRef.current = null;
}
};
}, []);
// Handle hosted checkout success - open directly to success state
useEffect(() => {
if (opened && hostedCheckoutSuccess) {
console.log('Opening modal to success state for hosted checkout return');
// Set appropriate state based on upgrade vs new subscription
if (hostedCheckoutSuccess.isUpgrade) {
setCurrentLicenseKey('existing'); // Flag to indicate upgrade
setPollingStatus('ready');
} else if (hostedCheckoutSuccess.licenseKey) {
setLicenseKey(hostedCheckoutSuccess.licenseKey);
setPollingStatus('ready');
}
// Set to success state to show success UI
setState({ status: 'success' });
}
}, [opened, hostedCheckoutSuccess]);
// Initialize checkout when modal opens or period changes
useEffect(() => {
// Don't reset if we're showing success state (license key)
if (state.status === 'success') {
return;
}
// Skip initialization if opening for hosted checkout success
if (hostedCheckoutSuccess) {
return;
}
if (opened && state.status === 'idle') {
createCheckoutSession();
} else if (!opened) {
setState({ status: 'idle' });
}
}, [opened, selectedPeriod, state.status, hostedCheckoutSuccess]);
const renderContent = () => {
// Check if Stripe is configured
if (!stripePromise) {
return (
<Alert color="red" title={t('payment.stripeNotConfigured', 'Stripe Not Configured')}>
<Stack gap="md">
<Text size="sm">
{t(
'payment.stripeNotConfiguredMessage',
'Stripe payment integration is not configured. Please contact your administrator.'
)}
</Text>
<Button variant="outline" onClick={handleClose}>
{t('common.close', 'Close')}
</Button>
</Stack>
</Alert>
);
}
switch (state.status) {
case 'loading':
return (
<Stack align="center" justify="center" style={{ padding: '2rem 0' }}>
<Loader size="lg" />
<Text size="sm" c="dimmed" mt="md">
{t('payment.preparing', 'Preparing your checkout...')}
</Text>
</Stack>
);
case 'ready':
{
if (!state.clientSecret || !selectedPlan) return null;
// Build period selector data with prices
const periodData = [];
if (planGroup.monthly) {
const monthlyPrice = planGroup.monthly.requiresSeats && planGroup.monthly.seatPrice
? `${planGroup.monthly.currency}${planGroup.monthly.price.toFixed(2)}${planGroup.monthly.period} + ${planGroup.monthly.currency}${planGroup.monthly.seatPrice.toFixed(2)}/seat`
: `${planGroup.monthly.currency}${planGroup.monthly.price.toFixed(2)}${planGroup.monthly.period}`;
periodData.push({
value: 'monthly',
label: `${t('payment.monthly', 'Monthly')} - ${monthlyPrice}`,
});
}
if (planGroup.yearly) {
const yearlyPrice = planGroup.yearly.requiresSeats && planGroup.yearly.seatPrice
? `${planGroup.yearly.currency}${planGroup.yearly.price.toFixed(2)}${planGroup.yearly.period} + ${planGroup.yearly.currency}${planGroup.yearly.seatPrice.toFixed(2)}/seat`
: `${planGroup.yearly.currency}${planGroup.yearly.price.toFixed(2)}${planGroup.yearly.period}`;
periodData.push({
value: 'yearly',
label: `${t('payment.yearly', 'Yearly')} - ${yearlyPrice}`,
});
}
return (
<Grid gutter="md">
{/* Left: Period Selector - only show if both periods available */}
{periodData.length > 1 && (
<Grid.Col span={3}>
<Stack gap="sm" style={{ height: '100%' }}>
<Text size="sm" fw={600}>
{t('payment.billingPeriod', 'Billing Period')}
</Text>
<SegmentedControl
value={selectedPeriod}
onChange={handlePeriodChange}
data={periodData}
orientation="vertical"
fullWidth
/>
{selectedPlan.requiresSeats && selectedPlan.seatPrice && (
<Text size="xs" c="dimmed" mt="md">
{t('payment.enterpriseNote', 'Seats can be adjusted in checkout (1-1000).')}
</Text>
)}
</Stack>
</Grid.Col>
)}
{/* Right: Stripe Checkout */}
<Grid.Col span={periodData.length > 1 ? 9 : 12}>
<EmbeddedCheckoutProvider
key={state.clientSecret}
stripe={stripePromise}
options={{
clientSecret: state.clientSecret,
onComplete: handlePaymentComplete,
}}
>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
</Grid.Col>
</Grid>
);
}
case 'success':
return (
<Alert color="green" title={t('payment.success', 'Payment Successful!')}>
<Stack gap="md">
<Text size="sm">
{t(
'payment.successMessage',
'Your subscription has been activated successfully.'
)}
</Text>
{/* License Key Polling Status */}
{pollingStatus === 'polling' && (
<Group gap="xs">
<Loader size="sm" />
<Text size="sm" c="dimmed">
{currentLicenseKey
? t('payment.syncingLicense', 'Syncing your upgraded license...')
: t('payment.generatingLicense', 'Generating your license key...')}
</Text>
</Group>
)}
{pollingStatus === 'ready' && !currentLicenseKey && licenseKey && (
<Paper withBorder p="md" radius="md" bg="gray.1">
<Stack gap="sm">
<Text size="sm" fw={600}>
{t('payment.licenseKey', 'Your License Key')}
</Text>
<Code block>{licenseKey}</Code>
<Button
variant="light"
size="sm"
onClick={() => navigator.clipboard.writeText(licenseKey)}
>
{t('common.copy', 'Copy to Clipboard')}
</Button>
<Text size="xs" c="dimmed">
{t(
'payment.licenseInstructions',
'Enter this key in Settings → Admin Plan → License Key section'
)}
</Text>
</Stack>
</Paper>
)}
{pollingStatus === 'ready' && currentLicenseKey && (
<Alert color="green" title={t('payment.upgradeComplete', 'Upgrade Complete')}>
<Text size="sm">
{t(
'payment.upgradeCompleteMessage',
'Your subscription has been upgraded successfully. Your existing license key has been updated.'
)}
</Text>
</Alert>
)}
{pollingStatus === 'timeout' && (
<Alert color="yellow" title={t('payment.licenseDelayed', 'License Key Processing')}>
<Text size="sm">
{t(
'payment.licenseDelayedMessage',
'Your license key is being generated. Please check your email shortly or contact support.'
)}
</Text>
</Alert>
)}
{pollingStatus === 'ready' && (
<Text size="xs" c="dimmed">
{t('payment.canCloseWindow', 'You can now close this window.')}
</Text>
)}
</Stack>
</Alert>
);
case 'error':
return (
<Alert color="red" title={t('payment.error', 'Payment Error')}>
<Stack gap="md">
<Text size="sm">{state.error}</Text>
<Button variant="outline" onClick={handleClose}>
{t('common.close', 'Close')}
</Button>
</Stack>
</Alert>
);
default:
return null;
}
};
return (
<Modal
opened={opened}
onClose={handleClose}
title={
<Text fw={600} size="lg">
{t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName: planGroup.name })}
</Text>
}
size="90%"
centered
withCloseButton={true}
closeOnEscape={true}
closeOnClickOutside={false}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
styles={{
body: {
minHeight: '85vh',
},
content: {
maxHeight: '95vh',
},
}}
>
{renderContent()}
</Modal>
);
};
export default StripeCheckout;
@@ -0,0 +1,144 @@
import React, { useState, useEffect } from 'react';
import { Group, Text, Button, ActionIcon, Paper } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@app/auth/UseSession';
import { useCheckout } from '@app/contexts/CheckoutContext';
import { useLicense } from '@app/contexts/LicenseContext';
import { mapLicenseToTier } from '@app/services/licenseService';
import LocalIcon from '@app/components/shared/LocalIcon';
import { isSupabaseConfigured } from '@app/services/supabaseClient';
/**
* UpgradeBanner - Dismissable top banner encouraging users to upgrade
*
* This component demonstrates:
* - How to check authentication status with useAuth()
* - How to check license status with licenseService
* - How to open checkout modal with useCheckout()
* - How to persist dismissal state with localStorage
*
* To remove this banner:
* 1. Remove the import and component from AppProviders.tsx
* 2. Delete this file
*/
const UpgradeBanner: React.FC = () => {
const { t } = useTranslation();
const { user } = useAuth();
const { openCheckout } = useCheckout();
const { licenseInfo, loading: licenseLoading } = useLicense();
const [isVisible, setIsVisible] = useState(false);
// Check if user should see the banner
useEffect(() => {
// Don't show if not logged in
if (!user) {
setIsVisible(false);
return;
}
// Don't show if Supabase is not configured (no checkout available)
if (!isSupabaseConfigured) {
setIsVisible(false);
return;
}
// Don't show while license is loading
if (licenseLoading) {
return;
}
// Check if banner was dismissed
const dismissed = localStorage.getItem('upgradeBannerDismissed');
if (dismissed === 'true') {
setIsVisible(false);
return;
}
// Check license status from global context
const tier = mapLicenseToTier(licenseInfo);
// Show banner only for free tier users
if (tier === 'free' || tier === null) {
setIsVisible(true);
} else {
// Auto-hide banner if user upgrades
setIsVisible(false);
}
}, [user, licenseInfo, licenseLoading]);
// Handle dismiss
const handleDismiss = () => {
localStorage.setItem('upgradeBannerDismissed', 'true');
setIsVisible(false);
};
// Handle upgrade button click
const handleUpgrade = () => {
openCheckout('server', {
currency: 'gbp',
minimumSeats: 1,
onSuccess: () => {
// Banner will auto-hide on next render when license is detected
setIsVisible(false);
},
});
};
// Don't render anything if loading or not visible
if (licenseLoading || !isVisible) {
return null;
}
return (
<Paper
shadow="sm"
p="md"
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 1000,
borderRadius: 0,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="md" wrap="nowrap">
<LocalIcon icon="stars-rounded" width="1.5rem" height="1.5rem" />
<div>
<Text size="sm" fw={600}>
{t('upgradeBanner.title', 'Upgrade to Server Plan')}
</Text>
<Text size="xs" opacity={0.9}>
{t('upgradeBanner.message', 'Get the most out of Stirling PDF with unlimited users and advanced features')}
</Text>
</div>
</Group>
<Group gap="xs" wrap="nowrap">
<Button
variant="white"
size="sm"
onClick={handleUpgrade}
leftSection={<LocalIcon icon="upgrade-rounded" width="1rem" height="1rem" />}
>
{t('upgradeBanner.upgradeButton', 'Upgrade Now')}
</Button>
<ActionIcon
variant="subtle"
color="white"
size="lg"
onClick={handleDismiss}
aria-label={t('upgradeBanner.dismiss', 'Dismiss banner')}
>
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
</ActionIcon>
</Group>
</Group>
</Paper>
);
};
export default UpgradeBanner;
@@ -10,6 +10,7 @@ import AdminDatabaseSection from '@app/components/shared/config/configSections/A
import AdminAdvancedSection from '@app/components/shared/config/configSections/AdminAdvancedSection';
import AdminLegalSection from '@app/components/shared/config/configSections/AdminLegalSection';
import AdminPremiumSection from '@app/components/shared/config/configSections/AdminPremiumSection';
import AdminPlanSection from '@app/components/shared/config/configSections/AdminPlanSection';
import AdminFeaturesSection from '@app/components/shared/config/configSections/AdminFeaturesSection';
import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection';
import AdminAuditSection from '@app/components/shared/config/configSections/AdminAuditSection';
@@ -136,6 +137,14 @@ export const createConfigNavSections = (
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'adminPlan',
label: 'Plan',
icon: 'star-rounded',
component: <AdminPlanSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'adminAudit',
label: 'Audit',
@@ -0,0 +1,240 @@
import React, { useState, useCallback, useEffect } from 'react';
import { Divider, Loader, Alert, Select, Group, Text, Collapse, Button, TextInput, Stack, Paper } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { usePlans } from '@app/hooks/usePlans';
import licenseService, { PlanTierGroup } from '@app/services/licenseService';
import { useCheckout } from '@app/contexts/CheckoutContext';
import { useLicense } from '@app/contexts/LicenseContext';
import AvailablePlansSection from '@app/components/shared/config/configSections/plan/AvailablePlansSection';
import StaticPlanSection from '@app/components/shared/config/configSections/plan/StaticPlanSection';
import { alert } from '@app/components/toast';
import LocalIcon from '@app/components/shared/LocalIcon';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { ManageBillingButton } from '@app/components/shared/ManageBillingButton';
import { isSupabaseConfigured } from '@app/services/supabaseClient';
const AdminPlanSection: React.FC = () => {
const { t } = useTranslation();
const { openCheckout } = useCheckout();
const { licenseInfo, refetchLicense } = useLicense();
const [currency, setCurrency] = useState<string>('gbp');
const [useStaticVersion, setUseStaticVersion] = useState(false);
const [showLicenseKey, setShowLicenseKey] = useState(false);
const [licenseKeyInput, setLicenseKeyInput] = useState<string>('');
const [savingLicense, setSavingLicense] = useState(false);
const { plans, loading, error, refetch } = usePlans(currency);
// Check if we should use static version
useEffect(() => {
// Check if Stripe and Supabase are configured
const stripeKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
if (!stripeKey || !isSupabaseConfigured || error) {
setUseStaticVersion(true);
}
}, [error]);
const handleSaveLicense = async () => {
try {
setSavingLicense(true);
// Allow empty string to clear/remove license
const response = await licenseService.saveLicenseKey(licenseKeyInput.trim());
if (response.success) {
// Refresh license context to update all components
await refetchLicense();
alert({
alertType: 'success',
title: t('admin.settings.premium.key.success', 'License Key Saved'),
body: t('admin.settings.premium.key.successMessage', 'Your license key has been activated successfully. No restart required.'),
});
// Clear input
setLicenseKeyInput('');
} else {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: response.error || t('admin.settings.saveError', 'Failed to save license key'),
});
}
} catch (error) {
console.error('Failed to save license key:', error);
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save license key'),
});
} finally {
setSavingLicense(false);
}
};
const currencyOptions = [
{ value: 'gbp', label: 'British pound (GBP, £)' },
{ value: 'usd', label: 'US dollar (USD, $)' },
{ value: 'eur', label: 'Euro (EUR, €)' },
{ value: 'cny', label: 'Chinese yuan (CNY, ¥)' },
{ value: 'inr', label: 'Indian rupee (INR, ₹)' },
{ value: 'brl', label: 'Brazilian real (BRL, R$)' },
{ value: 'idr', label: 'Indonesian rupiah (IDR, Rp)' },
];
const handleUpgradeClick = useCallback(
(planGroup: PlanTierGroup) => {
// Only allow upgrades for server and enterprise tiers
if (planGroup.tier === 'free') {
return;
}
// Use checkout context to open checkout modal
openCheckout(planGroup.tier, {
currency,
onSuccess: () => {
// Refetch plans after successful payment
// License context will auto-update
refetch();
},
});
},
[openCheckout, currency, refetch]
);
// Show static version if Stripe is not configured or there's an error
if (useStaticVersion) {
return <StaticPlanSection currentLicenseInfo={licenseInfo ?? undefined} />;
}
// Early returns after all hooks are called
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '2rem 0' }}>
<Loader size="lg" />
</div>
);
}
if (error) {
// Fallback to static version on error
return <StaticPlanSection currentLicenseInfo={licenseInfo ?? undefined} />;
}
if (!plans || plans.length === 0) {
return (
<Alert color="yellow" title="No data available">
Plans data is not available at the moment.
</Alert>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
{/* Currency Selection & Manage Subscription */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text size="lg" fw={600}>
{t('plan.currency', 'Currency')}
</Text>
<Select
value={currency}
onChange={(value) => setCurrency(value || 'gbp')}
data={currencyOptions}
searchable
clearable={false}
w={300}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
</Group>
{/* Manage Subscription Button - Only show if user has active license and Supabase is configured */}
{licenseInfo?.licenseKey && isSupabaseConfigured && (
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{t('plan.manageSubscription.description', 'Manage your subscription, billing, and payment methods')}
</Text>
<ManageBillingButton />
</Group>
)}
</Stack>
</Paper>
<AvailablePlansSection
plans={plans}
currentLicenseInfo={licenseInfo}
onUpgradeClick={handleUpgradeClick}
/>
<Divider />
{/* License Key Section */}
<div>
<Button
variant="subtle"
leftSection={<LocalIcon icon={showLicenseKey ? "expand-less-rounded" : "expand-more-rounded"} width="1.25rem" height="1.25rem" />}
onClick={() => setShowLicenseKey(!showLicenseKey)}
>
{t('admin.settings.premium.licenseKey.toggle', 'Got a license key or certificate file?')}
</Button>
<Collapse in={showLicenseKey} mt="md">
<Stack gap="md">
<Alert
variant="light"
color="blue"
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Text size="sm">
{t('admin.settings.premium.licenseKey.info', 'If you have a license key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features.')}
</Text>
</Alert>
{/* Severe warning if license already exists */}
{licenseInfo?.licenseKey && (
<Alert
variant="light"
color="red"
icon={<LocalIcon icon="warning-rounded" width="1rem" height="1rem" />}
title={t('admin.settings.premium.key.overwriteWarning.title', '⚠️ Warning: Existing License Detected')}
>
<Stack gap="xs">
<Text size="sm" fw={600}>
{t('admin.settings.premium.key.overwriteWarning.line1', 'Overwriting your current license key cannot be undone.')}
</Text>
<Text size="sm">
{t('admin.settings.premium.key.overwriteWarning.line2', 'Your previous license will be permanently lost unless you have backed it up elsewhere.')}
</Text>
<Text size="sm" fw={500}>
{t('admin.settings.premium.key.overwriteWarning.line3', 'Important: Keep license keys private and secure. Never share them publicly.')}
</Text>
</Stack>
</Alert>
)}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<TextInput
label={t('admin.settings.premium.key.label', 'License Key')}
description={t('admin.settings.premium.key.description', 'Enter your premium or enterprise license key. Premium features will be automatically enabled when a key is provided.')}
value={licenseKeyInput}
onChange={(e) => setLicenseKeyInput(e.target.value)}
placeholder={licenseInfo?.licenseKey || '00000000-0000-0000-0000-000000000000'}
type="password"
disabled={savingLicense}
/>
<Group justify="flex-end">
<Button onClick={handleSaveLicense} loading={savingLicense} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Collapse>
</div>
</div>
);
};
export default AdminPlanSection;
@@ -0,0 +1,109 @@
import React, { useState, useMemo } from 'react';
import { Button, Collapse } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import licenseService, { PlanTier, PlanTierGroup, LicenseInfo, mapLicenseToTier } from '@app/services/licenseService';
import PlanCard from '@app/components/shared/config/configSections/plan/PlanCard';
import FeatureComparisonTable from '@app/components/shared/config/configSections/plan/FeatureComparisonTable';
interface AvailablePlansSectionProps {
plans: PlanTier[];
currentPlanId?: string;
currentLicenseInfo?: LicenseInfo | null;
onUpgradeClick: (planGroup: PlanTierGroup) => void;
}
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
plans,
currentLicenseInfo,
onUpgradeClick,
}) => {
const { t } = useTranslation();
const [showComparison, setShowComparison] = useState(false);
// Group plans by tier (Free, Server, Enterprise)
const groupedPlans = useMemo(() => {
return licenseService.groupPlansByTier(plans);
}, [plans]);
// Calculate current tier from license info
const currentTier = useMemo(() => {
return mapLicenseToTier(currentLicenseInfo || null);
}, [currentLicenseInfo]);
// Determine if the current tier matches (checks both Stripe subscription and license)
const isCurrentTier = (tierGroup: PlanTierGroup): boolean => {
// Check license tier match
if (currentTier && tierGroup.tier === currentTier) {
return true;
}
return false;
};
// Determine if selecting this plan would be a downgrade
const isDowngrade = (tierGroup: PlanTierGroup): boolean => {
if (!currentTier) return false;
// Define tier hierarchy: enterprise > server > free
const tierHierarchy: Record<string, number> = {
'enterprise': 3,
'server': 2,
'free': 1
};
const currentLevel = tierHierarchy[currentTier] || 0;
const targetLevel = tierHierarchy[tierGroup.tier] || 0;
return currentLevel > targetLevel;
};
return (
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('plan.availablePlans.title', 'Available Plans')}
</h3>
<p
style={{
margin: '0.25rem 0 1rem 0',
color: 'var(--mantine-color-dimmed)',
fontSize: '0.875rem',
}}
>
{t('plan.availablePlans.subtitle', 'Choose the plan that fits your needs')}
</p>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '1rem',
marginBottom: '1rem',
}}
>
{groupedPlans.map((group) => (
<PlanCard
key={group.tier}
planGroup={group}
isCurrentTier={isCurrentTier(group)}
isDowngrade={isDowngrade(group)}
currentLicenseInfo={currentLicenseInfo}
onUpgradeClick={onUpgradeClick}
/>
))}
</div>
<div style={{ textAlign: 'center' }}>
<Button variant="subtle" onClick={() => setShowComparison(!showComparison)}>
{showComparison
? t('plan.hideComparison', 'Hide Feature Comparison')
: t('plan.showComparison', 'Compare All Features')}
</Button>
</div>
<Collapse in={showComparison}>
<FeatureComparisonTable plans={groupedPlans} />
</Collapse>
</div>
);
};
export default AvailablePlansSection;
@@ -0,0 +1,93 @@
import React from 'react';
import { Card, Badge, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PlanFeature } from '@app/services/licenseService';
interface PlanWithFeatures {
name: string;
features: PlanFeature[];
popular?: boolean;
tier?: string;
}
interface FeatureComparisonTableProps {
plans: PlanWithFeatures[];
}
const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({ plans }) => {
const { t } = useTranslation();
return (
<Card padding="lg" radius="md" withBorder style={{ marginTop: '1rem' }}>
<Text size="lg" fw={600} mb="md">
{t('plan.featureComparison', 'Feature Comparison')}
</Text>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--mantine-color-gray-3)' }}>
<th style={{ textAlign: 'left', padding: '0.75rem' }}>
{t('plan.feature.title', 'Feature')}
</th>
{plans.map((plan, index) => (
<th
key={plan.tier || plan.name || index}
style={{
textAlign: 'center',
padding: '0.75rem',
minWidth: '8rem',
position: 'relative'
}}
>
{plan.name}
{plan.popular && (
<Badge
color="blue"
variant="filled"
size="xs"
style={{
position: 'absolute',
top: '0.5rem',
right: '0.5rem',
}}
>
{t('plan.popular', 'Popular')}
</Badge>
)}
</th>
))}
</tr>
</thead>
<tbody>
{plans[0]?.features.map((_, featureIndex) => (
<tr
key={featureIndex}
style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}
>
<td style={{ padding: '0.75rem' }}>
{plans[0].features[featureIndex].name}
</td>
{plans.map((plan, planIndex) => (
<td key={planIndex} style={{ textAlign: 'center', padding: '0.75rem' }}>
{plan.features[featureIndex]?.included ? (
<Text c="green" fw={600} size="lg">
</Text>
) : (
<Text c="gray" size="sm">
</Text>
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Card>
);
};
export default FeatureComparisonTable;
@@ -0,0 +1,202 @@
import React from 'react';
import { Button, Card, Badge, Text, Stack, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PlanTierGroup, LicenseInfo } from '@app/services/licenseService';
interface PlanCardProps {
planGroup: PlanTierGroup;
isCurrentTier: boolean;
isDowngrade: boolean;
currentLicenseInfo?: LicenseInfo | null;
onUpgradeClick: (planGroup: PlanTierGroup) => void;
}
const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngrade, currentLicenseInfo, onUpgradeClick }) => {
const { t } = useTranslation();
// Render Free plan
if (planGroup.tier === 'free') {
return (
<Card
padding="lg"
radius="md"
withBorder
style={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
minHeight: '400px',
borderColor: isCurrentTier ? 'var(--mantine-color-green-6)' : undefined,
borderWidth: isCurrentTier ? '2px' : undefined,
}}
>
{isCurrentTier && (
<Badge
color="green"
variant="filled"
size="sm"
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
>
{t('plan.current', 'Current Plan')}
</Badge>
)}
<Stack gap="md" style={{ height: '100%' }}>
<div>
<Text size="xl" fw={700} mb="xs">
{planGroup.name}
</Text>
<Text size="xs" c="dimmed" mb="xs" style={{ opacity: 0 }}>
{t('plan.from', 'From')}
</Text>
<Text size="2.5rem" fw={700} style={{ lineHeight: 1 }}>
£0
</Text>
<Text size="sm" c="dimmed" mt="xs">
{t('plan.free.forever', 'Forever free')}
</Text>
</div>
<Divider />
<Stack gap="xs">
{planGroup.highlights.map((highlight, index) => (
<Text key={index} size="sm" c="dimmed">
{highlight}
</Text>
))}
</Stack>
<div style={{ flexGrow: 1 }} />
<Button variant="filled" disabled fullWidth>
{isCurrentTier
? t('plan.current', 'Current Plan')
: t('plan.free.included', 'Included')}
</Button>
</Stack>
</Card>
);
}
// Render Server or Enterprise plans
const { monthly, yearly } = planGroup;
const isEnterprise = planGroup.tier === 'enterprise';
// Calculate "From" pricing - show yearly price divided by 12 for lowest monthly equivalent
let displayPrice = monthly?.price || 0;
let displaySeatPrice = monthly?.seatPrice;
let displayCurrency = monthly?.currency || '£';
if (yearly) {
displayPrice = Math.round(yearly.price / 12);
displaySeatPrice = yearly.seatPrice ? Math.round(yearly.seatPrice / 12) : undefined;
displayCurrency = yearly.currency;
}
return (
<Card
padding="lg"
radius="md"
withBorder
style={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
minHeight: '400px',
borderColor: isCurrentTier ? 'var(--mantine-color-green-6)' : undefined,
borderWidth: isCurrentTier ? '2px' : undefined,
}}
>
{isCurrentTier ? (
<Badge
color="green"
variant="filled"
size="sm"
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
>
{t('plan.current', 'Current Plan')}
</Badge>
) : planGroup.popular ? (
<Badge
variant="filled"
size="sm"
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
>
{t('plan.popular', 'Popular')}
</Badge>
) : null}
<Stack gap="md" style={{ height: '100%' }}>
{/* Tier Name */}
<div>
<Text size="xl" fw={700} mb="xs">
{planGroup.name}
</Text>
<Text size="xs" c="dimmed" mb="xs">
{t('plan.from', 'From')}
</Text>
{/* Price */}
{isEnterprise && displaySeatPrice !== undefined ? (
<>
<Text size="2.5rem" fw={700} style={{ lineHeight: 1 }}>
{displayCurrency}{displayPrice}
</Text>
<Text size="sm" c="dimmed" mt="xs">
+ {displayCurrency}{displaySeatPrice}/seat {t('plan.perMonth', '/month')}
</Text>
</>
) : (
<>
<Text size="2.5rem" fw={700} style={{ lineHeight: 1 }}>
{displayCurrency}{displayPrice}
</Text>
<Text size="sm" c="dimmed" mt="xs">
{t('plan.perMonth', '/month')}
</Text>
</>
)}
{/* Show seat count for enterprise plans when current */}
{isEnterprise && isCurrentTier && currentLicenseInfo && currentLicenseInfo.maxUsers > 0 && (
<Text size="sm" c="green" fw={500} mt="xs">
{t('plan.licensedSeats', 'Licensed: {{count}} seats', { count: currentLicenseInfo.maxUsers })}
</Text>
)}
</div>
<Divider />
{/* Highlights */}
<Stack gap="xs">
{planGroup.highlights.map((highlight, index) => (
<Text key={index} size="sm" c="dimmed">
{highlight}
</Text>
))}
</Stack>
<div style={{ flexGrow: 1 }} />
{/* Single Upgrade Button */}
<Button
variant={isCurrentTier || isDowngrade ? 'light' : 'filled'}
fullWidth
onClick={() => onUpgradeClick(planGroup)}
disabled={isCurrentTier || isDowngrade}
>
{isCurrentTier
? t('plan.current', 'Current Plan')
: isDowngrade
? t('plan.includedInCurrent', 'Included in Your Plan')
: isEnterprise
? t('plan.selectPlan', 'Select Plan')
: t('plan.upgrade', 'Upgrade')}
</Button>
</Stack>
</Card>
);
};
export default PlanCard;
@@ -0,0 +1,338 @@
import React, { useState, useEffect } from 'react';
import { Card, Text, Group, Stack, Badge, Button, Collapse, Alert, TextInput, Paper, Loader, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { alert } from '@app/components/toast';
import { LicenseInfo, mapLicenseToTier } from '@app/services/licenseService';
import { PLAN_FEATURES, PLAN_HIGHLIGHTS } from '@app/constants/planConstants';
import FeatureComparisonTable from '@app/components/shared/config/configSections/plan/FeatureComparisonTable';
interface PremiumSettingsData {
key?: string;
enabled?: boolean;
}
interface StaticPlanSectionProps {
currentLicenseInfo?: LicenseInfo;
}
const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInfo }) => {
const { t } = useTranslation();
const [showLicenseKey, setShowLicenseKey] = useState(false);
const [showComparison, setShowComparison] = useState(false);
// Premium/License key management
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
const {
settings: premiumSettings,
setSettings: setPremiumSettings,
loading: premiumLoading,
saving: premiumSaving,
fetchSettings: fetchPremiumSettings,
saveSettings: savePremiumSettings,
isFieldPending,
} = useAdminSettings<PremiumSettingsData>({
sectionName: 'premium',
});
useEffect(() => {
fetchPremiumSettings();
}, []);
const handleSaveLicense = async () => {
try {
await savePremiumSettings();
showRestartModal();
} catch (_error) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
});
}
};
const staticPlans = [
{
id: 'free',
name: t('plan.free.name', 'Free'),
price: 0,
currency: '£',
period: '',
highlights: PLAN_HIGHLIGHTS.FREE,
features: PLAN_FEATURES.FREE,
maxUsers: 5,
},
{
id: 'server',
name: 'Server',
price: 0,
currency: '',
period: '',
popular: false,
highlights: PLAN_HIGHLIGHTS.SERVER_MONTHLY,
features: PLAN_FEATURES.SERVER,
maxUsers: 'Unlimited users',
},
{
id: 'enterprise',
name: t('plan.enterprise.name', 'Enterprise'),
price: 0,
currency: '',
period: '',
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_MONTHLY,
features: PLAN_FEATURES.ENTERPRISE,
maxUsers: 'Custom',
},
];
const getCurrentPlan = () => {
const tier = mapLicenseToTier(currentLicenseInfo || null);
if (tier === 'enterprise') return staticPlans[2];
if (tier === 'server') return staticPlans[1];
return staticPlans[0]; // free
};
const currentPlan = getCurrentPlan();
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
{/* Current Plan Section */}
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('plan.activePlan.title', 'Active Plan')}
</h3>
<p
style={{
margin: '0.25rem 0 1rem 0',
color: 'var(--mantine-color-dimmed)',
fontSize: '0.875rem',
}}
>
{t('plan.activePlan.subtitle', 'Your current subscription details')}
</p>
<Card padding="lg" radius="md" withBorder>
<Group justify="space-between" align="center">
<Stack gap="xs">
<Group gap="sm">
<Text size="lg" fw={600}>
{currentPlan.name}
</Text>
<Badge color="green" variant="light">
{t('subscription.status.active', 'Active')}
</Badge>
</Group>
{currentLicenseInfo && (
<Text size="sm" c="dimmed">
{t('plan.static.maxUsers', 'Max Users')}: {currentLicenseInfo.maxUsers}
</Text>
)}
</Stack>
<div style={{ textAlign: 'right' }}>
<Text size="xl" fw={700}>
{currentPlan.price === 0 ? t('plan.free.name', 'Free') : `${currentPlan.currency}${currentPlan.price}${currentPlan.period}`}
</Text>
</div>
</Group>
</Card>
</div>
{/* Available Plans */}
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('plan.availablePlans.title', 'Available Plans')}
</h3>
<p
style={{
margin: '0.25rem 0 1rem 0',
color: 'var(--mantine-color-dimmed)',
fontSize: '0.875rem',
}}
>
{t('plan.static.contactToUpgrade', 'Contact us to upgrade or customize your plan')}
</p>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '1rem',
paddingBottom: '1rem',
}}
>
{staticPlans.map((plan) => (
<Card
key={plan.id}
padding="lg"
radius="md"
withBorder
style={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
borderColor: plan.id === currentPlan.id ? 'var(--mantine-color-green-6)' : undefined,
borderWidth: plan.id === currentPlan.id ? '2px' : undefined,
}}
>
{plan.id === currentPlan.id && (
<Badge
color="green"
variant="filled"
size="sm"
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
>
{t('plan.current', 'Current Plan')}
</Badge>
)}
{plan.popular && plan.id !== currentPlan.id && (
<Badge
variant="filled"
size="xs"
style={{ position: 'absolute', top: '0.5rem', right: '0.5rem' }}
>
{t('plan.popular', 'Popular')}
</Badge>
)}
<Stack gap="md" style={{ height: '100%' }}>
<div>
<Text size="lg" fw={600}>
{plan.name}
</Text>
<Group gap="xs" style={{ alignItems: 'baseline' }}>
<Text size="xl" fw={700} style={{ fontSize: '2rem' }}>
{plan.price === 0 && plan.id !== 'free'
? t('plan.customPricing', 'Custom')
: plan.price === 0
? t('plan.free.name', 'Free')
: `${plan.currency}${plan.price}`}
</Text>
{plan.period && (
<Text size="sm" c="dimmed">
{plan.period}
</Text>
)}
</Group>
<Text size="xs" c="dimmed" mt="xs">
{typeof plan.maxUsers === 'string'
? plan.maxUsers
: `${t('plan.static.upTo', 'Up to')} ${plan.maxUsers} ${t('workspace.people.license.users', 'users')}`}
</Text>
</div>
<Stack gap="xs">
{plan.highlights.map((highlight, index) => (
<Text key={index} size="sm" c="dimmed">
{highlight}
</Text>
))}
</Stack>
<div style={{ flexGrow: 1 }} />
<Button
variant={plan.id === currentPlan.id ? 'light' : 'filled'}
disabled={plan.id === currentPlan.id}
fullWidth
onClick={() =>
window.open('https://www.stirling.com/contact', '_blank')
}
>
{plan.id === currentPlan.id
? t('plan.current', 'Current Plan')
: t('plan.contact', 'Contact Us')}
</Button>
</Stack>
</Card>
))}
</div>
{/* Feature Comparison Toggle */}
<div style={{ textAlign: 'center', marginTop: '1rem' }}>
<Button variant="subtle" onClick={() => setShowComparison(!showComparison)}>
{showComparison
? t('plan.hideComparison', 'Hide Feature Comparison')
: t('plan.showComparison', 'Compare All Features')}
</Button>
</div>
{/* Feature Comparison Table */}
<Collapse in={showComparison}>
<FeatureComparisonTable plans={staticPlans} />
</Collapse>
</div>
<Divider />
{/* License Key Section */}
<div>
<Button
variant="subtle"
leftSection={<LocalIcon icon={showLicenseKey ? "expand-less-rounded" : "expand-more-rounded"} width="1.25rem" height="1.25rem" />}
onClick={() => setShowLicenseKey(!showLicenseKey)}
>
{t('admin.settings.premium.licenseKey.toggle', 'Got a license key or certificate file?')}
</Button>
<Collapse in={showLicenseKey} mt="md">
<Stack gap="md">
<Alert
variant="light"
color="blue"
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Text size="sm">
{t('admin.settings.premium.licenseKey.info', 'If you have a license key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features.')}
</Text>
</Alert>
{premiumLoading ? (
<Stack align="center" justify="center" h={100}>
<Loader size="md" />
</Stack>
) : (
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div>
<TextInput
label={
<Group gap="xs">
<span>{t('admin.settings.premium.key.label', 'License Key')}</span>
<PendingBadge show={isFieldPending('key')} />
</Group>
}
description={t('admin.settings.premium.key.description', 'Enter your premium or enterprise license key. Premium features will be automatically enabled when a key is provided.')}
value={premiumSettings.key || ''}
onChange={(e) => setPremiumSettings({ ...premiumSettings, key: e.target.value })}
placeholder="00000000-0000-0000-0000-000000000000"
/>
</div>
<Group justify="flex-end">
<Button onClick={handleSaveLicense} loading={premiumSaving} size="sm">
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
</Paper>
)}
</Stack>
</Collapse>
</div>
{/* Restart Confirmation Modal */}
<RestartConfirmationModal
opened={restartModalOpened}
onClose={closeRestartModal}
onRestart={restartServer}
/>
</div>
);
};
export default StaticPlanSection;
@@ -0,0 +1,97 @@
import { PlanFeature } from '@app/services/licenseService';
/**
* Shared plan feature definitions for Stirling PDF Self-Hosted
* Used by both dynamic (Stripe) and static (fallback) plan displays
*/
export const PLAN_FEATURES = {
FREE: [
{ name: 'Self-hosted deployment', included: true },
{ name: 'All PDF operations', included: true },
{ name: 'Secure Login Support', included: true },
{ name: 'Community support', included: true },
{ name: 'Regular updates', included: true },
{ name: 'up to 5 users', included: true },
{ name: 'Unlimited users', included: false },
{ name: 'Google drive integration', included: false },
{ name: 'External Database', included: false },
{ name: 'Editing text in pdfs', included: false },
{ name: 'Users limited to seats', included: false },
{ name: 'SSO', included: false },
{ name: 'Auditing', included: false },
{ name: 'Usage tracking', included: false },
{ name: 'Prometheus Support', included: false },
{ name: 'Custom PDF metadata', included: false },
] as PlanFeature[],
SERVER: [
{ name: 'Self-hosted deployment', included: true },
{ name: 'All PDF operations', included: true },
{ name: 'Secure Login Support', included: true },
{ name: 'Community support', included: true },
{ name: 'Regular updates', included: true },
{ name: 'Up to 5 users', included: false },
{ name: 'Unlimited users', included: true },
{ name: 'Google drive integration', included: true },
{ name: 'External Database', included: true },
{ name: 'Editing text in pdfs', included: true },
{ name: 'Users limited to seats', included: false },
{ name: 'SSO', included: false },
{ name: 'Auditing', included: false },
{ name: 'Usage tracking', included: false },
{ name: 'Prometheus Support', included: false },
{ name: 'Custom PDF metadata', included: false },
] as PlanFeature[],
ENTERPRISE: [
{ name: 'Self-hosted deployment', included: true },
{ name: 'All PDF operations', included: true },
{ name: 'Secure Login Support', included: true },
{ name: 'Community support', included: true },
{ name: 'Regular updates', included: true },
{ name: 'up to 5 users', included: false },
{ name: 'Unlimited users', included: false },
{ name: 'Google drive integration', included: true },
{ name: 'External Database', included: true },
{ name: 'Editing text in pdfs', included: true },
{ name: 'Users limited to seats', included: true },
{ name: 'SSO', included: true },
{ name: 'Auditing', included: true },
{ name: 'Usage tracking', included: true },
{ name: 'Prometheus Support', included: true },
{ name: 'Custom PDF metadata', included: true },
] as PlanFeature[],
} as const;
export const PLAN_HIGHLIGHTS = {
FREE: [
'Up to 5 users',
'Self-hosted',
'All basic features'
],
SERVER_MONTHLY: [
'Self-hosted on your infrastructure',
'Unlimited users',
'Advanced integrations',
'Cancel anytime'
],
SERVER_YEARLY: [
'Self-hosted on your infrastructure',
'Unlimited users',
'Advanced integrations',
'Save with annual billing'
],
ENTERPRISE_MONTHLY: [
'Enterprise features (SSO, Auditing)',
'Usage tracking & Prometheus',
'Custom PDF metadata',
'Per-seat licensing'
],
ENTERPRISE_YEARLY: [
'Enterprise features (SSO, Auditing)',
'Usage tracking & Prometheus',
'Custom PDF metadata',
'Save with annual billing'
]
} as const;
@@ -0,0 +1,350 @@
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { usePlans } from '@app/hooks/usePlans';
import licenseService, { PlanTierGroup, LicenseInfo, mapLicenseToTier } from '@app/services/licenseService';
import StripeCheckout from '@app/components/shared/StripeCheckout';
import { userManagementService } from '@app/services/userManagementService';
import { alert } from '@app/components/toast';
import { pollLicenseKeyWithBackoff, activateLicenseKey, resyncExistingLicense } from '@app/utils/licenseCheckoutUtils';
import { useLicense } from '@app/contexts/LicenseContext';
import { isSupabaseConfigured } from '@app/services/supabaseClient';
export interface CheckoutOptions {
minimumSeats?: number; // Override calculated seats for enterprise
currency?: string; // Optional currency override (defaults to 'gbp')
onSuccess?: (sessionId: string) => void; // Callback after successful payment
onError?: (error: string) => void; // Callback on error
}
interface CheckoutContextValue {
openCheckout: (
tier: 'server' | 'enterprise',
options?: CheckoutOptions
) => Promise<void>;
closeCheckout: () => void;
isOpen: boolean;
isLoading: boolean;
}
const CheckoutContext = createContext<CheckoutContextValue | undefined>(undefined);
interface CheckoutProviderProps {
children: ReactNode;
defaultCurrency?: string;
}
export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
children,
defaultCurrency = 'gbp'
}) => {
const { t } = useTranslation();
const { refetchLicense } = useLicense();
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [selectedPlanGroup, setSelectedPlanGroup] = useState<PlanTierGroup | null>(null);
const [minimumSeats, setMinimumSeats] = useState<number>(1);
const [currentCurrency, setCurrentCurrency] = useState(defaultCurrency);
const [currentOptions, setCurrentOptions] = useState<CheckoutOptions>({});
const [hostedCheckoutSuccess, setHostedCheckoutSuccess] = useState<{
isUpgrade: boolean;
licenseKey?: string;
} | null>(null);
// Load plans with current currency
const { plans, refetch: refetchPlans } = usePlans(currentCurrency);
// Handle return from hosted Stripe checkout
useEffect(() => {
const handleCheckoutReturn = async () => {
const urlParams = new URLSearchParams(window.location.search);
const paymentStatus = urlParams.get('payment_status');
const sessionId = urlParams.get('session_id');
if (paymentStatus === 'success' && sessionId) {
console.log('Payment successful via hosted checkout:', sessionId);
// Clear URL parameters
window.history.replaceState({}, '', window.location.pathname);
// Fetch current license info to determine upgrade vs new
let licenseInfo: LicenseInfo | null = null;
try {
licenseInfo = await licenseService.getLicenseInfo();
} catch (err) {
console.warn('Could not fetch license info:', err);
}
// Check if this is an upgrade or new subscription
if (licenseInfo?.licenseKey) {
// UPGRADE: Resync existing license with Keygen
console.log('Upgrade detected - resyncing existing license');
const activation = await resyncExistingLicense();
if (activation.success) {
console.log('License synced successfully, refreshing license context');
// Refresh global license context
await refetchLicense();
await refetchPlans();
// Determine tier from license type
const tier = activation.licenseType === 'ENTERPRISE' ? 'enterprise' : 'server';
const planGroups = licenseService.groupPlansByTier(plans);
const planGroup = planGroups.find(pg => pg.tier === tier);
if (planGroup) {
// Reopen modal to show success
setSelectedPlanGroup(planGroup);
setHostedCheckoutSuccess({ isUpgrade: true });
setIsOpen(true);
} else {
// Fallback to toast if plan group not found
alert({
alertType: 'success',
title: t('payment.upgradeSuccess'),
});
}
} else {
console.error('Failed to sync license after upgrade:', activation.error);
alert({
alertType: 'error',
title: t('payment.syncError'),
});
}
} else {
// NEW SUBSCRIPTION: Poll for license key
console.log('New subscription - polling for license key');
try {
const installationId = await licenseService.getInstallationId();
console.log('Polling for license key with installation ID:', installationId);
// Use shared polling utility
const result = await pollLicenseKeyWithBackoff(installationId);
if (result.success && result.licenseKey) {
// Activate the license key
const activation = await activateLicenseKey(result.licenseKey);
if (activation.success) {
console.log(`License key activated: ${activation.licenseType}`);
// Refresh global license context
await refetchLicense();
await refetchPlans();
// Determine tier from license type
const tier = activation.licenseType === 'ENTERPRISE' ? 'enterprise' : 'server';
const planGroups = licenseService.groupPlansByTier(plans);
const planGroup = planGroups.find(pg => pg.tier === tier);
if (planGroup) {
// Reopen modal to show success with license key
setSelectedPlanGroup(planGroup);
setHostedCheckoutSuccess({
isUpgrade: false,
licenseKey: result.licenseKey
});
setIsOpen(true);
} else {
// Fallback to toast if plan group not found
alert({
alertType: 'success',
title: t('payment.licenseActivated'),
});
}
} else {
console.error('Failed to save license key:', activation.error);
alert({
alertType: 'error',
title: t('payment.licenseSaveError'),
});
}
} else if (result.timedOut) {
console.warn('License key polling timed out');
alert({
alertType: 'warning',
title: t('payment.licenseDelayed'),
});
} else {
console.error('License key polling failed:', result.error);
alert({
alertType: 'error',
title: t('payment.licensePollingError'),
});
}
} catch (error) {
console.error('Failed to poll for license key:', error);
alert({
alertType: 'error',
title: t('payment.licenseRetrievalError'),
});
}
}
} else if (paymentStatus === 'canceled') {
console.log('Payment canceled by user');
// Clear URL parameters
window.history.replaceState({}, '', window.location.pathname);
alert({
alertType: 'warning',
title: t('payment.paymentCanceled'),
});
}
};
handleCheckoutReturn();
}, [t, refetchPlans, refetchLicense, plans]);
const openCheckout = useCallback(
async (tier: 'server' | 'enterprise', options: CheckoutOptions = {}) => {
try {
setIsLoading(true);
// Check if Supabase is configured
if (!isSupabaseConfigured) {
throw new Error('Checkout is not available. Supabase is not configured.');
}
// Update currency if provided
const currency = options.currency || currentCurrency;
if (currency !== currentCurrency) {
setCurrentCurrency(currency);
// Plans will reload automatically via usePlans
}
// Fetch license info and user data for seat calculations
let licenseInfo: LicenseInfo | null = null;
let totalUsers = 0;
try {
const [licenseData, userData] = await Promise.all([
licenseService.getLicenseInfo(),
userManagementService.getUsers()
]);
licenseInfo = licenseData;
totalUsers = userData.totalUsers || 0;
} catch (err) {
console.warn('Could not fetch license/user info, proceeding with defaults:', err);
}
// Calculate minimum seats for enterprise upgrades
let calculatedMinSeats = options.minimumSeats || 1;
if (tier === 'enterprise' && !options.minimumSeats) {
const currentTier = mapLicenseToTier(licenseInfo);
if (currentTier === 'server' || currentTier === 'free') {
// Upgrading from Server (unlimited) to Enterprise (per-seat)
// Use current total user count as minimum
calculatedMinSeats = Math.max(totalUsers, 1);
console.log(`Setting minimum seats from server user count: ${calculatedMinSeats}`);
} else if (currentTier === 'enterprise') {
// Upgrading within Enterprise (e.g., monthly to yearly)
// Use current licensed seat count as minimum
calculatedMinSeats = Math.max(licenseInfo?.maxUsers || 1, 1);
console.log(`Setting minimum seats from current license: ${calculatedMinSeats}`);
}
}
// Find the plan group for the requested tier
const planGroups = licenseService.groupPlansByTier(plans);
const planGroup = planGroups.find(pg => pg.tier === tier);
if (!planGroup) {
throw new Error(`No ${tier} plan available`);
}
// Store options for callbacks
setCurrentOptions(options);
setMinimumSeats(calculatedMinSeats);
setSelectedPlanGroup(planGroup);
setIsOpen(true);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to open checkout';
console.error('Error opening checkout:', errorMessage);
options.onError?.(errorMessage);
} finally {
setIsLoading(false);
}
},
[currentCurrency, plans]
);
const closeCheckout = useCallback(() => {
setIsOpen(false);
setSelectedPlanGroup(null);
setCurrentOptions({});
setHostedCheckoutSuccess(null);
// Refetch plans and license after modal closes to update subscription display
refetchPlans();
refetchLicense();
}, [refetchPlans, refetchLicense]);
const handlePaymentSuccess = useCallback(
(sessionId: string) => {
console.log('Payment successful, session:', sessionId);
currentOptions.onSuccess?.(sessionId);
// Don't close modal - let user view license key and close manually
},
[currentOptions]
);
const handlePaymentError = useCallback(
(error: string) => {
console.error('Payment error:', error);
currentOptions.onError?.(error);
},
[currentOptions]
);
const handleLicenseActivated = useCallback((licenseInfo: {
licenseType: string;
enabled: boolean;
maxUsers: number;
hasKey: boolean;
}) => {
console.log('License activated:', licenseInfo);
// Could expose this via context if needed
}, []);
const contextValue: CheckoutContextValue = {
openCheckout,
closeCheckout,
isOpen,
isLoading,
};
return (
<CheckoutContext.Provider value={contextValue}>
{children}
{/* Global Checkout Modal */}
{selectedPlanGroup && (
<StripeCheckout
opened={isOpen}
onClose={closeCheckout}
planGroup={selectedPlanGroup}
minimumSeats={minimumSeats}
onSuccess={handlePaymentSuccess}
onError={handlePaymentError}
onLicenseActivated={handleLicenseActivated}
hostedCheckoutSuccess={hostedCheckoutSuccess}
/>
)}
</CheckoutContext.Provider>
);
};
export const useCheckout = (): CheckoutContextValue => {
const context = useContext(CheckoutContext);
if (!context) {
throw new Error('useCheckout must be used within CheckoutProvider');
}
return context;
};
@@ -0,0 +1,74 @@
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
import licenseService, { LicenseInfo } from '@app/services/licenseService';
import { useAppConfig } from '@app/contexts/AppConfigContext';
interface LicenseContextValue {
licenseInfo: LicenseInfo | null;
loading: boolean;
error: string | null;
refetchLicense: () => Promise<void>;
}
const LicenseContext = createContext<LicenseContextValue | undefined>(undefined);
interface LicenseProviderProps {
children: ReactNode;
}
export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) => {
const { config } = useAppConfig();
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const refetchLicense = useCallback(async () => {
// Only fetch license info if user is an admin
if (!config?.isAdmin) {
console.debug('[LicenseContext] User is not an admin, skipping license fetch');
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const info = await licenseService.getLicenseInfo();
setLicenseInfo(info);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch license info';
console.error('Error fetching license info:', errorMessage);
setError(errorMessage);
setLicenseInfo(null);
} finally {
setLoading(false);
}
}, [config?.isAdmin]);
// Fetch license info when config changes (only if user is admin)
useEffect(() => {
if (config) {
refetchLicense();
}
}, [config, refetchLicense]);
const contextValue: LicenseContextValue = {
licenseInfo,
loading,
error,
refetchLicense,
};
return (
<LicenseContext.Provider value={contextValue}>
{children}
</LicenseContext.Provider>
);
};
export const useLicense = (): LicenseContextValue => {
const context = useContext(LicenseContext);
if (!context) {
throw new Error('useLicense must be used within LicenseProvider');
}
return context;
};
@@ -0,0 +1,44 @@
import { useState, useEffect } from 'react';
import licenseService, {
PlanTier,
PlansResponse,
} from '@app/services/licenseService';
export interface UsePlansReturn {
plans: PlanTier[];
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
export const usePlans = (currency: string = 'gbp'): UsePlansReturn => {
const [plans, setPlans] = useState<PlanTier[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchPlans = async () => {
try {
setLoading(true);
setError(null);
const data: PlansResponse = await licenseService.getPlans(currency);
setPlans(data.plans);
} catch (err) {
console.error('Error fetching plans:', err);
setError(err instanceof Error ? err.message : 'Failed to fetch plans');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchPlans();
}, [currency]);
return {
plans,
loading,
error,
refetch: fetchPlans,
};
};
@@ -0,0 +1,475 @@
import apiClient from '@app/services/apiClient';
import { supabase, isSupabaseConfigured } from '@app/services/supabaseClient';
import { getCheckoutMode } from '@app/utils/protocolDetection';
import { PLAN_FEATURES, PLAN_HIGHLIGHTS } from '@app/constants/planConstants';
export interface PlanFeature {
name: string;
included: boolean;
}
export interface PlanTier {
id: string;
name: string;
price: number;
currency: string;
period: string;
popular?: boolean;
features: PlanFeature[];
highlights: readonly string[];
isContactOnly?: boolean;
seatPrice?: number; // Per-seat price for enterprise plans
requiresSeats?: boolean; // Flag indicating seat selection is needed
lookupKey: string; // Stripe lookup key for this plan
}
export interface PlanTierGroup {
tier: 'free' | 'server' | 'enterprise';
name: string;
monthly: PlanTier | null;
yearly: PlanTier | null;
features: PlanFeature[];
highlights: readonly string[];
popular?: boolean;
}
export interface PlansResponse {
plans: PlanTier[];
}
export interface CheckoutSessionRequest {
lookup_key: string; // Stripe lookup key (e.g., 'selfhosted:server:monthly')
installation_id?: string; // Installation ID from backend (MAC-based fingerprint)
current_license_key?: string; // Current license key for upgrades
requires_seats?: boolean; // Whether to add adjustable seat pricing
seat_count?: number; // Initial number of seats for enterprise plans (user can adjust in Stripe UI)
successUrl?: string;
cancelUrl?: string;
}
export interface CheckoutSessionResponse {
clientSecret: string;
sessionId: string;
url?: string; // URL for hosted checkout (when not using HTTPS)
}
export interface BillingPortalResponse {
url: string;
}
export interface InstallationIdResponse {
installationId: string;
}
export interface LicenseKeyResponse {
status: 'ready' | 'pending';
license_key?: string;
email?: string;
plan?: string;
}
export interface LicenseInfo {
licenseType: 'NORMAL' | 'PRO' | 'ENTERPRISE';
enabled: boolean;
maxUsers: number;
hasKey: boolean;
licenseKey?: string; // The actual license key (for upgrades)
}
export interface LicenseSaveResponse {
success: boolean;
licenseType?: string;
message?: string;
error?: string;
}
// Currency symbol mapping
const getCurrencySymbol = (currency: string): string => {
const currencySymbols: { [key: string]: string } = {
'gbp': '£',
'usd': '$',
'eur': '€',
'cny': '¥',
'inr': '₹',
'brl': 'R$',
'idr': 'Rp'
};
return currencySymbols[currency.toLowerCase()] || currency.toUpperCase();
};
// Self-hosted plan lookup keys
const SELF_HOSTED_LOOKUP_KEYS = [
'selfhosted:server:monthly',
'selfhosted:server:yearly',
'selfhosted:enterpriseseat:monthly',
'selfhosted:enterpriseseat:yearly',
];
const licenseService = {
/**
* Get available plans with pricing for the specified currency
*/
async getPlans(currency: string = 'gbp'): Promise<PlansResponse> {
try {
// Check if Supabase is configured
if (!isSupabaseConfigured || !supabase) {
throw new Error('Supabase is not configured. Please use static plans instead.');
}
// Fetch all self-hosted prices from Stripe
const { data, error } = await supabase.functions.invoke<{
prices: Record<string, {
unit_amount: number;
currency: string;
lookup_key: string;
}>;
missing: string[];
}>('stripe-price-lookup', {
body: {
lookup_keys: SELF_HOSTED_LOOKUP_KEYS,
currency
},
});
if (error) {
throw new Error(`Failed to fetch plans: ${error.message}`);
}
if (!data || !data.prices) {
throw new Error('No pricing data returned');
}
// Log missing prices for debugging
if (data.missing && data.missing.length > 0) {
console.warn('Missing Stripe prices for lookup keys:', data.missing, 'in currency:', currency);
}
// Build price map for easy access
const priceMap = new Map<string, { unit_amount: number; currency: string }>();
for (const [lookupKey, priceData] of Object.entries(data.prices)) {
priceMap.set(lookupKey, {
unit_amount: priceData.unit_amount,
currency: priceData.currency
});
}
const currencySymbol = getCurrencySymbol(currency);
// Helper to get price info
const getPriceInfo = (lookupKey: string, fallback: number = 0) => {
const priceData = priceMap.get(lookupKey);
return priceData ? priceData.unit_amount / 100 : fallback;
};
// Build plan tiers
const plans: PlanTier[] = [
{
id: 'selfhosted:server:monthly',
lookupKey: 'selfhosted:server:monthly',
name: 'Server - Monthly',
price: getPriceInfo('selfhosted:server:monthly'),
currency: currencySymbol,
period: '/month',
popular: false,
features: PLAN_FEATURES.SERVER,
highlights: PLAN_HIGHLIGHTS.SERVER_MONTHLY
},
{
id: 'selfhosted:server:yearly',
lookupKey: 'selfhosted:server:yearly',
name: 'Server - Yearly',
price: getPriceInfo('selfhosted:server:yearly'),
currency: currencySymbol,
period: '/year',
popular: true,
features: PLAN_FEATURES.SERVER,
highlights: PLAN_HIGHLIGHTS.SERVER_YEARLY
},
{
id: 'selfhosted:enterprise:monthly',
lookupKey: 'selfhosted:server:monthly',
name: 'Enterprise - Monthly',
price: getPriceInfo('selfhosted:server:monthly'),
seatPrice: getPriceInfo('selfhosted:enterpriseseat:monthly'),
currency: currencySymbol,
period: '/month',
popular: false,
requiresSeats: true,
features: PLAN_FEATURES.ENTERPRISE,
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_MONTHLY
},
{
id: 'selfhosted:enterprise:yearly',
lookupKey: 'selfhosted:server:yearly',
name: 'Enterprise - Yearly',
price: getPriceInfo('selfhosted:server:yearly'),
seatPrice: getPriceInfo('selfhosted:enterpriseseat:yearly'),
currency: currencySymbol,
period: '/year',
popular: false,
requiresSeats: true,
features: PLAN_FEATURES.ENTERPRISE,
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_YEARLY
},
];
// Filter out plans with missing prices (price === 0 means Stripe price not found)
const validPlans = plans.filter(plan => plan.price > 0);
if (validPlans.length < plans.length) {
const missingPlans = plans.filter(plan => plan.price === 0).map(p => p.id);
console.warn('Filtered out plans with missing prices:', missingPlans);
}
// Add Free plan (static definition)
const freePlan: PlanTier = {
id: 'free',
lookupKey: 'free',
name: 'Free',
price: 0,
currency: currencySymbol,
period: '',
popular: false,
features: PLAN_FEATURES.FREE,
highlights: PLAN_HIGHLIGHTS.FREE
};
const allPlans = [freePlan, ...validPlans];
return {
plans: allPlans
};
} catch (error) {
console.error('Error fetching plans:', error);
throw error;
}
},
/**
* Group plans by tier for display (Free, Server, Enterprise)
*/
groupPlansByTier(plans: PlanTier[]): PlanTierGroup[] {
const groups: PlanTierGroup[] = [];
// Free tier
const freePlan = plans.find(p => p.id === 'free');
if (freePlan) {
groups.push({
tier: 'free',
name: 'Free',
monthly: freePlan,
yearly: null,
features: freePlan.features,
highlights: freePlan.highlights,
popular: false,
});
}
// Server tier
const serverMonthly = plans.find(p => p.lookupKey === 'selfhosted:server:monthly');
const serverYearly = plans.find(p => p.lookupKey === 'selfhosted:server:yearly');
if (serverMonthly || serverYearly) {
groups.push({
tier: 'server',
name: 'Server',
monthly: serverMonthly || null,
yearly: serverYearly || null,
features: (serverMonthly || serverYearly)!.features,
highlights: (serverMonthly || serverYearly)!.highlights,
popular: serverYearly?.popular || serverMonthly?.popular || false,
});
}
// Enterprise tier (uses server pricing + seats)
const enterpriseMonthly = plans.find(p => p.id === 'selfhosted:enterprise:monthly');
const enterpriseYearly = plans.find(p => p.id === 'selfhosted:enterprise:yearly');
if (enterpriseMonthly || enterpriseYearly) {
groups.push({
tier: 'enterprise',
name: 'Enterprise',
monthly: enterpriseMonthly || null,
yearly: enterpriseYearly || null,
features: (enterpriseMonthly || enterpriseYearly)!.features,
highlights: (enterpriseMonthly || enterpriseYearly)!.highlights,
popular: false,
});
}
return groups;
},
/**
* Create a Stripe checkout session for upgrading
*/
async createCheckoutSession(request: CheckoutSessionRequest): Promise<CheckoutSessionResponse> {
// Check if Supabase is configured
if (!isSupabaseConfigured || !supabase) {
throw new Error('Supabase is not configured. Checkout is not available.');
}
// Detect if HTTPS is available to determine checkout mode
const checkoutMode = getCheckoutMode();
const baseUrl = window.location.origin;
const settingsUrl = `${baseUrl}/settings/adminPlan`;
const { data, error } = await supabase.functions.invoke('create-checkout', {
body: {
self_hosted: true,
lookup_key: request.lookup_key,
installation_id: request.installation_id,
current_license_key: request.current_license_key,
requires_seats: request.requires_seats,
seat_count: request.seat_count || 1,
callback_base_url: baseUrl,
ui_mode: checkoutMode,
// For hosted checkout, provide success/cancel URLs
success_url: checkoutMode === 'hosted'
? `${settingsUrl}?session_id={CHECKOUT_SESSION_ID}&payment_status=success`
: undefined,
cancel_url: checkoutMode === 'hosted'
? `${settingsUrl}?payment_status=canceled`
: undefined,
},
});
if (error) {
throw new Error(`Failed to create checkout session: ${error.message}`);
}
return data as CheckoutSessionResponse;
},
/**
* Create a Stripe billing portal session for managing subscription
* Uses license key for self-hosted authentication
*/
async createBillingPortalSession(returnUrl: string, licenseKey: string): Promise<BillingPortalResponse> {
// Check if Supabase is configured
if (!isSupabaseConfigured || !supabase) {
throw new Error('Supabase is not configured. Billing portal is not available.');
}
const { data, error} = await supabase.functions.invoke('manage-billing', {
body: {
return_url: returnUrl,
license_key: licenseKey,
self_hosted: true // Explicitly indicate self-hosted mode
},
});
if (error) {
throw new Error(`Failed to create billing portal session: ${error.message}`);
}
return data as BillingPortalResponse;
},
/**
* Get the installation ID from the backend (MAC-based fingerprint)
*/
async getInstallationId(): Promise<string> {
try {
const response = await apiClient.get('/api/v1/admin/installation-id');
const data: InstallationIdResponse = await response.data;
return data.installationId;
} catch (error) {
console.error('Error fetching installation ID:', error);
throw error;
}
},
/**
* Check if license key is ready for the given installation ID
*/
async checkLicenseKey(installationId: string): Promise<LicenseKeyResponse> {
// Check if Supabase is configured
if (!isSupabaseConfigured || !supabase) {
throw new Error('Supabase is not configured. License key lookup is not available.');
}
const { data, error } = await supabase.functions.invoke('get-license-key', {
body: {
installation_id: installationId,
},
});
if (error) {
throw new Error(`Failed to check license key: ${error.message}`);
}
return data as LicenseKeyResponse;
},
/**
* Save license key to backend
*/
async saveLicenseKey(licenseKey: string): Promise<LicenseSaveResponse> {
try {
const response = await apiClient.post('/api/v1/admin/license-key', {
licenseKey: licenseKey,
});
return response.data;
} catch (error) {
console.error('Error saving license key:', error);
throw error;
}
},
/**
* Get current license information from backend
*/
async getLicenseInfo(): Promise<LicenseInfo> {
try {
const response = await apiClient.get('/api/v1/admin/license-info');
return response.data;
} catch (error) {
console.error('Error fetching license info:', error);
throw error;
}
},
/**
* Resync the current license with Keygen
* Re-validates the existing license key and updates local settings
*/
async resyncLicense(): Promise<LicenseSaveResponse> {
try {
const response = await apiClient.post('/api/v1/admin/license/resync');
return response.data;
} catch (error) {
console.error('Error resyncing license:', error);
throw error;
}
},
};
/**
* Map license type to plan tier
* @param licenseInfo - Current license information
* @returns Plan tier: 'free' | 'server' | 'enterprise'
*/
export const mapLicenseToTier = (licenseInfo: LicenseInfo | null): 'free' | 'server' | 'enterprise' | null => {
if (!licenseInfo) return null;
// No license or NORMAL type = Free tier
if (licenseInfo.licenseType === 'NORMAL' || !licenseInfo.enabled) {
return 'free';
}
// PRO type (no seats) = Server tier
if (licenseInfo.licenseType === 'PRO') {
return 'server';
}
// ENTERPRISE type (with seats) = Enterprise tier
if (licenseInfo.licenseType === 'ENTERPRISE' && licenseInfo.maxUsers > 0) {
return 'enterprise';
}
// Default fallback
return 'free';
};
export default licenseService;
@@ -0,0 +1,265 @@
/**
* Shared utilities for license checkout completion
* Used by both embedded and hosted checkout flows
*/
import licenseService, { LicenseInfo } from '@app/services/licenseService';
/**
* Result of license key polling
*/
export interface LicenseKeyPollResult {
success: boolean;
licenseKey?: string;
error?: string;
timedOut?: boolean;
}
/**
* Configuration for license key polling
*/
export interface PollConfig {
/** Check if component is still mounted (prevents state updates after unmount) */
isMounted?: () => boolean;
/** Callback for status changes during polling */
onStatusChange?: (status: 'polling' | 'ready' | 'timeout') => void;
/** Custom backoff intervals in milliseconds (default: [1000, 2000, 4000, 8000, 16000]) */
backoffMs?: number[];
}
/**
* Poll for license key with exponential backoff
* Consolidates polling logic used by both embedded and hosted checkout
*/
export async function pollLicenseKeyWithBackoff(
installationId: string,
config: PollConfig = {}
): Promise<LicenseKeyPollResult> {
const {
isMounted = () => true,
onStatusChange,
backoffMs = [1000, 2000, 4000, 8000, 16000],
} = config;
let attemptIndex = 0;
onStatusChange?.('polling');
console.log(`Starting license key polling for installation: ${installationId}`);
const poll = async (): Promise<LicenseKeyPollResult> => {
// Check if component is still mounted
if (!isMounted()) {
console.log('Polling cancelled: component unmounted');
return { success: false, error: 'Component unmounted' };
}
const attemptNumber = attemptIndex + 1;
console.log(`Polling attempt ${attemptNumber}/${backoffMs.length}`);
try {
const response = await licenseService.checkLicenseKey(installationId);
// Check mounted after async operation
if (!isMounted()) {
return { success: false, error: 'Component unmounted' };
}
if (response.status === 'ready' && response.license_key) {
console.log('✅ License key ready!');
onStatusChange?.('ready');
return {
success: true,
licenseKey: response.license_key,
};
}
// License not ready yet, continue polling
attemptIndex++;
if (attemptIndex >= backoffMs.length) {
console.warn('⏱️ License polling timeout after all attempts');
onStatusChange?.('timeout');
return {
success: false,
timedOut: true,
error: 'Polling timeout - license key not ready',
};
}
// Wait before next attempt
const nextDelay = backoffMs[attemptIndex];
console.log(`Retrying in ${nextDelay}ms...`);
await new Promise(resolve => setTimeout(resolve, nextDelay));
return poll();
} catch (error) {
console.error(`Polling attempt ${attemptNumber} failed:`, error);
if (!isMounted()) {
return { success: false, error: 'Component unmounted' };
}
attemptIndex++;
if (attemptIndex >= backoffMs.length) {
console.error('Polling failed after all attempts');
onStatusChange?.('timeout');
return {
success: false,
error: error instanceof Error ? error.message : 'Polling failed',
};
}
// Retry with exponential backoff even on error
const nextDelay = backoffMs[attemptIndex];
console.log(`Retrying after error in ${nextDelay}ms...`);
await new Promise(resolve => setTimeout(resolve, nextDelay));
return poll();
}
};
return poll();
}
/**
* Result of license key activation
*/
export interface LicenseActivationResult {
success: boolean;
licenseType?: string;
licenseInfo?: LicenseInfo;
error?: string;
}
/**
* Activate a license key by saving it to the backend and fetching updated info
* Used for NEW subscriptions where we have a new license key to save
*/
export async function activateLicenseKey(
licenseKey: string,
options: {
/** Check if component is still mounted */
isMounted?: () => boolean;
/** Callback when license is activated with updated info */
onActivated?: (licenseInfo: LicenseInfo) => void;
} = {}
): Promise<LicenseActivationResult> {
const { isMounted = () => true, onActivated } = options;
try {
console.log('Activating license key...');
const saveResponse = await licenseService.saveLicenseKey(licenseKey);
if (!isMounted()) {
return { success: false, error: 'Component unmounted' };
}
if (saveResponse.success) {
console.log(`License key activated: ${saveResponse.licenseType}`);
// Fetch updated license info
try {
const licenseInfo = await licenseService.getLicenseInfo();
if (!isMounted()) {
return { success: false, error: 'Component unmounted' };
}
onActivated?.(licenseInfo);
return {
success: true,
licenseType: saveResponse.licenseType,
licenseInfo,
};
} catch (infoError) {
console.error('Error fetching license info after activation:', infoError);
// Still return success since save succeeded
return {
success: true,
licenseType: saveResponse.licenseType,
error: 'Failed to fetch updated license info',
};
}
} else {
console.error('Failed to save license key:', saveResponse.error);
return {
success: false,
error: saveResponse.error || 'Failed to save license key',
};
}
} catch (error) {
console.error('Error activating license key:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Activation failed',
};
}
}
/**
* Resync existing license with Keygen
* Used for UPGRADES where we already have a license key configured
* Calls the dedicated resync endpoint instead of re-saving the same key
*/
export async function resyncExistingLicense(
options: {
/** Check if component is still mounted */
isMounted?: () => boolean;
/** Callback when license is resynced with updated info */
onActivated?: (licenseInfo: LicenseInfo) => void;
} = {}
): Promise<LicenseActivationResult> {
const { isMounted = () => true, onActivated } = options;
try {
console.log('Resyncing existing license with Keygen...');
const resyncResponse = await licenseService.resyncLicense();
if (!isMounted()) {
return { success: false, error: 'Component unmounted' };
}
if (resyncResponse.success) {
console.log(`License resynced: ${resyncResponse.licenseType}`);
// Fetch updated license info
try {
const licenseInfo = await licenseService.getLicenseInfo();
if (!isMounted()) {
return { success: false, error: 'Component unmounted' };
}
onActivated?.(licenseInfo);
return {
success: true,
licenseType: resyncResponse.licenseType,
licenseInfo,
};
} catch (infoError) {
console.error('Error fetching license info after resync:', infoError);
// Still return success since resync succeeded
return {
success: true,
licenseType: resyncResponse.licenseType,
error: 'Failed to fetch updated license info',
};
}
} else {
console.error('Failed to resync license:', resyncResponse.error);
return {
success: false,
error: resyncResponse.error || 'Failed to resync license',
};
}
} catch (error) {
console.error('Error resyncing license:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Resync failed',
};
}
}
@@ -0,0 +1,43 @@
/**
* Protocol detection utility for determining secure context
* Used to decide between Embedded Checkout (HTTPS) and Hosted Checkout (HTTP)
*/
/**
* Check if the current context is secure (HTTPS or localhost)
* @returns true if HTTPS or localhost, false if HTTP
*/
export function isSecureContext(): boolean {
// Allow localhost for development (works with both HTTP and HTTPS)
if (typeof window !== 'undefined') {
// const hostname = window.location.hostname;
const protocol = window.location.protocol;
// Localhost is considered secure for development
// if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]') {
// return true;
// }
// Check if HTTPS
return protocol === 'https:';
}
// Default to false if window is not available (SSR context)
return false;
}
/**
* Get the appropriate Stripe checkout UI mode based on current context
* @returns 'embedded' for HTTPS/localhost, 'hosted' for HTTP
*/
export function getCheckoutMode(): 'embedded' | 'hosted' {
return isSecureContext() ? 'embedded' : 'hosted';
}
/**
* Check if Embedded Checkout can be used in current context
* @returns true if secure context (HTTPS/localhost)
*/
export function canUseEmbeddedCheckout(): boolean {
return isSecureContext();
}

Some files were not shown because too many files have changed in this diff Show More