Merge branch 'feature/v2/bookmarks' of https://github.com/Stirling-Tools/Stirling-PDF into feature/v2/bookmarks
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -115,46 +115,46 @@ Stirling-PDF currently supports 40 languages!
|
||||
|
||||
| Language | Progress |
|
||||
| -------------------------------------------- | -------------------------------------- |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| Arabic (العربية) (ar_AR) |  |
|
||||
| Azerbaijani (Azərbaycan Dili) (az_AZ) |  |
|
||||
| Basque (Euskara) (eu_ES) |  |
|
||||
| Bulgarian (Български) (bg_BG) |  |
|
||||
| Catalan (Català) (ca_CA) |  |
|
||||
| Croatian (Hrvatski) (hr_HR) |  |
|
||||
| Czech (Česky) (cs_CZ) |  |
|
||||
| Danish (Dansk) (da_DK) |  |
|
||||
| Dutch (Nederlands) (nl_NL) |  |
|
||||
| English (English) (en_GB) |  |
|
||||
| English (US) (en_US) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| French (Français) (fr_FR) |  |
|
||||
| German (Deutsch) (de_DE) |  |
|
||||
| Greek (Ελληνικά) (el_GR) |  |
|
||||
| Hindi (हिंदी) (hi_IN) |  |
|
||||
| Hungarian (Magyar) (hu_HU) |  |
|
||||
| Indonesian (Bahasa Indonesia) (id_ID) |  |
|
||||
| Irish (Gaeilge) (ga_IE) |  |
|
||||
| Italian (Italiano) (it_IT) |  |
|
||||
| Japanese (日本語) (ja_JP) |  |
|
||||
| Korean (한국어) (ko_KR) |  |
|
||||
| Norwegian (Norsk) (no_NB) |  |
|
||||
| Persian (فارسی) (fa_IR) |  |
|
||||
| Polish (Polski) (pl_PL) |  |
|
||||
| Portuguese (Português) (pt_PT) |  |
|
||||
| Portuguese Brazilian (Português) (pt_BR) |  |
|
||||
| Romanian (Română) (ro_RO) |  |
|
||||
| Russian (Русский) (ru_RU) |  |
|
||||
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) |  |
|
||||
| Simplified Chinese (简体中文) (zh_CN) |  |
|
||||
| Slovakian (Slovensky) (sk_SK) |  |
|
||||
| Slovenian (Slovenščina) (sl_SI) |  |
|
||||
| Spanish (Español) (es_ES) |  |
|
||||
| Swedish (Svenska) (sv_SE) |  |
|
||||
| Thai (ไทย) (th_TH) |  |
|
||||
| Tibetan (བོད་ཡིག་) (bo_CN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Traditional Chinese (繁體中文) (zh_TW) |  |
|
||||
| Turkish (Türkçe) (tr_TR) |  |
|
||||
| Ukrainian (Українська) (uk_UA) |  |
|
||||
| Vietnamese (Tiếng Việt) (vi_VN) |  |
|
||||
| Malayalam (മലയാളം) (ml_IN) |  |
|
||||
|
||||
## Stirling PDF Enterprise
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -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();
|
||||
}
|
||||
|
||||
+245
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -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();
|
||||
|
||||
Generated
+160
-3
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+403
-4
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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": "**" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod connection_state;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.'
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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)
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
+240
@@ -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;
|
||||
+109
@@ -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;
|
||||
+93
@@ -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;
|
||||
+338
@@ -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
Reference in New Issue
Block a user