diff --git a/.github/workflows/tauri-build.yml b/.github/workflows/tauri-build.yml index 4c833bc16..09ef22a16 100644 --- a/.github/workflows/tauri-build.yml +++ b/.github/workflows/tauri-build.yml @@ -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: diff --git a/README.md b/README.md index 211301b99..1d7188998 100644 --- a/README.md +++ b/README.md @@ -115,46 +115,46 @@ Stirling-PDF currently supports 40 languages! | Language | Progress | | -------------------------------------------- | -------------------------------------- | -| Arabic (العربية) (ar_AR) | ![64%](https://geps.dev/progress/64) | -| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![24%](https://geps.dev/progress/24) | -| Basque (Euskara) (eu_ES) | ![14%](https://geps.dev/progress/14) | -| Bulgarian (Български) (bg_BG) | ![26%](https://geps.dev/progress/26) | -| Catalan (Català) (ca_CA) | ![26%](https://geps.dev/progress/26) | -| Croatian (Hrvatski) (hr_HR) | ![24%](https://geps.dev/progress/24) | -| Czech (Česky) (cs_CZ) | ![26%](https://geps.dev/progress/26) | -| Danish (Dansk) (da_DK) | ![23%](https://geps.dev/progress/23) | -| Dutch (Nederlands) (nl_NL) | ![23%](https://geps.dev/progress/23) | +| Arabic (العربية) (ar_AR) | ![94%](https://geps.dev/progress/94) | +| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![93%](https://geps.dev/progress/93) | +| Basque (Euskara) (eu_ES) | ![93%](https://geps.dev/progress/93) | +| Bulgarian (Български) (bg_BG) | ![94%](https://geps.dev/progress/94) | +| Catalan (Català) (ca_CA) | ![93%](https://geps.dev/progress/93) | +| Croatian (Hrvatski) (hr_HR) | ![93%](https://geps.dev/progress/93) | +| Czech (Česky) (cs_CZ) | ![91%](https://geps.dev/progress/91) | +| Danish (Dansk) (da_DK) | ![92%](https://geps.dev/progress/92) | +| Dutch (Nederlands) (nl_NL) | ![93%](https://geps.dev/progress/93) | | English (English) (en_GB) | ![100%](https://geps.dev/progress/100) | | English (US) (en_US) | ![100%](https://geps.dev/progress/100) | -| French (Français) (fr_FR) | ![63%](https://geps.dev/progress/63) | -| German (Deutsch) (de_DE) | ![64%](https://geps.dev/progress/64) | -| Greek (Ελληνικά) (el_GR) | ![26%](https://geps.dev/progress/26) | -| Hindi (हिंदी) (hi_IN) | ![26%](https://geps.dev/progress/26) | -| Hungarian (Magyar) (hu_HU) | ![29%](https://geps.dev/progress/29) | -| Indonesian (Bahasa Indonesia) (id_ID) | ![24%](https://geps.dev/progress/24) | -| Irish (Gaeilge) (ga_IE) | ![26%](https://geps.dev/progress/26) | -| Italian (Italiano) (it_IT) | ![64%](https://geps.dev/progress/64) | -| Japanese (日本語) (ja_JP) | ![47%](https://geps.dev/progress/47) | -| Korean (한국어) (ko_KR) | ![26%](https://geps.dev/progress/26) | -| Norwegian (Norsk) (no_NB) | ![24%](https://geps.dev/progress/24) | -| Persian (فارسی) (fa_IR) | ![26%](https://geps.dev/progress/26) | -| Polish (Polski) (pl_PL) | ![27%](https://geps.dev/progress/27) | -| Portuguese (Português) (pt_PT) | ![26%](https://geps.dev/progress/26) | -| Portuguese Brazilian (Português) (pt_BR) | ![64%](https://geps.dev/progress/64) | -| Romanian (Română) (ro_RO) | ![22%](https://geps.dev/progress/22) | -| Russian (Русский) (ru_RU) | ![63%](https://geps.dev/progress/63) | -| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![28%](https://geps.dev/progress/28) | -| Simplified Chinese (简体中文) (zh_CN) | ![65%](https://geps.dev/progress/65) | -| Slovakian (Slovensky) (sk_SK) | ![19%](https://geps.dev/progress/19) | -| Slovenian (Slovenščina) (sl_SI) | ![27%](https://geps.dev/progress/27) | -| Spanish (Español) (es_ES) | ![64%](https://geps.dev/progress/64) | -| Swedish (Svenska) (sv_SE) | ![25%](https://geps.dev/progress/25) | -| Thai (ไทย) (th_TH) | ![23%](https://geps.dev/progress/23) | +| French (Français) (fr_FR) | ![93%](https://geps.dev/progress/93) | +| German (Deutsch) (de_DE) | ![93%](https://geps.dev/progress/93) | +| Greek (Ελληνικά) (el_GR) | ![93%](https://geps.dev/progress/93) | +| Hindi (हिंदी) (hi_IN) | ![94%](https://geps.dev/progress/94) | +| Hungarian (Magyar) (hu_HU) | ![94%](https://geps.dev/progress/94) | +| Indonesian (Bahasa Indonesia) (id_ID) | ![93%](https://geps.dev/progress/93) | +| Irish (Gaeilge) (ga_IE) | ![94%](https://geps.dev/progress/94) | +| Italian (Italiano) (it_IT) | ![93%](https://geps.dev/progress/93) | +| Japanese (日本語) (ja_JP) | ![94%](https://geps.dev/progress/94) | +| Korean (한국어) (ko_KR) | ![94%](https://geps.dev/progress/94) | +| Norwegian (Norsk) (no_NB) | ![93%](https://geps.dev/progress/93) | +| Persian (فارسی) (fa_IR) | ![94%](https://geps.dev/progress/94) | +| Polish (Polski) (pl_PL) | ![93%](https://geps.dev/progress/93) | +| Portuguese (Português) (pt_PT) | ![93%](https://geps.dev/progress/93) | +| Portuguese Brazilian (Português) (pt_BR) | ![93%](https://geps.dev/progress/93) | +| Romanian (Română) (ro_RO) | ![93%](https://geps.dev/progress/93) | +| Russian (Русский) (ru_RU) | ![94%](https://geps.dev/progress/94) | +| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![93%](https://geps.dev/progress/93) | +| Simplified Chinese (简体中文) (zh_CN) | ![94%](https://geps.dev/progress/94) | +| Slovakian (Slovensky) (sk_SK) | ![93%](https://geps.dev/progress/93) | +| Slovenian (Slovenščina) (sl_SI) | ![94%](https://geps.dev/progress/94) | +| Spanish (Español) (es_ES) | ![94%](https://geps.dev/progress/94) | +| Swedish (Svenska) (sv_SE) | ![93%](https://geps.dev/progress/93) | +| Thai (ไทย) (th_TH) | ![93%](https://geps.dev/progress/93) | | Tibetan (བོད་ཡིག་) (bo_CN) | ![65%](https://geps.dev/progress/65) | -| Traditional Chinese (繁體中文) (zh_TW) | ![29%](https://geps.dev/progress/29) | -| Turkish (Türkçe) (tr_TR) | ![28%](https://geps.dev/progress/28) | -| Ukrainian (Українська) (uk_UA) | ![28%](https://geps.dev/progress/28) | -| Vietnamese (Tiếng Việt) (vi_VN) | ![21%](https://geps.dev/progress/21) | +| Traditional Chinese (繁體中文) (zh_TW) | ![94%](https://geps.dev/progress/94) | +| Turkish (Türkçe) (tr_TR) | ![94%](https://geps.dev/progress/94) | +| Ukrainian (Українська) (uk_UA) | ![94%](https://geps.dev/progress/94) | +| Vietnamese (Tiếng Việt) (vi_VN) | ![93%](https://geps.dev/progress/93) | | Malayalam (മലയാളം) (ml_IN) | ![73%](https://geps.dev/progress/73) | ## Stirling PDF Enterprise diff --git a/WINDOWS_SIGNING.md b/WINDOWS_SIGNING.md new file mode 100644 index 000000000..45528e13a --- /dev/null +++ b/WINDOWS_SIGNING.md @@ -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) diff --git a/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java b/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java index 587bc992e..d3a4ce776 100644 --- a/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java +++ b/app/core/src/main/java/stirling/software/SPDF/SPDFApplication.java @@ -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; diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WAUTrackingFilter.java b/app/core/src/main/java/stirling/software/SPDF/config/WAUTrackingFilter.java new file mode 100644 index 000000000..08362fa0b --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/config/WAUTrackingFilter.java @@ -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); + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java index 0823c29e9..fc578fdbc 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/WebMvcConfig.java @@ -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) } } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index ffbec5a7d..a4a169cd9 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -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) { diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java b/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java index 8f17e0baf..53e60a6b5 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java @@ -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 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 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(); diff --git a/app/core/src/main/java/stirling/software/SPDF/service/WeeklyActiveUsersService.java b/app/core/src/main/java/stirling/software/SPDF/service/WeeklyActiveUsersService.java new file mode 100644 index 000000000..ddf3a7b26 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/service/WeeklyActiveUsersService.java @@ -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 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); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java index 212922d55..050a565c2 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/configuration/ee/LicenseKeyChecker.java @@ -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(); } diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java new file mode 100644 index 000000000..7bd5836c8 --- /dev/null +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java @@ -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> 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> saveLicenseKey( + @RequestBody Map 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 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> 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 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> getLicenseInfo() { + try { + Map 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")); + } + } +} diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java index 27c924ae4..a4bdd9da5 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminSettingsController.java @@ -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 entry : sectionData.entrySet()) { String propertyKey = entry.getKey(); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 498218e70..84af9b0c8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -39,10 +39,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", @@ -3121,6 +3125,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", @@ -3888,6 +4024,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", @@ -4194,7 +4339,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" @@ -4206,6 +4350,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", @@ -4240,6 +4390,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", @@ -14007,7 +14166,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": { @@ -14878,7 +15036,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" diff --git a/frontend/package.json b/frontend/package.json index 9d021a728..e4eae5b1c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,19 +26,23 @@ "@embedpdf/plugin-viewport": "^1.4.1", "@embedpdf/plugin-zoom": "^1.4.1", "@emotion/react": "^11.14.0", - "@tauri-apps/api": "^2.5.0", - "@tauri-apps/plugin-fs": "^2.4.0", "@emotion/styled": "^11.14.1", "@iconify/react": "^6.0.2", "@mantine/core": "^8.3.1", "@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", "@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", @@ -111,11 +115,11 @@ ] }, "devDependencies": { - "@tauri-apps/cli": "^2.5.0", "@eslint/js": "^9.36.0", "@iconify-json/material-symbols": "^1.2.37", "@iconify/utils": "^3.0.2", "@playwright/test": "^1.55.0", + "@tauri-apps/cli": "^2.5.0", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index c6747c0de..8a04d3d19 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -359,7 +359,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", @@ -380,6 +388,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", @@ -2092,13 +2131,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", @@ -2117,12 +2197,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", @@ -2133,11 +2219,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", @@ -2283,15 +2375,78 @@ "tags": "differentiate,contrast,changes,analysis", "title": "Compare", "header": "Compare PDFs", - "highlightColor": { - "1": "Highlight Colour 1:", - "2": "Highlight Colour 2:" + "clearSelected": "Clear selected", + "clear": { + "confirmTitle": "Clear selected PDFs?", + "confirmBody": "This will close the current comparison and take you back to Active Files.", + "confirm": "Clear and return" + }, + "review": { + "title": "Comparison Result", + "actionsHint": "Review the comparison, switch document roles, or export the summary.", + "switchOrder": "Switch order", + "exportSummary": "Export summary" + }, + "base": { + "label": "Original document", + "placeholder": "Select the original PDF" + }, + "comparison": { + "label": "Edited document", + "placeholder": "Select the edited PDF" + }, + "addFilesHint": "Add PDFs in the Files step to enable selection.", + "noFiles": "No PDFs available yet", + "pages": "Pages", + "selection": { + "originalEditedTitle": "Select Original and Edited PDFs" + }, + "original": { "label": "Original PDF" }, + "edited": { "label": "Edited PDF" }, + "swap": { + "confirmTitle": "Re-run comparison?", + "confirmBody": "This will rerun the tool. Are you sure you want to swap the order of Original and Edited?", + "confirm": "Swap and Re-run" + }, + "cta": "Compare", + "loading": "Comparing...", + + "summary": { + "baseHeading": "Original document", + "comparisonHeading": "Edited document", + "pageLabel": "Page" + }, + "rendering": { + "pageNotReadyTitle": "Page not rendered yet", + "pageNotReadyBody": "Some pages are still rendering. Navigation will snap once they are ready.", + "rendering": "rendering", + "inProgress": "At least one of these PDFs are very large, scrolling won't be smooth until the rendering is complete", + "pagesRendered": "pages rendered", + "complete": "Page rendering complete" + }, + "dropdown": { + "deletionsLabel": "Deletions", + "additionsLabel": "Additions", + "deletions": "Deletions ({{count}})", + "additions": "Additions ({{count}})", + "searchPlaceholder": "Search changes...", + "noResults": "No changes found" }, "document": { "1": "Document 1", "2": "Document 2" }, - "submit": "Compare", + "longJob": { + "title": "Large comparison in progress", + "body": "These PDFs together exceed 2,000 pages. Processing can take several minutes." + }, + "slowOperation": { + "title": "Still working…", + "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" }, @@ -4046,10 +4201,26 @@ "title": "Premium & Enterprise", "description": "Configure your premium or enterprise license key.", "license": "License Configuration", - "key": "License Key", - "key.description": "Enter your premium or enterprise license key", - "enabled": "Enable Premium Features", - "enabled.description": "Enable license key checks for pro/enterprise features", + "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. 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", + "description": "Enable license key checks for pro/enterprise features" + }, "movedFeatures": { "title": "Premium Features Distributed", "message": "Premium and Enterprise features are now organized in their respective sections:" @@ -4426,6 +4597,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": { "copy": "Copy", "copied": "Copied!", @@ -4435,7 +4609,12 @@ "used": "used", "available": "available", "cancel": "Cancel", - "preview": "Preview" + "preview": "Preview", + "close": "Close", + "done": "Done", + "loading": "Loading...", + "back": "Back", + "continue": "Continue" }, "config": { "overview": { @@ -5035,6 +5214,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" @@ -5052,13 +5239,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", @@ -5100,13 +5290,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", @@ -5262,6 +5483,145 @@ "backendHealth": { "checking": "Checking backend status...", "online": "Backend Online", - "offline": "Backend Offline" + "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." } } diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index c40b35a8e..7ddc3dbfd 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -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" diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index b75106195..968edc53d 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -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"] } diff --git a/frontend/src-tauri/capabilities/default.json b/frontend/src-tauri/capabilities/default.json index 385973667..b6f98d92e 100644 --- a/frontend/src-tauri/capabilities/default.json +++ b/frontend/src-tauri/capabilities/default.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": "**" }] + } ] } diff --git a/frontend/src-tauri/src/commands/auth.rs b/frontend/src-tauri/src/commands/auth.rs new file mode 100644 index 000000000..9ce4c3cda --- /dev/null +++ b/frontend/src-tauri/src/commands/auth.rs @@ -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, +} + +fn get_keyring_entry() -> Result { + 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, 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, +) -> 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, 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 = 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, +} + +#[derive(Debug, Deserialize)] +struct SpringBootLoginResponse { + session: SpringBootSession, + user: SpringBootUser, +} + +#[derive(Debug, Serialize)] +pub struct LoginResponse { + pub token: String, + pub username: String, + pub email: Option, +} + +/// 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 { + 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, + }) +} diff --git a/frontend/src-tauri/src/commands/backend.rs b/frontend/src-tauri/src/commands/backend.rs index c7bce50f7..27a4a7dfd 100644 --- a/frontend/src-tauri/src/commands/backend.rs +++ b/frontend/src-tauri/src/commands/backend.rs @@ -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> = Mutex::new(None); static BACKEND_STARTING: Mutex = Mutex::new(false); +static BACKEND_PORT: Mutex> = 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 { + // 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::().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 { } 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 { .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 { @@ -237,17 +256,22 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver { @@ -255,13 +279,13 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver 0 { println!("⚠️ Backend process ended with {} errors detected", error_count); } @@ -308,14 +332,36 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver Result { +pub async fn start_backend( + app: tauri::AppHandle, + connection_state: tauri::State<'_, AppConnectionState>, +) -> Result { 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 { 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 { + 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() { } } } -} \ No newline at end of file +} diff --git a/frontend/src-tauri/src/commands/connection.rs b/frontend/src-tauri/src/commands/connection.rs new file mode 100644 index 000000000..097d411b5 --- /dev/null +++ b/frontend/src-tauri/src/commands/connection.rs @@ -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, +} + +#[tauri::command] +pub async fn get_connection_config( + app_handle: AppHandle, + state: State<'_, AppConnectionState>, +) -> Result { + // 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 = 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, +) -> 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 { + 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) +} diff --git a/frontend/src-tauri/src/commands/health.rs b/frontend/src-tauri/src/commands/health.rs index 394c8462c..e807df7ef 100644 --- a/frontend/src-tauri/src/commands/health.rs +++ b/frontend/src-tauri/src/commands/health.rs @@ -1,36 +1,16 @@ -// Command to check if backend is healthy +use reqwest; + #[tauri::command] -pub async fn check_backend_health() -> Result { - let client = reqwest::Client::builder() +pub async fn check_backend_health(port: u16) -> Result { + 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 } -} \ No newline at end of file +} diff --git a/frontend/src-tauri/src/commands/mod.rs b/frontend/src-tauri/src/commands/mod.rs index ba9995ba6..ecf2f0a8d 100644 --- a/frontend/src-tauri/src/commands/mod.rs +++ b/frontend/src-tauri/src/commands/mod.rs @@ -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; diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index 969479666..cc8e5b65f 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -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") diff --git a/frontend/src-tauri/src/state/connection_state.rs b/frontend/src-tauri/src/state/connection_state.rs new file mode 100644 index 000000000..e6a924956 --- /dev/null +++ b/frontend/src-tauri/src/state/connection_state.rs @@ -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, +} + +impl Default for ConnectionState { + fn default() -> Self { + Self { + mode: ConnectionMode::Offline, + server_config: None, + } + } +} + +pub struct AppConnectionState(pub Mutex); + +impl Default for AppConnectionState { + fn default() -> Self { + Self(Mutex::new(ConnectionState::default())) + } +} diff --git a/frontend/src-tauri/src/state/mod.rs b/frontend/src-tauri/src/state/mod.rs new file mode 100644 index 000000000..4b8c86c60 --- /dev/null +++ b/frontend/src-tauri/src/state/mod.rs @@ -0,0 +1 @@ +pub mod connection_state; diff --git a/frontend/src-tauri/tauri.conf.json b/frontend/src-tauri/tauri.conf.json index a6dffc881..a7c3355d8 100644 --- a/frontend/src-tauri/tauri.conf.json +++ b/frontend/src-tauri/tauri.conf.json @@ -51,6 +51,11 @@ "desktopTemplate": "stirling-pdf.desktop" } }, + "windows": { + "certificateThumbprint": null, + "digestAlgorithm": "sha256", + "timestampUrl": "http://timestamp.digicert.com" + }, "macOS": { "minimumSystemVersion": "10.15", "signingIdentity": null, diff --git a/frontend/src/core/components/annotation/shared/BaseAnnotationTool.tsx b/frontend/src/core/components/annotation/shared/BaseAnnotationTool.tsx index c61b61cfd..ea093b5be 100644 --- a/frontend/src/core/components/annotation/shared/BaseAnnotationTool.tsx +++ b/frontend/src/core/components/annotation/shared/BaseAnnotationTool.tsx @@ -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 = ({ undo, redo } = usePDFAnnotation(); + const { historyApiRef } = useSignature(); const [selectedColor, setSelectedColor] = useState('#000000'); const [isColorPickerOpen, setIsColorPickerOpen] = useState(false); const [signatureData, setSignatureData] = useState(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 = ({ = ({ /> ); -}; \ No newline at end of file +}; diff --git a/frontend/src/core/components/annotation/shared/ColorPicker.tsx b/frontend/src/core/components/annotation/shared/ColorPicker.tsx index 40bb363b4..04ae501bb 100644 --- a/frontend/src/core/components/annotation/shared/ColorPicker.tsx +++ b/frontend/src/core/components/annotation/shared/ColorPicker.tsx @@ -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 = ({ onClose, selectedColor, onColorChange, - title = "Choose Color" + title }) => { + const { t } = useTranslation(); + const resolvedTitle = title ?? t('colorPicker.title', 'Choose colour'); + return ( @@ -36,7 +40,7 @@ export const ColorPicker: React.FC = ({ /> @@ -64,4 +68,4 @@ export const ColorSwatchButton: React.FC = ({ onClick={onClick} /> ); -}; \ No newline at end of file +}; diff --git a/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx b/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx index e8600e0a2..cf13532f1 100644 --- a/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx +++ b/frontend/src/core/components/annotation/shared/DrawingCanvas.tsx @@ -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 = ({ @@ -34,12 +36,14 @@ export const DrawingCanvas: React.FC = ({ disabled = false, width = 400, height = 150, + initialSignatureData, }) => { + const { t } = useTranslation(); const previewCanvasRef = useRef(null); const modalCanvasRef = useRef(null); const padRef = useRef(null); const [modalOpen, setModalOpen] = useState(false); - const [colorPickerOpen, setColorPickerOpen] = useState(false); + const [savedSignatureData, setSavedSignatureData] = useState(null); const initPad = (canvas: HTMLCanvasElement) => { if (!padRef.current) { @@ -55,6 +59,18 @@ export const DrawingCanvas: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ } }; + 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 ( <> - Draw your signature - + {t('sign.canvas.heading', 'Draw your signature')} + - Click to open drawing canvas + {t('sign.canvas.clickToOpen', 'Click to open the drawing canvas')} - + -
-
- Color - - -
- setColorPickerOpen(!colorPickerOpen)} - /> -
-
- - { - onColorSwatchClick(); - updatePenColor(color); - }} - swatches={['#000000', '#0066cc', '#cc0000', '#cc6600', '#009900', '#6600cc']} - /> - -
-
-
- Pen Size + + + + {t('sign.canvas.colorLabel', 'Colour')} + + + + + + {t('sign.canvas.penSizeLabel', 'Pen size')} + = ({ updatePenSize(size); }} onInputChange={onPenSizeInputChange} - placeholder="Size" + placeholder={t('sign.canvas.penSizePlaceholder', 'Size')} size="compact-sm" - style={{ width: '60px' }} + style={{ width: '80px' }} /> -
-
+
+ = ({ 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 = ({
diff --git a/frontend/src/core/components/annotation/shared/DrawingControls.tsx b/frontend/src/core/components/annotation/shared/DrawingControls.tsx index 62c7c615f..3c28a594e 100644 --- a/frontend/src/core/components/annotation/shared/DrawingControls.tsx +++ b/frontend/src/core/components/annotation/shared/DrawingControls.tsx @@ -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 = ({ @@ -18,30 +22,48 @@ export const DrawingControls: React.FC = ({ 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 ( - - {/* Undo/Redo Controls */} - - + + {onUndo && ( + + + + + + )} + {onRedo && ( + + + + + + )} + + {additionalControls} {/* Place Signature Button */} {showPlaceButton && onPlaceSignature && ( @@ -50,11 +72,11 @@ export const DrawingControls: React.FC = ({ color="blue" onClick={onPlaceSignature} disabled={disabled || !hasSignatureData} - flex={1} + ml="auto" > {placeButtonText} )} ); -}; \ No newline at end of file +}; diff --git a/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx b/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx index aca7430ce..c700d4d05 100644 --- a/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx +++ b/frontend/src/core/components/annotation/shared/TextInputWithFont.tsx @@ -34,12 +34,18 @@ export const TextInputWithFont: React.FC = ({ 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 = ({ 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 ( onTextChange(e.target.value)} @@ -63,7 +74,7 @@ export const TextInputWithFont: React.FC = ({ {/* Font Selection */} setCurrency(value || 'gbp')} + data={currencyOptions} + searchable + clearable={false} + w={300} + comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }} + /> + + + {/* Manage Subscription Button - Only show if user has active license and Supabase is configured */} + {licenseInfo?.licenseKey && isSupabaseConfigured && ( + + + {t('plan.manageSubscription.description', 'Manage your subscription, billing, and payment methods')} + + + + )} + + + + + + + + {/* License Key Section */} +
+ + + + + } + > + + {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.')} + + + + {/* Severe warning if license already exists */} + {licenseInfo?.licenseKey && ( + } + title={t('admin.settings.premium.key.overwriteWarning.title', '⚠️ Warning: Existing License Detected')} + > + + + {t('admin.settings.premium.key.overwriteWarning.line1', 'Overwriting your current license key cannot be undone.')} + + + {t('admin.settings.premium.key.overwriteWarning.line2', 'Your previous license will be permanently lost unless you have backed it up elsewhere.')} + + + {t('admin.settings.premium.key.overwriteWarning.line3', 'Important: Keep license keys private and secure. Never share them publicly.')} + + + + )} + + + + setLicenseKeyInput(e.target.value)} + placeholder={licenseInfo?.licenseKey || '00000000-0000-0000-0000-000000000000'} + type="password" + disabled={savingLicense} + /> + + + + + + + + +
+ + ); +}; + +export default AdminPlanSection; diff --git a/frontend/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.tsx new file mode 100644 index 000000000..2c0640a3f --- /dev/null +++ b/frontend/src/proprietary/components/shared/config/configSections/plan/AvailablePlansSection.tsx @@ -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 = ({ + 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 = { + 'enterprise': 3, + 'server': 2, + 'free': 1 + }; + + const currentLevel = tierHierarchy[currentTier] || 0; + const targetLevel = tierHierarchy[tierGroup.tier] || 0; + + return currentLevel > targetLevel; + }; + + return ( +
+

+ {t('plan.availablePlans.title', 'Available Plans')} +

+

+ {t('plan.availablePlans.subtitle', 'Choose the plan that fits your needs')} +

+ +
+ {groupedPlans.map((group) => ( + + ))} +
+ +
+ +
+ + + + +
+ ); +}; + +export default AvailablePlansSection; diff --git a/frontend/src/proprietary/components/shared/config/configSections/plan/FeatureComparisonTable.tsx b/frontend/src/proprietary/components/shared/config/configSections/plan/FeatureComparisonTable.tsx new file mode 100644 index 000000000..129d59b9f --- /dev/null +++ b/frontend/src/proprietary/components/shared/config/configSections/plan/FeatureComparisonTable.tsx @@ -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 = ({ plans }) => { + const { t } = useTranslation(); + + return ( + + + {t('plan.featureComparison', 'Feature Comparison')} + + +
+ + + + + {plans.map((plan, index) => ( + + ))} + + + + {plans[0]?.features.map((_, featureIndex) => ( + + + {plans.map((plan, planIndex) => ( + + ))} + + ))} + +
+ {t('plan.feature.title', 'Feature')} + + {plan.name} + {plan.popular && ( + + {t('plan.popular', 'Popular')} + + )} +
+ {plans[0].features[featureIndex].name} + + {plan.features[featureIndex]?.included ? ( + + ✓ + + ) : ( + + − + + )} +
+
+
+ ); +}; + +export default FeatureComparisonTable; diff --git a/frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx b/frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx new file mode 100644 index 000000000..696fa50e9 --- /dev/null +++ b/frontend/src/proprietary/components/shared/config/configSections/plan/PlanCard.tsx @@ -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 = ({ planGroup, isCurrentTier, isDowngrade, currentLicenseInfo, onUpgradeClick }) => { + const { t } = useTranslation(); + + // Render Free plan + if (planGroup.tier === 'free') { + return ( + + {isCurrentTier && ( + + {t('plan.current', 'Current Plan')} + + )} + +
+ + {planGroup.name} + + + {t('plan.from', 'From')} + + + £0 + + + {t('plan.free.forever', 'Forever free')} + +
+ + + + + {planGroup.highlights.map((highlight, index) => ( + + • {highlight} + + ))} + + +
+ + + + + ); + } + + // 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 ( + + {isCurrentTier ? ( + + {t('plan.current', 'Current Plan')} + + ) : planGroup.popular ? ( + + {t('plan.popular', 'Popular')} + + ) : null} + + + {/* Tier Name */} +
+ + {planGroup.name} + + + + {t('plan.from', 'From')} + + + {/* Price */} + {isEnterprise && displaySeatPrice !== undefined ? ( + <> + + {displayCurrency}{displayPrice} + + + + {displayCurrency}{displaySeatPrice}/seat {t('plan.perMonth', '/month')} + + + ) : ( + <> + + {displayCurrency}{displayPrice} + + + {t('plan.perMonth', '/month')} + + + )} + + {/* Show seat count for enterprise plans when current */} + {isEnterprise && isCurrentTier && currentLicenseInfo && currentLicenseInfo.maxUsers > 0 && ( + + {t('plan.licensedSeats', 'Licensed: {{count}} seats', { count: currentLicenseInfo.maxUsers })} + + )} +
+ + + + {/* Highlights */} + + {planGroup.highlights.map((highlight, index) => ( + + • {highlight} + + ))} + + +
+ + {/* Single Upgrade Button */} + + + + ); +}; + +export default PlanCard; diff --git a/frontend/src/proprietary/components/shared/config/configSections/plan/StaticPlanSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/plan/StaticPlanSection.tsx new file mode 100644 index 000000000..3bdd06baf --- /dev/null +++ b/frontend/src/proprietary/components/shared/config/configSections/plan/StaticPlanSection.tsx @@ -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 = ({ 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({ + 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 ( +
+ {/* Current Plan Section */} +
+

+ {t('plan.activePlan.title', 'Active Plan')} +

+

+ {t('plan.activePlan.subtitle', 'Your current subscription details')} +

+ + + + + + + {currentPlan.name} + + + {t('subscription.status.active', 'Active')} + + + {currentLicenseInfo && ( + + {t('plan.static.maxUsers', 'Max Users')}: {currentLicenseInfo.maxUsers} + + )} + +
+ + {currentPlan.price === 0 ? t('plan.free.name', 'Free') : `${currentPlan.currency}${currentPlan.price}${currentPlan.period}`} + +
+
+
+
+ + {/* Available Plans */} +
+

+ {t('plan.availablePlans.title', 'Available Plans')} +

+

+ {t('plan.static.contactToUpgrade', 'Contact us to upgrade or customize your plan')} +

+ +
+ {staticPlans.map((plan) => ( + + {plan.id === currentPlan.id && ( + + {t('plan.current', 'Current Plan')} + + )} + {plan.popular && plan.id !== currentPlan.id && ( + + {t('plan.popular', 'Popular')} + + )} + + +
+ + {plan.name} + + + + {plan.price === 0 && plan.id !== 'free' + ? t('plan.customPricing', 'Custom') + : plan.price === 0 + ? t('plan.free.name', 'Free') + : `${plan.currency}${plan.price}`} + + {plan.period && ( + + {plan.period} + + )} + + + {typeof plan.maxUsers === 'string' + ? plan.maxUsers + : `${t('plan.static.upTo', 'Up to')} ${plan.maxUsers} ${t('workspace.people.license.users', 'users')}`} + +
+ + + {plan.highlights.map((highlight, index) => ( + + • {highlight} + + ))} + + +
+ + + + + ))} +
+ + {/* Feature Comparison Toggle */} +
+ +
+ + {/* Feature Comparison Table */} + + + +
+ + + + {/* License Key Section */} +
+ + + + + } + > + + {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.')} + + + + {premiumLoading ? ( + + + + ) : ( + + +
+ + {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={premiumSettings.key || ''} + onChange={(e) => setPremiumSettings({ ...premiumSettings, key: e.target.value })} + placeholder="00000000-0000-0000-0000-000000000000" + /> +
+ + + + +
+
+ )} +
+
+
+ + {/* Restart Confirmation Modal */} + +
+ ); +}; + +export default StaticPlanSection; diff --git a/frontend/src/proprietary/constants/planConstants.ts b/frontend/src/proprietary/constants/planConstants.ts new file mode 100644 index 000000000..1865238df --- /dev/null +++ b/frontend/src/proprietary/constants/planConstants.ts @@ -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; diff --git a/frontend/src/proprietary/contexts/CheckoutContext.tsx b/frontend/src/proprietary/contexts/CheckoutContext.tsx new file mode 100644 index 000000000..f8558a650 --- /dev/null +++ b/frontend/src/proprietary/contexts/CheckoutContext.tsx @@ -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; + closeCheckout: () => void; + isOpen: boolean; + isLoading: boolean; +} + +const CheckoutContext = createContext(undefined); + +interface CheckoutProviderProps { + children: ReactNode; + defaultCurrency?: string; +} + +export const CheckoutProvider: React.FC = ({ + children, + defaultCurrency = 'gbp' +}) => { + const { t } = useTranslation(); + const { refetchLicense } = useLicense(); + const [isOpen, setIsOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [selectedPlanGroup, setSelectedPlanGroup] = useState(null); + const [minimumSeats, setMinimumSeats] = useState(1); + const [currentCurrency, setCurrentCurrency] = useState(defaultCurrency); + const [currentOptions, setCurrentOptions] = useState({}); + 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 ( + + {children} + + {/* Global Checkout Modal */} + {selectedPlanGroup && ( + + )} + + ); +}; + +export const useCheckout = (): CheckoutContextValue => { + const context = useContext(CheckoutContext); + if (!context) { + throw new Error('useCheckout must be used within CheckoutProvider'); + } + return context; +}; diff --git a/frontend/src/proprietary/contexts/LicenseContext.tsx b/frontend/src/proprietary/contexts/LicenseContext.tsx new file mode 100644 index 000000000..9414dcf5d --- /dev/null +++ b/frontend/src/proprietary/contexts/LicenseContext.tsx @@ -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; +} + +const LicenseContext = createContext(undefined); + +interface LicenseProviderProps { + children: ReactNode; +} + +export const LicenseProvider: React.FC = ({ children }) => { + const { config } = useAppConfig(); + const [licenseInfo, setLicenseInfo] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + {children} + + ); +}; + +export const useLicense = (): LicenseContextValue => { + const context = useContext(LicenseContext); + if (!context) { + throw new Error('useLicense must be used within LicenseProvider'); + } + return context; +}; diff --git a/frontend/src/proprietary/hooks/usePlans.ts b/frontend/src/proprietary/hooks/usePlans.ts new file mode 100644 index 000000000..33cb198de --- /dev/null +++ b/frontend/src/proprietary/hooks/usePlans.ts @@ -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; +} + +export const usePlans = (currency: string = 'gbp'): UsePlansReturn => { + const [plans, setPlans] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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, + }; +}; diff --git a/frontend/src/proprietary/services/licenseService.ts b/frontend/src/proprietary/services/licenseService.ts new file mode 100644 index 000000000..409dbfd74 --- /dev/null +++ b/frontend/src/proprietary/services/licenseService.ts @@ -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 { + 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; + 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(); + 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 { + // 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 { + // 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 { + 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 { + // 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 { + 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 { + 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 { + 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; diff --git a/frontend/src/proprietary/utils/licenseCheckoutUtils.ts b/frontend/src/proprietary/utils/licenseCheckoutUtils.ts new file mode 100644 index 000000000..6235bbfe9 --- /dev/null +++ b/frontend/src/proprietary/utils/licenseCheckoutUtils.ts @@ -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 { + 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 => { + // 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 { + 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 { + 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', + }; + } +} diff --git a/frontend/src/proprietary/utils/protocolDetection.ts b/frontend/src/proprietary/utils/protocolDetection.ts new file mode 100644 index 000000000..9bd9ce03b --- /dev/null +++ b/frontend/src/proprietary/utils/protocolDetection.ts @@ -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(); +} diff --git a/frontend/tsconfig.desktop.json b/frontend/tsconfig.desktop.json index fe0419a78..cb842c399 100644 --- a/frontend/tsconfig.desktop.json +++ b/frontend/tsconfig.desktop.json @@ -13,6 +13,10 @@ } }, "exclude": [ + "src/core/**/*.test.ts*", + "src/core/**/*.spec.ts*", + "src/proprietary/**/*.test.ts*", + "src/proprietary/**/*.spec.ts*", "node_modules" ] } diff --git a/frontend/tsconfig.proprietary.json b/frontend/tsconfig.proprietary.json index 29f1b62df..4c726f09a 100644 --- a/frontend/tsconfig.proprietary.json +++ b/frontend/tsconfig.proprietary.json @@ -10,6 +10,8 @@ } }, "exclude": [ + "src/core/**/*.test.ts*", + "src/core/**/*.spec.ts*", "src/desktop", "node_modules" ] diff --git a/frontend/vite-env.d.ts b/frontend/vite-env.d.ts index ca36e9027..f483e59d4 100644 --- a/frontend/vite-env.d.ts +++ b/frontend/vite-env.d.ts @@ -3,6 +3,8 @@ interface ImportMetaEnv { readonly VITE_PUBLIC_POSTHOG_KEY: string; readonly VITE_PUBLIC_POSTHOG_HOST: string; + readonly VITE_SUPABASE_URL: string; + readonly VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY: string; } interface ImportMeta { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index f7af0fdac..49aacd3ae 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -31,24 +31,25 @@ export default defineConfig(({ mode }) => { // tell vite to ignore watching `src-tauri` ignored: ['**/src-tauri/**'], }, - proxy: { - '/api': { - target: 'http://localhost:8080', - changeOrigin: true, - secure: false, - xfwd: true, - }, - '/oauth2': { - target: 'http://localhost:8080', - changeOrigin: true, - secure: false, - xfwd: true, - }, - '/login/oauth2': { - target: 'http://localhost:8080', - changeOrigin: true, - secure: false, - xfwd: true, + // Only use proxy in web mode - Tauri handles backend connections directly + proxy: isDesktopMode ? undefined : { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + secure: false, + xfwd: true, + }, + '/oauth2': { + target: 'http://localhost:8080', + changeOrigin: true, + secure: false, + xfwd: true, + }, + '/login/oauth2': { + target: 'http://localhost:8080', + changeOrigin: true, + secure: false, + xfwd: true, }, }, }, diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index bf9880848..111099896 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -2,52 +2,87 @@ import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react-swc'; import tsconfigPaths from 'vite-tsconfig-paths'; -export default defineConfig((configEnv) => { - const isProprietary = process.env.DISABLE_ADDITIONAL_FEATURES !== 'true'; - const isDesktopMode = - configEnv.mode === 'desktop' || - process.env.STIRLING_DESKTOP === 'true' || - process.env.VITE_DESKTOP === 'true'; - - const baseProject = isProprietary ? './tsconfig.proprietary.json' : './tsconfig.core.json'; - const desktopProject = isProprietary ? './tsconfig.desktop.json' : baseProject; - const tsconfigProject = isDesktopMode ? desktopProject : baseProject; - - return { - plugins: [ - react(), - tsconfigPaths({ - projects: [tsconfigProject], - }), +export default defineConfig({ + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./src/core/setupTests.ts'], + css: false, + exclude: [ + 'node_modules/', + 'src/**/*.spec.ts', // Exclude Playwright E2E tests + 'src/tests/test-fixtures/**' ], - test: { - globals: true, - environment: 'jsdom', - setupFiles: ['./src/core/setupTests.ts'], - css: false, // Disable CSS processing to speed up tests - include: [ - 'src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}' - ], + testTimeout: 10000, + hookTimeout: 10000, + coverage: { + reporter: ['text', 'json', 'html'], exclude: [ 'node_modules/', - 'src/**/*.spec.ts', // Exclude Playwright E2E tests - 'src/tests/test-fixtures/**' - ], - testTimeout: 10000, // 10 second timeout - hookTimeout: 10000, // 10 second timeout for setup/teardown - coverage: { - reporter: ['text', 'json', 'html'], - exclude: [ - 'node_modules/', - 'src/core/setupTests.ts', - '**/*.d.ts', - 'src/tests/test-fixtures/**', - 'src/**/*.spec.ts' // Exclude Playwright files from coverage - ] - } + 'src/core/setupTests.ts', + '**/*.d.ts', + 'src/tests/test-fixtures/**', + 'src/**/*.spec.ts' + ] }, - esbuild: { - target: 'es2020' // Use older target to avoid warnings - } - }; + projects: [ + { + test: { + name: 'core', + include: ['src/core/**/*.test.{ts,tsx}'], + environment: 'jsdom', + globals: true, + setupFiles: ['./src/core/setupTests.ts'], + }, + plugins: [ + react(), + tsconfigPaths({ + projects: ['./tsconfig.core.json'], + }), + ], + esbuild: { + target: 'es2020' + } + }, + { + test: { + name: 'proprietary', + include: ['src/proprietary/**/*.test.{ts,tsx}'], + environment: 'jsdom', + globals: true, + setupFiles: ['./src/core/setupTests.ts'], + }, + plugins: [ + react(), + tsconfigPaths({ + projects: ['./tsconfig.proprietary.json'], + }), + ], + esbuild: { + target: 'es2020' + } + }, + { + test: { + name: 'desktop', + include: ['src/desktop/**/*.test.{ts,tsx}'], + environment: 'jsdom', + globals: true, + setupFiles: ['./src/core/setupTests.ts'], + }, + plugins: [ + react(), + tsconfigPaths({ + projects: ['./tsconfig.desktop.json'], + }), + ], + esbuild: { + target: 'es2020' + } + }, + ], + }, + esbuild: { + target: 'es2020' + } }); diff --git a/scripts/ignore_translation.toml b/scripts/ignore_translation.toml index 83e2df925..51565290f 100644 --- a/scripts/ignore_translation.toml +++ b/scripts/ignore_translation.toml @@ -50,7 +50,6 @@ ignore = [ [ca_CA] ignore = [ - 'adminUserSettings.admin', 'lang.amh', 'lang.ceb', 'lang.chr', @@ -408,8 +407,6 @@ ignore = [ 'endpointStatistics.top20', 'home.pipeline.title', 'language.direction', - 'pipeline.title', - 'pipelineOptions.pipelineHeader', 'pro', 'showJS.tags', ] @@ -612,7 +609,6 @@ ignore = [ 'lang.urd', 'lang.yor', 'language.direction', - 'oops', 'sponsor', ] @@ -788,7 +784,6 @@ ignore = [ 'lang.urd', 'lang.uzb', 'language.direction', - 'navbar.sections.security', 'text', 'watermark.type.1', ] @@ -838,7 +833,6 @@ ignore = [ 'endpointStatistics.top10', 'endpointStatistics.top20', 'font', - 'info', 'lang.div', 'lang.epo', 'lang.hin', @@ -896,7 +890,6 @@ ignore = [ 'lang.tir', 'lang.uzb_cyrl', 'language.direction', - 'pipelineOptions.pipelineHeader', 'showJS.tags', ] @@ -987,6 +980,5 @@ ignore = [ [zh_TW] ignore = [ 'language.direction', - 'poweredBy', 'showJS.tags', ]