Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84ac407017 | ||
|
|
794d15ebaa | ||
|
|
1e11848d4f | ||
|
|
5f32502b54 | ||
|
|
4f4f3c90ba | ||
|
|
2e51a7b288 | ||
|
|
8140ff8c02 | ||
|
|
432e1047d9 | ||
|
|
89df78c347 | ||
|
|
dd6419743f | ||
|
|
fca8470637 | ||
|
|
76f2fd3b76 | ||
|
|
06af6be14b | ||
|
|
c87da6d5cc | ||
|
|
6c8d2c89fe | ||
|
|
8d9e70c796 | ||
|
|
5f62e6f81f |
@@ -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:
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
# Windows Code Signing Setup Guide
|
||||
|
||||
This guide explains how to set up Windows code signing for Stirling-PDF desktop application builds.
|
||||
|
||||
## Overview
|
||||
|
||||
Windows code signing is essential for:
|
||||
- Preventing Windows SmartScreen warnings
|
||||
- Building trust with users
|
||||
- Enabling Microsoft Store distribution
|
||||
- Professional application distribution
|
||||
|
||||
## Certificate Types
|
||||
|
||||
### OV Certificate (Organization Validated)
|
||||
- More affordable option
|
||||
- Requires business verification
|
||||
- May trigger SmartScreen warnings initially until reputation builds
|
||||
- Suitable for most independent software vendors
|
||||
|
||||
### EV Certificate (Extended Validation)
|
||||
- Premium option with immediate SmartScreen reputation
|
||||
- Requires hardware security module (HSM) or cloud-based signing
|
||||
- Higher cost but provides immediate trust
|
||||
- Required since June 2023 for new certificates
|
||||
|
||||
## Obtaining a Certificate
|
||||
|
||||
### Certificate Authorities
|
||||
Popular certificate authorities for Windows code signing:
|
||||
- DigiCert
|
||||
- Sectigo (formerly Comodo)
|
||||
- GlobalSign
|
||||
- SSL.com
|
||||
|
||||
### Certificate Format
|
||||
You'll receive a certificate in one of these formats:
|
||||
- `.pfx` or `.p12` (preferred - contains both certificate and private key)
|
||||
- `.cer` + private key (needs conversion to .pfx)
|
||||
|
||||
### Converting to PFX (if needed)
|
||||
If you have separate certificate and private key files:
|
||||
|
||||
```bash
|
||||
openssl pkcs12 -export -out certificate.pfx -inkey private-key.key -in certificate.cer
|
||||
```
|
||||
|
||||
## Setting Up GitHub Secrets
|
||||
|
||||
### Required Secrets
|
||||
|
||||
Navigate to your GitHub repository → Settings → Secrets and variables → Actions
|
||||
|
||||
Add the following secrets:
|
||||
|
||||
#### 1. `WINDOWS_CERTIFICATE`
|
||||
- **Description**: Base64-encoded .pfx certificate file
|
||||
- **How to create**:
|
||||
|
||||
**On macOS/Linux:**
|
||||
```bash
|
||||
base64 -i certificate.pfx | pbcopy # Copies to clipboard
|
||||
```
|
||||
|
||||
**On Windows (PowerShell):**
|
||||
```powershell
|
||||
[Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx")) | Set-Clipboard
|
||||
```
|
||||
|
||||
Paste the entire base64 string into the GitHub secret.
|
||||
|
||||
#### 2. `WINDOWS_CERTIFICATE_PASSWORD`
|
||||
- **Description**: Password for the .pfx certificate
|
||||
- **Value**: The password you set when creating/exporting the .pfx file
|
||||
|
||||
### Optional Secrets for Tauri Updater
|
||||
|
||||
If you're using Tauri's built-in updater feature:
|
||||
|
||||
#### `TAURI_SIGNING_PRIVATE_KEY`
|
||||
- Generated using Tauri CLI: `npm run tauri signer generate`
|
||||
- Used for update package verification
|
||||
|
||||
#### `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`
|
||||
- Password for the Tauri signing key
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### 1. Tauri Configuration (frontend/src-tauri/tauri.conf.json)
|
||||
|
||||
The Windows signing configuration is already set up:
|
||||
|
||||
```json
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": "http://timestamp.digicert.com"
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration Options:**
|
||||
- `certificateThumbprint`: Automatically extracted from imported certificate (leave as `null`)
|
||||
- `digestAlgorithm`: Hashing algorithm - `sha256` is recommended
|
||||
- `timestampUrl`: Timestamp server to prove signing time (survives certificate expiration)
|
||||
|
||||
**Alternative Timestamp Servers:**
|
||||
- DigiCert: `http://timestamp.digicert.com`
|
||||
- Sectigo: `http://timestamp.sectigo.com`
|
||||
- GlobalSign: `http://timestamp.globalsign.com`
|
||||
|
||||
### 2. GitHub Workflow (.github/workflows/tauri-build.yml)
|
||||
|
||||
The workflow includes three Windows signing steps:
|
||||
|
||||
1. **Import Certificate**: Decodes and imports the .pfx certificate into Windows certificate store
|
||||
2. **Build Tauri App**: Builds and signs the application using the imported certificate
|
||||
3. **Verify Signature**: Validates that both .exe and .msi files are properly signed
|
||||
|
||||
## Testing the Setup
|
||||
|
||||
### 1. Local Testing (Windows Only)
|
||||
|
||||
Before pushing to GitHub, test locally:
|
||||
|
||||
```powershell
|
||||
# Set environment variables
|
||||
$env:WINDOWS_CERTIFICATE = [Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx"))
|
||||
$env:WINDOWS_CERTIFICATE_PASSWORD = "your-certificate-password"
|
||||
|
||||
# Build the application
|
||||
cd frontend
|
||||
npm run tauri build
|
||||
|
||||
# Verify the signature
|
||||
Get-AuthenticodeSignature "./src-tauri/target/release/bundle/msi/Stirling-PDF_*.msi"
|
||||
```
|
||||
|
||||
### 2. GitHub Actions Testing
|
||||
|
||||
1. Push your changes to a branch
|
||||
2. Manually trigger the workflow:
|
||||
- Go to Actions → Build Tauri Applications
|
||||
- Click "Run workflow"
|
||||
- Select "windows" platform
|
||||
3. Check the build logs for:
|
||||
- ✅ Certificate import success
|
||||
- ✅ Build completion
|
||||
- ✅ Signature verification
|
||||
|
||||
### 3. Verifying Signed Binaries
|
||||
|
||||
After downloading the built artifacts:
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
Get-AuthenticodeSignature "Stirling-PDF-windows-x86_64.exe"
|
||||
Get-AuthenticodeSignature "Stirling-PDF-windows-x86_64.msi"
|
||||
```
|
||||
|
||||
Look for:
|
||||
- Status: `Valid`
|
||||
- Signer: Your organization name
|
||||
- Timestamp: Recent date/time
|
||||
|
||||
**Windows (GUI):**
|
||||
1. Right-click the .exe or .msi file
|
||||
2. Select "Properties"
|
||||
3. Go to "Digital Signatures" tab
|
||||
4. Verify signature details
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "HashMismatch" Status
|
||||
- Certificate doesn't match the binary
|
||||
- Possible file corruption during download
|
||||
- Re-download and verify
|
||||
|
||||
### "NotSigned" Status
|
||||
- Certificate wasn't imported correctly
|
||||
- Check GitHub secrets are set correctly
|
||||
- Verify base64 encoding is complete (no truncation)
|
||||
|
||||
### "UnknownError" Status
|
||||
- Timestamp server unreachable
|
||||
- Try alternative timestamp URL in tauri.conf.json
|
||||
- Check network connectivity in GitHub Actions
|
||||
|
||||
### SmartScreen Still Shows Warnings
|
||||
- Normal for OV certificates initially
|
||||
- Reputation builds over time with user downloads
|
||||
- Consider EV certificate for immediate reputation
|
||||
|
||||
### Certificate Not Found During Build
|
||||
- Verify `WINDOWS_CERTIFICATE` secret is set
|
||||
- Check base64 encoding is correct (no extra whitespace)
|
||||
- Ensure password is correct
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Never commit certificates to version control**
|
||||
- Keep .pfx files secure and backed up
|
||||
- Use GitHub secrets for CI/CD
|
||||
|
||||
2. **Rotate certificates before expiration**
|
||||
- Set calendar reminders
|
||||
- Update GitHub secrets with new certificate
|
||||
|
||||
3. **Use strong passwords**
|
||||
- Certificate password should be complex
|
||||
- Store securely (password manager)
|
||||
|
||||
4. **Monitor certificate usage**
|
||||
- Review GitHub Actions logs
|
||||
- Set up notifications for failed builds
|
||||
|
||||
5. **Limit access to secrets**
|
||||
- Only repository admins should access secrets
|
||||
- Audit secret access regularly
|
||||
|
||||
## Certificate Lifecycle
|
||||
|
||||
### Before Expiration
|
||||
1. Obtain new certificate from CA (typically annual renewal)
|
||||
2. Convert to .pfx format if needed
|
||||
3. Update `WINDOWS_CERTIFICATE` secret with new base64-encoded certificate
|
||||
4. Update `WINDOWS_CERTIFICATE_PASSWORD` if password changed
|
||||
5. Test build to verify new certificate works
|
||||
|
||||
### Expired Certificates
|
||||
- Signed binaries remain valid (timestamp proves signing time)
|
||||
- New builds will fail until certificate is renewed
|
||||
- Users can still install previously signed versions
|
||||
|
||||
## Cost Considerations
|
||||
|
||||
### Certificate Costs (Annual, as of 2024)
|
||||
- **OV Certificate**: $100-400/year
|
||||
- **EV Certificate**: $400-1000/year
|
||||
|
||||
### Choosing the Right Certificate
|
||||
- **Open source / early stage**: Start with OV
|
||||
- **Commercial / enterprise**: Consider EV for better trust
|
||||
- **Microsoft Store**: EV certificate required
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Tauri Windows Signing Documentation](https://v2.tauri.app/distribute/sign/windows/)
|
||||
- [Microsoft Code Signing Overview](https://docs.microsoft.com/windows/win32/seccrypto/cryptography-tools)
|
||||
- [DigiCert Code Signing Guide](https://www.digicert.com/signing/code-signing-certificates)
|
||||
- [Windows SmartScreen FAQ](https://support.microsoft.com/windows/smartscreen-faq)
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter issues with Windows code signing:
|
||||
1. Check GitHub Actions logs for detailed error messages
|
||||
2. Verify all secrets are set correctly
|
||||
3. Test certificate locally first (Windows environment required)
|
||||
4. Open an issue in the repository with relevant logs (remove sensitive data)
|
||||
@@ -38,6 +38,7 @@ public class GeneralUtils {
|
||||
Set.of(
|
||||
"OCR images.json",
|
||||
"Prepare-pdfs-for-email.json",
|
||||
"Pre-publish-sanitization.json",
|
||||
"split-rotate-auto-rename.json");
|
||||
|
||||
private final String DEFAULT_WEBUI_CONFIGS_DIR = "defaultWebUIConfigs";
|
||||
|
||||
@@ -18,11 +18,37 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
@Slf4j
|
||||
public class EndpointConfiguration {
|
||||
|
||||
public enum DisableReason {
|
||||
CONFIG,
|
||||
DEPENDENCY,
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
public static class EndpointAvailability {
|
||||
private final boolean enabled;
|
||||
private final DisableReason reason;
|
||||
|
||||
public EndpointAvailability(boolean enabled, DisableReason reason) {
|
||||
this.enabled = enabled;
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public DisableReason getReason() {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
|
||||
private static final String REMOVE_BLANKS = "remove-blanks";
|
||||
private final ApplicationProperties applicationProperties;
|
||||
@Getter private Map<String, Boolean> endpointStatuses = new ConcurrentHashMap<>();
|
||||
private Map<String, Set<String>> endpointGroups = new ConcurrentHashMap<>();
|
||||
private Set<String> disabledGroups = new HashSet<>();
|
||||
private Map<String, DisableReason> endpointDisableReasons = new ConcurrentHashMap<>();
|
||||
private Map<String, DisableReason> groupDisableReasons = new ConcurrentHashMap<>();
|
||||
private Map<String, Set<String>> endpointAlternatives = new ConcurrentHashMap<>();
|
||||
private final boolean runningProOrHigher;
|
||||
|
||||
@@ -35,16 +61,31 @@ public class EndpointConfiguration {
|
||||
processEnvironmentConfigs();
|
||||
}
|
||||
|
||||
private String normalizeEndpoint(String endpoint) {
|
||||
if (endpoint == null) {
|
||||
return null;
|
||||
}
|
||||
return endpoint.startsWith("/") ? endpoint.substring(1) : endpoint;
|
||||
}
|
||||
|
||||
public void enableEndpoint(String endpoint) {
|
||||
endpointStatuses.put(endpoint, true);
|
||||
log.debug("Enabled endpoint: {}", endpoint);
|
||||
String normalized = normalizeEndpoint(endpoint);
|
||||
endpointStatuses.put(normalized, true);
|
||||
endpointDisableReasons.remove(normalized);
|
||||
log.debug("Enabled endpoint: {}", normalized);
|
||||
}
|
||||
|
||||
public void disableEndpoint(String endpoint) {
|
||||
if (!Boolean.FALSE.equals(endpointStatuses.get(endpoint))) {
|
||||
log.debug("Disabling endpoint: {}", endpoint);
|
||||
disableEndpoint(endpoint, DisableReason.CONFIG);
|
||||
}
|
||||
|
||||
public void disableEndpoint(String endpoint, DisableReason reason) {
|
||||
String normalized = normalizeEndpoint(endpoint);
|
||||
if (!Boolean.FALSE.equals(endpointStatuses.get(normalized))) {
|
||||
log.debug("Disabling endpoint: {}", normalized);
|
||||
}
|
||||
endpointStatuses.put(endpoint, false);
|
||||
endpointStatuses.put(normalized, false);
|
||||
endpointDisableReasons.put(normalized, reason);
|
||||
}
|
||||
|
||||
public boolean isEndpointEnabled(String endpoint) {
|
||||
@@ -150,6 +191,10 @@ public class EndpointConfiguration {
|
||||
}
|
||||
|
||||
public void disableGroup(String group) {
|
||||
disableGroup(group, DisableReason.CONFIG);
|
||||
}
|
||||
|
||||
public void disableGroup(String group, DisableReason reason) {
|
||||
if (disabledGroups.add(group)) {
|
||||
if (isToolGroup(group)) {
|
||||
log.debug(
|
||||
@@ -161,11 +206,12 @@ public class EndpointConfiguration {
|
||||
group);
|
||||
}
|
||||
}
|
||||
groupDisableReasons.put(group, reason);
|
||||
// Only cascade to endpoints for *functional* groups
|
||||
if (!isToolGroup(group)) {
|
||||
Set<String> endpoints = endpointGroups.get(group);
|
||||
if (endpoints != null) {
|
||||
endpoints.forEach(this::disableEndpoint);
|
||||
endpoints.forEach(endpoint -> disableEndpoint(endpoint, reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,12 +220,39 @@ public class EndpointConfiguration {
|
||||
if (disabledGroups.remove(group)) {
|
||||
log.debug("Enabling group: {}", group);
|
||||
}
|
||||
groupDisableReasons.remove(group);
|
||||
Set<String> endpoints = endpointGroups.get(group);
|
||||
if (endpoints != null) {
|
||||
endpoints.forEach(this::enableEndpoint);
|
||||
}
|
||||
}
|
||||
|
||||
public EndpointAvailability getEndpointAvailability(String endpoint) {
|
||||
boolean enabled = isEndpointEnabled(endpoint);
|
||||
DisableReason reason = enabled ? null : determineDisableReason(endpoint);
|
||||
return new EndpointAvailability(enabled, reason);
|
||||
}
|
||||
|
||||
private DisableReason determineDisableReason(String endpoint) {
|
||||
String normalized = normalizeEndpoint(endpoint);
|
||||
if (Boolean.FALSE.equals(endpointStatuses.get(normalized))) {
|
||||
return endpointDisableReasons.getOrDefault(normalized, DisableReason.CONFIG);
|
||||
}
|
||||
|
||||
for (Map.Entry<String, Set<String>> entry : endpointGroups.entrySet()) {
|
||||
String group = entry.getKey();
|
||||
Set<String> endpoints = entry.getValue();
|
||||
if (!disabledGroups.contains(group) || endpoints == null) {
|
||||
continue;
|
||||
}
|
||||
if (endpoints.contains(normalized)) {
|
||||
return groupDisableReasons.getOrDefault(group, DisableReason.CONFIG);
|
||||
}
|
||||
}
|
||||
|
||||
return DisableReason.UNKNOWN;
|
||||
}
|
||||
|
||||
public Set<String> getDisabledGroups() {
|
||||
return new HashSet<>(disabledGroups);
|
||||
}
|
||||
@@ -261,6 +334,8 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Convert", "pdf-to-csv");
|
||||
addEndpointToGroup("Convert", "pdf-to-markdown");
|
||||
addEndpointToGroup("Convert", "eml-to-pdf");
|
||||
addEndpointToGroup("Convert", "cbz-to-pdf");
|
||||
addEndpointToGroup("Convert", "pdf-to-cbz");
|
||||
|
||||
// Adding endpoints to "Security" group
|
||||
addEndpointToGroup("Security", "add-password");
|
||||
@@ -394,6 +469,8 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Java", "pdf-to-markdown");
|
||||
addEndpointToGroup("Java", "add-attachments");
|
||||
addEndpointToGroup("Java", "compress-pdf");
|
||||
addEndpointToGroup("Java", "cbz-to-pdf");
|
||||
addEndpointToGroup("Java", "pdf-to-cbz");
|
||||
addEndpointToGroup("rar", "pdf-to-cbr");
|
||||
|
||||
// Javascript
|
||||
|
||||
@@ -12,6 +12,7 @@ import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration.DisableReason;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
|
||||
@@ -97,7 +98,7 @@ public class ExternalAppDepConfig {
|
||||
if (affectedGroups != null) {
|
||||
for (String group : affectedGroups) {
|
||||
List<String> affectedFeatures = getAffectedFeatures(group);
|
||||
endpointConfiguration.disableGroup(group);
|
||||
endpointConfiguration.disableGroup(group, DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"Missing dependency: {} - Disabling group: {} (Affected features: {})",
|
||||
command,
|
||||
@@ -127,8 +128,8 @@ public class ExternalAppDepConfig {
|
||||
if (!pythonAvailable) {
|
||||
List<String> pythonFeatures = getAffectedFeatures("Python");
|
||||
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
|
||||
endpointConfiguration.disableGroup("Python");
|
||||
endpointConfiguration.disableGroup("OpenCV");
|
||||
endpointConfiguration.disableGroup("Python", DisableReason.DEPENDENCY);
|
||||
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"Missing dependency: Python - Disabling Python features: {} and OpenCV features: {}",
|
||||
String.join(", ", pythonFeatures),
|
||||
@@ -146,14 +147,14 @@ public class ExternalAppDepConfig {
|
||||
int exitCode = process.waitFor();
|
||||
if (exitCode != 0) {
|
||||
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
|
||||
endpointConfiguration.disableGroup("OpenCV");
|
||||
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"OpenCV not available in Python - Disabling OpenCV features: {}",
|
||||
String.join(", ", openCVFeatures));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
|
||||
endpointConfiguration.disableGroup("OpenCV");
|
||||
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"Error checking OpenCV: {} - Disabling OpenCV features: {}",
|
||||
e.getMessage(),
|
||||
|
||||
@@ -2,6 +2,9 @@ package stirling.software.SPDF.config;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import jakarta.servlet.Filter;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
@@ -9,17 +12,14 @@ import jakarta.servlet.ServletRequest;
|
||||
import jakarta.servlet.ServletResponse;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.service.WeeklyActiveUsersService;
|
||||
|
||||
/**
|
||||
* Filter to track browser IDs for Weekly Active Users (WAU) counting.
|
||||
* Only active when security is disabled (no-login mode).
|
||||
* Filter to track browser IDs for Weekly Active Users (WAU) counting. Only active when security is
|
||||
* disabled (no-login mode).
|
||||
*/
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "security.enableLogin", havingValue = "false")
|
||||
|
||||
+20
@@ -1,6 +1,7 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.context.ApplicationContext;
|
||||
@@ -10,9 +11,13 @@ import org.springframework.web.bind.annotation.RequestParam;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
|
||||
import stirling.software.SPDF.config.InitialSetup;
|
||||
import stirling.software.common.annotations.api.ConfigApi;
|
||||
import stirling.software.common.configuration.AppConfig;
|
||||
@@ -200,4 +205,19 @@ public class ConfigController {
|
||||
}
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@GetMapping("/endpoints-availability")
|
||||
public ResponseEntity<Map<String, EndpointAvailability>> getEndpointAvailability(
|
||||
@RequestParam(name = "endpoints")
|
||||
@Size(min = 1, max = 100, message = "Must provide between 1 and 100 endpoints")
|
||||
List<@NotBlank String> endpoints) {
|
||||
Map<String, EndpointAvailability> result = new HashMap<>();
|
||||
for (String endpoint : endpoints) {
|
||||
String trimmedEndpoint = endpoint.trim();
|
||||
result.put(
|
||||
trimmedEndpoint,
|
||||
endpointConfiguration.getEndpointAvailability(trimmedEndpoint));
|
||||
}
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,7 +369,8 @@ public class MetricsController {
|
||||
// Check if WAU service is available (only when security.enableLogin=false)
|
||||
if (wauService.isEmpty()) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body("WAU tracking is only available when security is disabled (no-login mode)");
|
||||
.body(
|
||||
"WAU tracking is only available when security is disabled (no-login mode)");
|
||||
}
|
||||
|
||||
WeeklyActiveUsersService service = wauService.get();
|
||||
|
||||
@@ -10,8 +10,8 @@ import org.springframework.stereotype.Service;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
/**
|
||||
* Service for tracking Weekly Active Users (WAU) in no-login mode.
|
||||
* Uses in-memory storage with automatic cleanup of old entries.
|
||||
* Service for tracking Weekly Active Users (WAU) in no-login mode. Uses in-memory storage with
|
||||
* automatic cleanup of old entries.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@@ -28,6 +28,7 @@ public class WeeklyActiveUsersService {
|
||||
|
||||
/**
|
||||
* Records a browser access with the current timestamp
|
||||
*
|
||||
* @param browserId Unique browser identifier from X-Browser-Id header
|
||||
*/
|
||||
public void recordBrowserAccess(String browserId) {
|
||||
@@ -46,6 +47,7 @@ public class WeeklyActiveUsersService {
|
||||
|
||||
/**
|
||||
* Gets the count of unique browsers seen in the last 7 days
|
||||
*
|
||||
* @return Weekly Active Users count
|
||||
*/
|
||||
public long getWeeklyActiveUsers() {
|
||||
@@ -55,6 +57,7 @@ public class WeeklyActiveUsersService {
|
||||
|
||||
/**
|
||||
* Gets the total count of unique browsers ever seen
|
||||
*
|
||||
* @return Total unique browsers count
|
||||
*/
|
||||
public long getTotalUniqueBrowsers() {
|
||||
@@ -63,6 +66,7 @@ public class WeeklyActiveUsersService {
|
||||
|
||||
/**
|
||||
* Gets the number of days the service has been running
|
||||
*
|
||||
* @return Days online
|
||||
*/
|
||||
public long getDaysOnline() {
|
||||
@@ -71,23 +75,20 @@ public class WeeklyActiveUsersService {
|
||||
|
||||
/**
|
||||
* Gets the timestamp when tracking started
|
||||
*
|
||||
* @return Start time
|
||||
*/
|
||||
public Instant getStartTime() {
|
||||
return startTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes entries older than 7 days
|
||||
*/
|
||||
/** Removes entries older than 7 days */
|
||||
private void cleanupOldEntries() {
|
||||
Instant sevenDaysAgo = Instant.now().minus(7, ChronoUnit.DAYS);
|
||||
activeBrowsers.entrySet().removeIf(entry -> entry.getValue().isBefore(sevenDaysAgo));
|
||||
}
|
||||
|
||||
/**
|
||||
* Manual cleanup trigger (can be called by scheduled task if needed)
|
||||
*/
|
||||
/** Manual cleanup trigger (can be called by scheduled task if needed) */
|
||||
public void performCleanup() {
|
||||
int sizeBefore = activeBrowsers.size();
|
||||
cleanupOldEntries();
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "Pre-publish-sanitization",
|
||||
"pipeline": [
|
||||
{
|
||||
"operation": "/api/v1/security/sanitize-pdf",
|
||||
"parameters": {
|
||||
"removeJavaScript": true,
|
||||
"removeEmbeddedFiles": true,
|
||||
"removeXMPMetadata": true,
|
||||
"removeMetadata": true,
|
||||
"removeLinks": true,
|
||||
"removeFonts": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"operation": "/api/v1/misc/flatten",
|
||||
"parameters": {
|
||||
"flattenOnlyForms": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"operation": "/api/v1/general/remove-annotations",
|
||||
"parameters": {}
|
||||
},
|
||||
{
|
||||
"operation": "/api/v1/misc/update-metadata",
|
||||
"parameters": {
|
||||
"deleteAll": true,
|
||||
"author": "",
|
||||
"creationDate": "",
|
||||
"creator": "",
|
||||
"keywords": "",
|
||||
"modificationDate": "",
|
||||
"producer": "",
|
||||
"subject": "",
|
||||
"title": "",
|
||||
"trapped": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"operation": "/api/v1/misc/compress-pdf",
|
||||
"parameters": {
|
||||
"optimizeLevel": 3,
|
||||
"expectedOutputSize": ""
|
||||
}
|
||||
}
|
||||
],
|
||||
"_examples": {
|
||||
"outputDir": "{outputFolder}/{folderName}",
|
||||
"outputFileName": "{filename}-{pipelineName}-{date}-{time}"
|
||||
},
|
||||
"outputDir": "{outputFolder}",
|
||||
"outputFileName": "pre_publish_{filename}.PDF"
|
||||
}
|
||||
+6
-1
@@ -113,7 +113,12 @@ public class LicenseKeyChecker {
|
||||
|
||||
public void updateLicenseKey(String newKey) throws IOException {
|
||||
applicationProperties.getPremium().setKey(newKey);
|
||||
GeneralUtils.saveKeyToSettings("EnterpriseEdition.key", newKey);
|
||||
GeneralUtils.saveKeyToSettings("premium.key", newKey);
|
||||
evaluateLicense();
|
||||
synchronizeLicenseSettings();
|
||||
}
|
||||
|
||||
public void resyncLicense() {
|
||||
evaluateLicense();
|
||||
synchronizeLicenseSettings();
|
||||
}
|
||||
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
package stirling.software.proprietary.security.controller.api;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier;
|
||||
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
|
||||
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
|
||||
|
||||
/**
|
||||
* Admin controller for license management. Provides installation ID for Stripe checkout metadata
|
||||
* and endpoints for managing license keys.
|
||||
*/
|
||||
@RestController
|
||||
@Slf4j
|
||||
@RequestMapping("/api/v1/admin")
|
||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||
@Tag(name = "Admin License Management", description = "Admin-only License Management APIs")
|
||||
public class AdminLicenseController {
|
||||
|
||||
@Autowired(required = false)
|
||||
private LicenseKeyChecker licenseKeyChecker;
|
||||
|
||||
@Autowired(required = false)
|
||||
private KeygenLicenseVerifier keygenLicenseVerifier;
|
||||
|
||||
@Autowired private ApplicationProperties applicationProperties;
|
||||
|
||||
/**
|
||||
* Get the installation ID (machine fingerprint) for this self-hosted instance. This ID is used
|
||||
* as metadata in Stripe checkout to link licenses to specific installations.
|
||||
*
|
||||
* @return Map containing the installation ID
|
||||
*/
|
||||
@GetMapping("/installation-id")
|
||||
@Operation(
|
||||
summary = "Get installation ID",
|
||||
description =
|
||||
"Returns the unique installation ID (MAC-based fingerprint) for this"
|
||||
+ " self-hosted instance")
|
||||
public ResponseEntity<Map<String, String>> getInstallationId() {
|
||||
try {
|
||||
String installationId = GeneralUtils.generateMachineFingerprint();
|
||||
log.info("Admin requested installation ID: {}", installationId);
|
||||
return ResponseEntity.ok(Map.of("installationId", installationId));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to generate installation ID", e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("error", "Failed to generate installation ID"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save and activate a license key. This endpoint accepts a license key from the frontend (e.g.,
|
||||
* after Stripe checkout) and activates it on the backend.
|
||||
*
|
||||
* @param request Map containing the license key
|
||||
* @return Response with success status, license type, and whether restart is required
|
||||
*/
|
||||
@PostMapping("/license-key")
|
||||
@Operation(
|
||||
summary = "Save and activate license key",
|
||||
description =
|
||||
"Accepts a license key and activates it on the backend. Returns the activated"
|
||||
+ " license type.")
|
||||
public ResponseEntity<Map<String, Object>> saveLicenseKey(
|
||||
@RequestBody Map<String, String> request) {
|
||||
String licenseKey = request.get("licenseKey");
|
||||
|
||||
// Reject null but allow empty string to clear license
|
||||
if (licenseKey == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "error", "License key is required"));
|
||||
}
|
||||
|
||||
try {
|
||||
if (licenseKeyChecker == null) {
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("success", false, "error", "License checker not available"));
|
||||
}
|
||||
// assume premium enabled when setting license key
|
||||
applicationProperties.getPremium().setEnabled(true);
|
||||
|
||||
// Use existing LicenseKeyChecker to update and validate license
|
||||
// Empty string will be evaluated as NORMAL license (free tier)
|
||||
licenseKeyChecker.updateLicenseKey(licenseKey.trim());
|
||||
|
||||
// Get current license status
|
||||
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
|
||||
|
||||
// Auto-enable premium features if license is valid
|
||||
if (license != License.NORMAL) {
|
||||
GeneralUtils.saveKeyToSettings("premium.enabled", true);
|
||||
// Enable premium features
|
||||
|
||||
// Save maxUsers from license metadata
|
||||
Integer maxUsers = applicationProperties.getPremium().getMaxUsers();
|
||||
if (maxUsers != null) {
|
||||
GeneralUtils.saveKeyToSettings("premium.maxUsers", maxUsers);
|
||||
}
|
||||
} else {
|
||||
GeneralUtils.saveKeyToSettings("premium.enabled", false);
|
||||
log.info("License key is not valid for premium features: type={}", license.name());
|
||||
}
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("licenseType", license.name());
|
||||
response.put("enabled", applicationProperties.getPremium().isEnabled());
|
||||
response.put("maxUsers", applicationProperties.getPremium().getMaxUsers());
|
||||
response.put("requiresRestart", false); // Dynamic evaluation works
|
||||
response.put("message", "License key saved and activated");
|
||||
|
||||
log.info("License key saved and activated: type={}", license.name());
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to save license key", e);
|
||||
return ResponseEntity.badRequest()
|
||||
.body(
|
||||
Map.of(
|
||||
"success",
|
||||
false,
|
||||
"error",
|
||||
"Failed to activate license: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resync the current license with Keygen. This endpoint re-validates the existing license key
|
||||
* and updates the max users setting. Used after subscription upgrades to sync the new license
|
||||
* limits.
|
||||
*
|
||||
* @return Response with updated license information
|
||||
*/
|
||||
@PostMapping("/license/resync")
|
||||
@Operation(
|
||||
summary = "Resync license with Keygen",
|
||||
description =
|
||||
"Re-validates the existing license key with Keygen and updates local settings."
|
||||
+ " Used after subscription upgrades.")
|
||||
public ResponseEntity<Map<String, Object>> resyncLicense() {
|
||||
try {
|
||||
if (licenseKeyChecker == null) {
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("success", false, "error", "License checker not available"));
|
||||
}
|
||||
|
||||
String currentKey = applicationProperties.getPremium().getKey();
|
||||
if (currentKey == null || currentKey.trim().isEmpty()) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "error", "No license key configured"));
|
||||
}
|
||||
|
||||
log.info("Resyncing license with Keygen");
|
||||
|
||||
// Re-validate license and sync settings
|
||||
licenseKeyChecker.resyncLicense();
|
||||
|
||||
// Get updated license status
|
||||
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
|
||||
ApplicationProperties.Premium premium = applicationProperties.getPremium();
|
||||
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("success", true);
|
||||
response.put("licenseType", license.name());
|
||||
response.put("enabled", premium.isEnabled());
|
||||
response.put("maxUsers", premium.getMaxUsers());
|
||||
response.put("message", "License resynced successfully");
|
||||
|
||||
log.info(
|
||||
"License resynced: type={}, maxUsers={}",
|
||||
license.name(),
|
||||
premium.getMaxUsers());
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to resync license", e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(
|
||||
Map.of(
|
||||
"success",
|
||||
false,
|
||||
"error",
|
||||
"Failed to resync license: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get information about the current license key status, including license type, enabled status,
|
||||
* and max users.
|
||||
*
|
||||
* @return Map containing license information
|
||||
*/
|
||||
@GetMapping("/license-info")
|
||||
@Operation(
|
||||
summary = "Get license information",
|
||||
description =
|
||||
"Returns information about the current license including type, enabled status,"
|
||||
+ " and max users")
|
||||
public ResponseEntity<Map<String, Object>> getLicenseInfo() {
|
||||
try {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
|
||||
if (licenseKeyChecker != null) {
|
||||
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
|
||||
response.put("licenseType", license.name());
|
||||
} else {
|
||||
response.put("licenseType", License.NORMAL.name());
|
||||
}
|
||||
|
||||
ApplicationProperties.Premium premium = applicationProperties.getPremium();
|
||||
response.put("enabled", premium.isEnabled());
|
||||
response.put("maxUsers", premium.getMaxUsers());
|
||||
response.put("hasKey", premium.getKey() != null && !premium.getKey().trim().isEmpty());
|
||||
|
||||
// Include license key for upgrades (admin-only endpoint)
|
||||
if (premium.getKey() != null && !premium.getKey().trim().isEmpty()) {
|
||||
response.put("licenseKey", premium.getKey());
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(response);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to get license info", e);
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("error", "Failed to retrieve license information"));
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
@@ -299,6 +299,16 @@ public class AdminSettingsController {
|
||||
+ String.join(", ", VALID_SECTION_NAMES));
|
||||
}
|
||||
|
||||
// Auto-enable premium features if license key is provided
|
||||
if ("premium".equalsIgnoreCase(sectionName) && sectionData.containsKey("key")) {
|
||||
Object keyValue = sectionData.get("key");
|
||||
if (keyValue != null && !keyValue.toString().trim().isEmpty()) {
|
||||
// Automatically set enabled to true when a key is provided
|
||||
sectionData.put("enabled", true);
|
||||
log.info("Auto-enabling premium features because license key was provided");
|
||||
}
|
||||
}
|
||||
|
||||
int updatedCount = 0;
|
||||
for (Map.Entry<String, Object> entry : sectionData.entrySet()) {
|
||||
String propertyKey = entry.getKey();
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
|
||||
public class JwtService implements JwtServiceInterface {
|
||||
|
||||
private static final String ISSUER = "https://stirling.com";
|
||||
private static final long EXPIRATION = 3600000;
|
||||
private static final long EXPIRATION = 43200000;
|
||||
|
||||
private final KeyPersistenceServiceInterface keyPersistenceService;
|
||||
private final boolean v2Enabled;
|
||||
|
||||
Generated
+150
-3
@@ -39,6 +39,9 @@
|
||||
"@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",
|
||||
@@ -3121,6 +3124,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",
|
||||
@@ -4203,7 +4338,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"
|
||||
@@ -4215,6 +4349,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",
|
||||
@@ -4249,6 +4389,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",
|
||||
@@ -13962,7 +14111,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": {
|
||||
@@ -14833,7 +14981,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"
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
"@mantine/dates": "^8.3.1",
|
||||
"@mantine/dropzone": "^8.3.1",
|
||||
"@mantine/hooks": "^8.3.1",
|
||||
"@stripe/react-stripe-js": "^4.0.2",
|
||||
"@stripe/stripe-js": "^7.9.0",
|
||||
"@supabase/supabase-js": "^2.47.13",
|
||||
"@mui/icons-material": "^7.3.2",
|
||||
"@mui/material": "^7.3.2",
|
||||
"@reactour/tour": "^3.8.0",
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"comingSoon": "Coming soon:",
|
||||
"favorite": "Add to favourites",
|
||||
"favorites": "Favourites",
|
||||
"unavailable": "Disabled by server administrator:",
|
||||
"unavailableDependency": "Unavailable - required tool missing on server:",
|
||||
"heading": "All tools (fullscreen view)",
|
||||
"noResults": "Try adjusting your search or toggle descriptions to find what you need.",
|
||||
"recommended": "Recommended",
|
||||
@@ -918,6 +920,11 @@
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while merging the PDFs."
|
||||
},
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "Merge Settings Overview"
|
||||
}
|
||||
}
|
||||
},
|
||||
"split": {
|
||||
@@ -2372,6 +2379,14 @@
|
||||
"title": "About Remove Annotations",
|
||||
"description": "This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents."
|
||||
},
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "About Remove Annotations"
|
||||
},
|
||||
"description": {
|
||||
"title": "What it does"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"failed": "An error occurred while removing annotations from the PDF."
|
||||
}
|
||||
@@ -2415,7 +2430,7 @@
|
||||
},
|
||||
"cta": "Compare",
|
||||
"loading": "Comparing...",
|
||||
|
||||
|
||||
"summary": {
|
||||
"baseHeading": "Original document",
|
||||
"comparisonHeading": "Edited document",
|
||||
@@ -2471,7 +2486,7 @@
|
||||
"body": "This comparison is taking longer than usual. You can let it continue or cancel it.",
|
||||
"cancel": "Cancel comparison"
|
||||
},
|
||||
|
||||
|
||||
"newLine": "new-line",
|
||||
"complex": {
|
||||
"message": "One or both of the provided documents are large files, accuracy of comparison may be reduced"
|
||||
@@ -2834,6 +2849,9 @@
|
||||
"header": {
|
||||
"title": "How Auto-Rename Works"
|
||||
},
|
||||
"description": {
|
||||
"title": "What it does"
|
||||
},
|
||||
"howItWorks": {
|
||||
"title": "Smart Renaming",
|
||||
"text": "Automatically finds the title from your PDF content and uses it as the filename.",
|
||||
@@ -2841,6 +2859,9 @@
|
||||
"bullet2": "Creates a clean, valid filename from the detected title",
|
||||
"bullet3": "Keeps the original name if no suitable title is found"
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"title": "About"
|
||||
}
|
||||
},
|
||||
"adjust-contrast": {
|
||||
@@ -4419,9 +4440,21 @@
|
||||
"title": "Premium & Enterprise",
|
||||
"description": "Configure your premium or enterprise license key.",
|
||||
"license": "License Configuration",
|
||||
"licenseKey": {
|
||||
"toggle": "Got a license key or certificate file?",
|
||||
"info": "If you have a license key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features."
|
||||
},
|
||||
"key": {
|
||||
"label": "License Key",
|
||||
"description": "Enter your premium or enterprise license key"
|
||||
"description": "Enter your premium or enterprise license key. Premium features will be automatically enabled when a key is provided.",
|
||||
"success": "License Key Saved",
|
||||
"successMessage": "Your license key has been activated successfully. No restart required.",
|
||||
"overwriteWarning": {
|
||||
"title": "⚠️ Warning: Existing License Detected",
|
||||
"line1": "Overwriting your current license key cannot be undone.",
|
||||
"line2": "Your previous license will be permanently lost unless you have backed it up elsewhere.",
|
||||
"line3": "Important: Keep license keys private and secure. Never share them publicly."
|
||||
}
|
||||
},
|
||||
"enabled": {
|
||||
"label": "Enable Premium Features",
|
||||
@@ -4828,7 +4861,9 @@
|
||||
"secureWorkflow": "Security Workflow",
|
||||
"secureWorkflowDesc": "Secures PDF documents by removing potentially malicious content like JavaScript and embedded files, then adds password protection to prevent unauthorised access. Password is set to 'password' by default.",
|
||||
"processImages": "Process Images",
|
||||
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images."
|
||||
"processImagesDesc": "Converts multiple image files into a single PDF document, then applies OCR technology to extract searchable text from the images.",
|
||||
"prePublishSanitization": "Pre-publish Sanitization",
|
||||
"prePublishSanitizationDesc": "Sanitization workflow that removes all hidden metadata, JavaScript, embedded files, annotations, and flattens forms to prevent data leakage before publishing PDFs online."
|
||||
}
|
||||
},
|
||||
"colorPicker": {
|
||||
@@ -4850,7 +4885,11 @@
|
||||
"available": "available",
|
||||
"cancel": "Cancel",
|
||||
"preview": "Preview",
|
||||
"done": "Done"
|
||||
"close": "Close",
|
||||
"done": "Done",
|
||||
"loading": "Loading...",
|
||||
"back": "Back",
|
||||
"continue": "Continue"
|
||||
},
|
||||
"config": {
|
||||
"overview": {
|
||||
@@ -4916,6 +4955,14 @@
|
||||
"addMoreFiles": "Add more files...",
|
||||
"selectedFiles": "Selected Files",
|
||||
"submit": "Add Attachments",
|
||||
"tooltip": {
|
||||
"header": {
|
||||
"title": "About Add Attachments"
|
||||
},
|
||||
"description": {
|
||||
"title": "What it does"
|
||||
}
|
||||
},
|
||||
"results": {
|
||||
"title": "Attachment Results"
|
||||
},
|
||||
@@ -5268,6 +5315,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"
|
||||
@@ -5285,13 +5340,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",
|
||||
@@ -5333,13 +5391,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",
|
||||
@@ -5499,6 +5588,24 @@
|
||||
"starting": "Backend starting up...",
|
||||
"wait": "Please wait for the backend to finish launching and try again."
|
||||
},
|
||||
"encryptedPdfUnlock": {
|
||||
"unlockPrompt": "Unlock PDF to continue",
|
||||
"title": "Remove password to continue",
|
||||
"description": "This PDF is password protected. Enter the password so you can continue working with it.",
|
||||
"password": {
|
||||
"label": "PDF password",
|
||||
"placeholder": "Enter the PDF password"
|
||||
},
|
||||
"skip": "Skip for now",
|
||||
"unlock": "Unlock & Continue",
|
||||
"incorrectPassword": "Incorrect password",
|
||||
"missingFile": "The selected file is no longer available.",
|
||||
"emptyResponse": "Password removal did not produce a file.",
|
||||
"required": "Enter the password to continue.",
|
||||
"successTitle": "Password removed",
|
||||
"successBodyWithName": "Password removed from {{fileName}}",
|
||||
"successBody": "Password removed successfully."
|
||||
},
|
||||
"setup": {
|
||||
"welcome": "Welcome to Stirling PDF",
|
||||
"description": "Get started by choosing how you want to use Stirling PDF",
|
||||
@@ -5615,7 +5722,11 @@
|
||||
"latestVersion": "Latest Version",
|
||||
"checkForUpdates": "Check for Updates",
|
||||
"viewDetails": "View Details"
|
||||
}
|
||||
},
|
||||
"hideUnavailableTools": "Hide unavailable tools",
|
||||
"hideUnavailableToolsDescription": "Remove tools that have been disabled by your server instead of showing them greyed out.",
|
||||
"hideUnavailableConversions": "Hide unavailable conversions",
|
||||
"hideUnavailableConversionsDescription": "Remove disabled conversion options in the Convert tool instead of showing them greyed out."
|
||||
},
|
||||
"hotkeys": {
|
||||
"errorConflict": "Shortcut already used by {{tool}}.",
|
||||
@@ -5635,21 +5746,5 @@
|
||||
"pleaseLoginAgain": "Please login again.",
|
||||
"accessDenied": "Access Denied",
|
||||
"insufficientPermissions": "You do not have permission to perform this action."
|
||||
},
|
||||
"common": {
|
||||
"loading": "Loading...",
|
||||
"back": "Back",
|
||||
"continue": "Continue",
|
||||
"preview": "Preview",
|
||||
"previous": "Previous",
|
||||
"next": "Next",
|
||||
"copied": "Copied!",
|
||||
"copy": "Copy",
|
||||
"expand": "Expand",
|
||||
"collapse": "Collapse",
|
||||
"retry": "Retry",
|
||||
"refresh": "Refresh",
|
||||
"cancel": "Cancel",
|
||||
"done": "Done"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,11 @@
|
||||
"desktopTemplate": "stirling-pdf.desktop"
|
||||
}
|
||||
},
|
||||
"windows": {
|
||||
"certificateThumbprint": null,
|
||||
"digestAlgorithm": "sha256",
|
||||
"timestampUrl": "http://timestamp.digicert.com"
|
||||
},
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15",
|
||||
"signingIdentity": null,
|
||||
|
||||
@@ -9,6 +9,7 @@ import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import UnarchiveIcon from '@mui/icons-material/Unarchive';
|
||||
import PushPinIcon from '@mui/icons-material/PushPin';
|
||||
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
||||
import LockOpenIcon from '@mui/icons-material/LockOpen';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import { StirlingFileStub } from '@app/types/fileContext';
|
||||
@@ -56,7 +57,14 @@ const FileEditorThumbnail = ({
|
||||
isSupported = true,
|
||||
}: FileEditorThumbnailProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions } = useFileContext();
|
||||
const {
|
||||
pinFile,
|
||||
unpinFile,
|
||||
isFilePinned,
|
||||
activeFiles,
|
||||
actions: fileActions,
|
||||
openEncryptedUnlockPrompt,
|
||||
} = useFileContext();
|
||||
const { state } = useFileState();
|
||||
const hasError = state.ui.errorFileIds.includes(file.id);
|
||||
|
||||
@@ -77,6 +85,7 @@ const FileEditorThumbnail = ({
|
||||
const isZipFile = zipFileService.isZipFileStub(file);
|
||||
|
||||
const pageCount = file.processedFile?.totalPages || 0;
|
||||
const isEncrypted = Boolean(file.processedFile?.isEncrypted);
|
||||
|
||||
const handleRef = useRef<HTMLSpanElement | null>(null);
|
||||
|
||||
@@ -301,6 +310,21 @@ const FileEditorThumbnail = ({
|
||||
|
||||
{/* Action buttons group */}
|
||||
<div className={styles.headerActions}>
|
||||
{isEncrypted && (
|
||||
<Tooltip label={t('encryptedPdfUnlock.unlockPrompt', 'Unlock PDF to continue')}>
|
||||
<ActionIcon
|
||||
aria-label={t('encryptedPdfUnlock.unlockPrompt', 'Unlock PDF to continue')}
|
||||
variant="subtle"
|
||||
className={styles.headerIconButton}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openEncryptedUnlockPrompt(file.id);
|
||||
}}
|
||||
>
|
||||
<LockOpenIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Pin/Unpin icon */}
|
||||
<Tooltip label={isPinned ? t('unpin', 'Unpin File (replace after tool run)') : t('pin', 'Pin File (keep active after tool run)')}>
|
||||
<ActionIcon
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Modal, Stack, Text, Button, PasswordInput, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { type KeyboardEventHandler } from 'react';
|
||||
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
|
||||
|
||||
interface EncryptedPdfUnlockModalProps {
|
||||
opened: boolean;
|
||||
fileName?: string;
|
||||
password: string;
|
||||
errorMessage?: string | null;
|
||||
isProcessing: boolean;
|
||||
onPasswordChange: (value: string) => void;
|
||||
onUnlock: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
const EncryptedPdfUnlockModal = ({
|
||||
opened,
|
||||
fileName,
|
||||
password,
|
||||
errorMessage,
|
||||
isProcessing,
|
||||
onPasswordChange,
|
||||
onUnlock,
|
||||
onSkip,
|
||||
}: EncryptedPdfUnlockModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (event) => {
|
||||
if (event.key === 'Enter' && !isProcessing && password.trim().length > 0) {
|
||||
onUnlock();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onSkip}
|
||||
title={t('encryptedPdfUnlock.title', 'Remove password to continue')}
|
||||
centered
|
||||
size="md"
|
||||
closeOnClickOutside={!isProcessing}
|
||||
closeOnEscape={!isProcessing}
|
||||
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text fw={600} ta="center">{fileName}</Text>
|
||||
<Text c="dimmed" ta="center">
|
||||
{t(
|
||||
'encryptedPdfUnlock.description',
|
||||
'This PDF is password protected. Enter the password so you can continue working with it.'
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Stack gap={4}>
|
||||
<PasswordInput
|
||||
label={t('encryptedPdfUnlock.password.label', 'PDF password')}
|
||||
placeholder={t('encryptedPdfUnlock.password.placeholder', 'Enter the PDF password')}
|
||||
value={password}
|
||||
onChange={(event) => onPasswordChange(event.currentTarget.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
disabled={isProcessing}
|
||||
autoFocus
|
||||
/>
|
||||
{errorMessage ? (
|
||||
<Text c="red" size="sm">
|
||||
{errorMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
|
||||
<Group justify="space-between">
|
||||
<Button variant="light" color="var(--mantine-color-gray-8)" onClick={onSkip} disabled={isProcessing}>
|
||||
{t('encryptedPdfUnlock.skip', 'Skip for now')}
|
||||
</Button>
|
||||
<Button onClick={onUnlock} loading={isProcessing} disabled={password.trim().length === 0}>
|
||||
{t('encryptedPdfUnlock.unlock', 'Unlock & Continue')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EncryptedPdfUnlockModal;
|
||||
@@ -53,11 +53,17 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
|
||||
};
|
||||
|
||||
const summary = await updateService.getUpdateSummary(config.appVersion, machineInfo);
|
||||
if (summary) {
|
||||
if (summary && summary.latest_version) {
|
||||
const isNewerVersion = updateService.compareVersions(summary.latest_version, config.appVersion) > 0;
|
||||
if (isNewerVersion) {
|
||||
setUpdateSummary(summary);
|
||||
} else {
|
||||
// Clear any existing update summary if user is on latest version
|
||||
setUpdateSummary(null);
|
||||
}
|
||||
} else {
|
||||
// No update available (latest_version is null) - clear any existing update summary
|
||||
setUpdateSummary(null);
|
||||
}
|
||||
setCheckingUpdate(false);
|
||||
};
|
||||
@@ -128,83 +134,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t('settings.general.defaultToolPickerMode', 'Default tool picker mode')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('settings.general.defaultToolPickerModeDescription', 'Choose whether the tool picker opens in fullscreen or sidebar by default')}
|
||||
</Text>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
value={preferences.defaultToolPanelMode}
|
||||
onChange={(val: string) => updatePreference('defaultToolPanelMode', val as ToolPanelMode)}
|
||||
data={[
|
||||
{ label: t('settings.general.mode.sidebar', 'Sidebar'), value: 'sidebar' },
|
||||
{ label: t('settings.general.mode.fullscreen', 'Fullscreen'), value: 'fullscreen' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<Tooltip
|
||||
label={t('settings.general.autoUnzipTooltip', 'Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.')}
|
||||
multiline
|
||||
w={300}
|
||||
withArrow
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t('settings.general.autoUnzip', 'Auto-unzip API responses')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('settings.general.autoUnzipDescription', 'Automatically extract files from ZIP responses')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={preferences.autoUnzip}
|
||||
onChange={(event) => updatePreference('autoUnzip', event.currentTarget.checked)}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
label={t('settings.general.autoUnzipFileLimitTooltip', 'Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.')}
|
||||
multiline
|
||||
w={300}
|
||||
withArrow
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t('settings.general.autoUnzipFileLimit', 'Auto-unzip file limit')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('settings.general.autoUnzipFileLimitDescription', 'Maximum number of files to extract from ZIP')}
|
||||
</Text>
|
||||
</div>
|
||||
<NumberInput
|
||||
value={fileLimitInput}
|
||||
onChange={setFileLimitInput}
|
||||
onBlur={() => {
|
||||
const numValue = Number(fileLimitInput);
|
||||
const finalValue = (!fileLimitInput || isNaN(numValue) || numValue < 1 || numValue > 100) ? DEFAULT_AUTO_UNZIP_FILE_LIMIT : numValue;
|
||||
setFileLimitInput(finalValue);
|
||||
updatePreference('autoUnzipFileLimit', finalValue);
|
||||
}}
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
disabled={!preferences.autoUnzip}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Update Check Section */}
|
||||
{config?.appVersion && (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
@@ -292,6 +221,111 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t('settings.general.defaultToolPickerMode', 'Default tool picker mode')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('settings.general.defaultToolPickerModeDescription', 'Choose whether the tool picker opens in fullscreen or sidebar by default')}
|
||||
</Text>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
value={preferences.defaultToolPanelMode}
|
||||
onChange={(val: string) => updatePreference('defaultToolPanelMode', val as ToolPanelMode)}
|
||||
data={[
|
||||
{ label: t('settings.general.mode.sidebar', 'Sidebar'), value: 'sidebar' },
|
||||
{ label: t('settings.general.mode.fullscreen', 'Fullscreen'), value: 'fullscreen' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t('settings.general.hideUnavailableTools', 'Hide unavailable tools')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('settings.general.hideUnavailableToolsDescription', 'Remove tools that have been disabled by your server instead of showing them greyed out.')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={preferences.hideUnavailableTools}
|
||||
onChange={(event) => updatePreference('hideUnavailableTools', event.currentTarget.checked)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t('settings.general.hideUnavailableConversions', 'Hide unavailable conversions')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('settings.general.hideUnavailableConversionsDescription', 'Remove disabled conversion options in the Convert tool instead of showing them greyed out.')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={preferences.hideUnavailableConversions}
|
||||
onChange={(event) => updatePreference('hideUnavailableConversions', event.currentTarget.checked)}
|
||||
/>
|
||||
</div>
|
||||
<Tooltip
|
||||
label={t('settings.general.autoUnzipTooltip', 'Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.')}
|
||||
multiline
|
||||
w={300}
|
||||
withArrow
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t('settings.general.autoUnzip', 'Auto-unzip API responses')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('settings.general.autoUnzipDescription', 'Automatically extract files from ZIP responses')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={preferences.autoUnzip}
|
||||
onChange={(event) => updatePreference('autoUnzip', event.currentTarget.checked)}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
label={t('settings.general.autoUnzipFileLimitTooltip', 'Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.')}
|
||||
multiline
|
||||
w={300}
|
||||
withArrow
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t('settings.general.autoUnzipFileLimit', 'Auto-unzip file limit')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('settings.general.autoUnzipFileLimitDescription', 'Maximum number of files to extract from ZIP')}
|
||||
</Text>
|
||||
</div>
|
||||
<NumberInput
|
||||
value={fileLimitInput}
|
||||
onChange={setFileLimitInput}
|
||||
onBlur={() => {
|
||||
const numValue = Number(fileLimitInput);
|
||||
const finalValue = (!fileLimitInput || isNaN(numValue) || numValue < 1 || numValue > 100) ? DEFAULT_AUTO_UNZIP_FILE_LIMIT : numValue;
|
||||
setFileLimitInput(finalValue);
|
||||
updatePreference('autoUnzipFileLimit', finalValue);
|
||||
}}
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
disabled={!preferences.autoUnzip}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Update Modal */}
|
||||
{updateSummary && config?.appVersion && config?.machineType && (
|
||||
<UpdateModal
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Allows selecting files to attach to PDFs.
|
||||
*/
|
||||
|
||||
import { Stack, Text, Group, ActionIcon, Alert, ScrollArea, Button } from "@mantine/core";
|
||||
import { Stack, Text, Group, ActionIcon, ScrollArea, Button } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AddAttachmentsParameters } from "@app/hooks/tools/addAttachments/useAddAttachmentsParameters";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
@@ -20,16 +20,7 @@ const AddAttachmentsSettings = ({ parameters, onParameterChange, disabled = fals
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Alert color="blue" variant="light">
|
||||
<Text size="sm">
|
||||
{t("AddAttachmentsRequest.info", "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel.")}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={500}>
|
||||
{t("AddAttachmentsRequest.selectFiles", "Select Files to Attach")}
|
||||
</Text>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getConversionEndpoints } from "@app/data/toolsTaxonomy";
|
||||
import { useFileSelection } from "@app/contexts/FileContext";
|
||||
import { useFileState } from "@app/contexts/FileContext";
|
||||
import { detectFileExtension } from "@app/utils/fileUtils";
|
||||
import { usePreferences } from "@app/contexts/PreferencesContext";
|
||||
import GroupedFormatDropdown from "@app/components/tools/convert/GroupedFormatDropdown";
|
||||
import ConvertToImageSettings from "@app/components/tools/convert/ConvertToImageSettings";
|
||||
import ConvertFromImageSettings from "@app/components/tools/convert/ConvertFromImageSettings";
|
||||
@@ -47,8 +48,12 @@ const ConvertSettings = ({
|
||||
const { setSelectedFiles } = useFileSelection();
|
||||
const { state, selectors } = useFileState();
|
||||
const activeFiles = state.files.ids;
|
||||
const { preferences } = usePreferences();
|
||||
|
||||
const allEndpoints = useMemo(() => getConversionEndpoints(EXTENSION_TO_ENDPOINT), []);
|
||||
const allEndpoints = useMemo(() => {
|
||||
const endpoints = getConversionEndpoints(EXTENSION_TO_ENDPOINT);
|
||||
return endpoints;
|
||||
}, []);
|
||||
|
||||
const { endpointStatus } = useMultipleEndpointsEnabled(allEndpoints);
|
||||
|
||||
@@ -56,7 +61,8 @@ const ConvertSettings = ({
|
||||
const endpointKey = EXTENSION_TO_ENDPOINT[fromExt]?.[toExt];
|
||||
if (!endpointKey) return false;
|
||||
|
||||
return endpointStatus[endpointKey] === true;
|
||||
const isAvailable = endpointStatus[endpointKey] === true;
|
||||
return isAvailable;
|
||||
};
|
||||
|
||||
// Enhanced FROM options with endpoint availability
|
||||
@@ -74,6 +80,12 @@ const ConvertSettings = ({
|
||||
};
|
||||
});
|
||||
|
||||
// Filter out unavailable source formats if preference is enabled
|
||||
let filteredOptions = baseOptions;
|
||||
if (preferences.hideUnavailableConversions) {
|
||||
filteredOptions = baseOptions.filter(opt => opt.enabled !== false);
|
||||
}
|
||||
|
||||
// Add dynamic format option if current selection is a file-<extension> format
|
||||
if (parameters.fromExtension && parameters.fromExtension.startsWith('file-')) {
|
||||
const extension = parameters.fromExtension.replace('file-', '');
|
||||
@@ -85,22 +97,32 @@ const ConvertSettings = ({
|
||||
};
|
||||
|
||||
// Add the dynamic option at the beginning
|
||||
return [dynamicOption, ...baseOptions];
|
||||
return [dynamicOption, ...filteredOptions];
|
||||
}
|
||||
|
||||
return baseOptions;
|
||||
}, [parameters.fromExtension, endpointStatus]);
|
||||
return filteredOptions;
|
||||
}, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions]);
|
||||
|
||||
// Enhanced TO options with endpoint availability
|
||||
const enhancedToOptions = useMemo(() => {
|
||||
if (!parameters.fromExtension) return [];
|
||||
|
||||
const availableOptions = getAvailableToExtensions(parameters.fromExtension) || [];
|
||||
return availableOptions.map(option => ({
|
||||
...option,
|
||||
enabled: isConversionAvailable(parameters.fromExtension, option.value)
|
||||
}));
|
||||
}, [parameters.fromExtension, endpointStatus]);
|
||||
const enhanced = availableOptions.map(option => {
|
||||
const enabled = isConversionAvailable(parameters.fromExtension, option.value);
|
||||
return {
|
||||
...option,
|
||||
enabled
|
||||
};
|
||||
});
|
||||
|
||||
// Filter out unavailable conversions if preference is enabled
|
||||
if (preferences.hideUnavailableConversions) {
|
||||
return enhanced.filter(opt => opt.enabled !== false);
|
||||
}
|
||||
|
||||
return enhanced;
|
||||
}, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions]);
|
||||
|
||||
const resetParametersToDefaults = () => {
|
||||
onParameterChange('imageOptions', {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import HotkeyDisplay from '@app/components/hotkeys/HotkeyDisplay';
|
||||
import FavoriteStar from '@app/components/tools/toolPicker/FavoriteStar';
|
||||
import { ToolRegistryEntry, getSubcategoryColor } from '@app/data/toolsTaxonomy';
|
||||
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta } from '@app/components/tools/fullscreen/shared';
|
||||
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta, getDisabledLabel } from '@app/components/tools/fullscreen/shared';
|
||||
|
||||
interface CompactToolItemProps {
|
||||
id: string;
|
||||
@@ -17,7 +17,7 @@ interface CompactToolItemProps {
|
||||
|
||||
const CompactToolItem: React.FC<CompactToolItemProps> = ({ id, tool, isSelected, onClick, tooltipPortalTarget }) => {
|
||||
const { t } = useTranslation();
|
||||
const { binding, isFav, toggleFavorite, disabled } = useToolMeta(id, tool);
|
||||
const { binding, isFav, toggleFavorite, disabled, disabledReason } = useToolMeta(id, tool);
|
||||
const categoryColor = getSubcategoryColor(tool.subcategoryId);
|
||||
const iconBg = getIconBackground(categoryColor, false);
|
||||
const iconClasses = 'tool-panel__fullscreen-list-icon';
|
||||
@@ -73,9 +73,12 @@ const CompactToolItem: React.FC<CompactToolItemProps> = ({ id, tool, isSelected,
|
||||
</button>
|
||||
);
|
||||
|
||||
const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason);
|
||||
const disabledMessage = t(disabledKey, disabledFallback);
|
||||
|
||||
const tooltipContent = disabled
|
||||
? (
|
||||
<span><strong>{t('toolPanel.fullscreen.comingSoon', 'Coming soon:')}</strong> {tool.description}</span>
|
||||
<span><strong>{disabledMessage}</strong> {tool.description}</span>
|
||||
)
|
||||
: (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import HotkeyDisplay from '@app/components/hotkeys/HotkeyDisplay';
|
||||
import FavoriteStar from '@app/components/tools/toolPicker/FavoriteStar';
|
||||
import { ToolRegistryEntry, getSubcategoryColor } from '@app/data/toolsTaxonomy';
|
||||
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta } from '@app/components/tools/fullscreen/shared';
|
||||
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta, getDisabledLabel } from '@app/components/tools/fullscreen/shared';
|
||||
|
||||
interface DetailedToolItemProps {
|
||||
id: string;
|
||||
@@ -15,7 +15,7 @@ interface DetailedToolItemProps {
|
||||
|
||||
const DetailedToolItem: React.FC<DetailedToolItemProps> = ({ id, tool, isSelected, onClick }) => {
|
||||
const { t } = useTranslation();
|
||||
const { binding, isFav, toggleFavorite, disabled } = useToolMeta(id, tool);
|
||||
const { binding, isFav, toggleFavorite, disabled, disabledReason } = useToolMeta(id, tool);
|
||||
|
||||
const categoryColor = getSubcategoryColor(tool.subcategoryId);
|
||||
const iconBg = getIconBackground(categoryColor, true);
|
||||
@@ -34,6 +34,9 @@ const DetailedToolItem: React.FC<DetailedToolItemProps> = ({ id, tool, isSelecte
|
||||
iconNode = tool.icon;
|
||||
}
|
||||
|
||||
const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason);
|
||||
const disabledMessage = t(disabledKey, disabledFallback);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -60,7 +63,12 @@ const DetailedToolItem: React.FC<DetailedToolItemProps> = ({ id, tool, isSelecte
|
||||
{tool.name}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" className="tool-panel__fullscreen-description">
|
||||
{tool.description}
|
||||
{disabled ? (
|
||||
<>
|
||||
<strong>{disabledMessage} </strong>
|
||||
{tool.description}
|
||||
</>
|
||||
) : tool.description}
|
||||
</Text>
|
||||
{binding && (
|
||||
<div className="tool-panel__fullscreen-shortcut">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useHotkeys } from '@app/contexts/HotkeyContext';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { ToolRegistryEntry } from '@app/data/toolsTaxonomy';
|
||||
import { ToolId } from '@app/types/toolId';
|
||||
import type { ToolAvailabilityMap } from '@app/hooks/useToolManagement';
|
||||
|
||||
export const getItemClasses = (isDetailed: boolean): string => {
|
||||
return isDetailed ? 'tool-panel__fullscreen-item--detailed' : '';
|
||||
@@ -22,23 +23,67 @@ export const getIconStyle = (): Record<string, string> => {
|
||||
return {};
|
||||
};
|
||||
|
||||
export const isToolDisabled = (id: string, tool: ToolRegistryEntry): boolean => {
|
||||
return !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
|
||||
export type ToolDisabledReason = 'comingSoon' | 'disabledByAdmin' | 'missingDependency' | 'unknownUnavailable' | null;
|
||||
|
||||
export const getToolDisabledReason = (
|
||||
id: string,
|
||||
tool: ToolRegistryEntry,
|
||||
toolAvailability?: ToolAvailabilityMap
|
||||
): ToolDisabledReason => {
|
||||
if (!tool.component && !tool.link && id !== 'read' && id !== 'multiTool') {
|
||||
return 'comingSoon';
|
||||
}
|
||||
|
||||
const availabilityInfo = toolAvailability?.[id as ToolId];
|
||||
if (availabilityInfo && availabilityInfo.available === false) {
|
||||
if (availabilityInfo.reason === 'missingDependency') {
|
||||
return 'missingDependency';
|
||||
}
|
||||
if (availabilityInfo.reason === 'disabledByAdmin') {
|
||||
return 'disabledByAdmin';
|
||||
}
|
||||
return 'unknownUnavailable';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getDisabledLabel = (
|
||||
disabledReason: ToolDisabledReason
|
||||
): { key: string; fallback: string } => {
|
||||
if (disabledReason === 'missingDependency') {
|
||||
return {
|
||||
key: 'toolPanel.fullscreen.unavailableDependency',
|
||||
fallback: 'Unavailable - required tool missing on server:'
|
||||
};
|
||||
}
|
||||
if (disabledReason === 'disabledByAdmin' || disabledReason === 'unknownUnavailable') {
|
||||
return {
|
||||
key: 'toolPanel.fullscreen.unavailable',
|
||||
fallback: 'Disabled by server administrator:'
|
||||
};
|
||||
}
|
||||
return {
|
||||
key: 'toolPanel.fullscreen.comingSoon',
|
||||
fallback: 'Coming soon:'
|
||||
};
|
||||
};
|
||||
|
||||
export function useToolMeta(id: string, tool: ToolRegistryEntry) {
|
||||
const { hotkeys } = useHotkeys();
|
||||
const { isFavorite, toggleFavorite } = useToolWorkflow();
|
||||
const { isFavorite, toggleFavorite, toolAvailability } = useToolWorkflow();
|
||||
|
||||
const isFav = isFavorite(id as ToolId);
|
||||
const binding = hotkeys[id as ToolId];
|
||||
const disabled = isToolDisabled(id, tool);
|
||||
const disabledReason = getToolDisabledReason(id, tool, toolAvailability);
|
||||
const disabled = disabledReason !== null;
|
||||
|
||||
return {
|
||||
binding,
|
||||
isFav,
|
||||
toggleFavorite: () => toggleFavorite(id as ToolId),
|
||||
disabled,
|
||||
disabledReason,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,9 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Stack, Text, Alert } from '@mantine/core';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { Stack } from '@mantine/core';
|
||||
|
||||
const RemoveAnnotationsSettings = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
icon={<LocalIcon icon="info-rounded" width="1.2rem" height="1.2rem" />}
|
||||
title={t('removeAnnotations.info.title', 'About Remove Annotations')}
|
||||
color="blue"
|
||||
variant="light"
|
||||
>
|
||||
<Text size="sm">
|
||||
{t('removeAnnotations.info.description',
|
||||
'This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents.'
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
{/* No settings needed for this tool - description is in tooltip */}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import HotkeyDisplay from "@app/components/hotkeys/HotkeyDisplay";
|
||||
import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import { getToolDisabledReason, getDisabledLabel } from "@app/components/tools/fullscreen/shared";
|
||||
|
||||
interface ToolButtonProps {
|
||||
id: ToolId;
|
||||
@@ -26,12 +27,12 @@ interface ToolButtonProps {
|
||||
|
||||
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym, hasStars = false }) => {
|
||||
const { t } = useTranslation();
|
||||
// Special case: read and multiTool are navigational tools that are always available
|
||||
const isUnavailable = !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
|
||||
const { isFavorite, toggleFavorite, toolAvailability } = useToolWorkflow();
|
||||
const disabledReason = getToolDisabledReason(id, tool, toolAvailability);
|
||||
const isUnavailable = disabledReason !== null;
|
||||
const { hotkeys } = useHotkeys();
|
||||
const binding = hotkeys[id];
|
||||
const { getToolNavigation } = useToolNavigation();
|
||||
const { isFavorite, toggleFavorite } = useToolWorkflow();
|
||||
const fav = isFavorite(id as ToolId);
|
||||
|
||||
const handleClick = (id: ToolId) => {
|
||||
@@ -48,8 +49,11 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
// Get navigation props for URL support (only if navigation is not disabled)
|
||||
const navProps = !isUnavailable && !tool.link && !disableNavigation ? getToolNavigation(id, tool) : null;
|
||||
|
||||
const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason);
|
||||
const disabledMessage = t(disabledKey, disabledFallback);
|
||||
|
||||
const tooltipContent = isUnavailable
|
||||
? (<span><strong>Coming soon:</strong> {tool.description}</span>)
|
||||
? (<span><strong>{disabledMessage}</strong> {tool.description}</span>)
|
||||
: (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
<span>{tool.description}</span>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '@app/types/tips';
|
||||
|
||||
export const useAddAttachmentsTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("AddAttachmentsRequest.tooltip.header.title", "About Add Attachments")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("AddAttachmentsRequest.tooltip.description.title", "What it does"),
|
||||
description: t("AddAttachmentsRequest.info", "Select files to attach to your PDF. These files will be embedded and accessible through the PDF's attachment panel."),
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
@@ -9,6 +9,10 @@ export const useAutoRenameTips = (): TooltipContent => {
|
||||
title: t("auto-rename.tooltip.header.title", "How Auto-Rename Works")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("auto-rename.tooltip.description.title", "What it does"),
|
||||
description: t("auto-rename.description", "Automatically finds the title from your PDF content and uses it as the filename."),
|
||||
},
|
||||
{
|
||||
title: t("auto-rename.tooltip.howItWorks.title", "Smart Renaming"),
|
||||
bullets: [
|
||||
|
||||
@@ -5,6 +5,9 @@ export const useMergeTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t('merge.tooltip.header.title', 'Merge Settings Overview')
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t('merge.removeDigitalSignature.tooltip.title', 'Remove Digital Signature'),
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '@app/types/tips';
|
||||
|
||||
export const useRemoveAnnotationsTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("removeAnnotations.tooltip.header.title", "About Remove Annotations")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("removeAnnotations.tooltip.description.title", "What it does"),
|
||||
description: t('removeAnnotations.info.description',
|
||||
'This tool will remove all annotations (comments, highlights, notes, etc.) from your PDF documents.'
|
||||
),
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,288 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { waitFor, renderHook, act } from '@testing-library/react';
|
||||
import { AppConfigProvider, useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
// Mock apiClient
|
||||
vi.mock('@app/services/apiClient');
|
||||
|
||||
describe('AppConfigContext', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Mock window.location.pathname
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { pathname: '/' },
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<AppConfigProvider>{children}</AppConfigProvider>
|
||||
);
|
||||
|
||||
it('should fetch and provide app config on non-auth pages', async () => {
|
||||
const mockConfig = {
|
||||
enableLogin: false,
|
||||
appNameNavbar: 'Stirling PDF',
|
||||
languages: ['en-US', 'en-GB'],
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: mockConfig,
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||
|
||||
// Initially loading
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.config).toBeNull();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.config).toEqual(mockConfig);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/api/v1/config/app-config', {
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip fetch on auth pages and use default config', async () => {
|
||||
// Mock being on login page
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { pathname: '/login' },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.config).toEqual({ enableLogin: true });
|
||||
});
|
||||
|
||||
// Should NOT call API on auth pages
|
||||
expect(apiClient.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle 401 error gracefully', async () => {
|
||||
const mockError = Object.assign(new Error('Unauthorized'), {
|
||||
response: { status: 401, data: {} },
|
||||
});
|
||||
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
|
||||
|
||||
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.config).toEqual({ enableLogin: true });
|
||||
// 401 should be handled gracefully, error may be null or set
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle network errors', async () => {
|
||||
const errorMessage = 'Network error occurred';
|
||||
const mockError = new Error(errorMessage);
|
||||
// Network errors don't have response property
|
||||
// Mock rejection for all retry attempts (default is 3 attempts)
|
||||
vi.mocked(apiClient.get)
|
||||
.mockRejectedValueOnce(mockError)
|
||||
.mockRejectedValueOnce(mockError)
|
||||
.mockRejectedValueOnce(mockError);
|
||||
|
||||
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.config).toEqual({ enableLogin: true });
|
||||
expect(result.current.error).toBe(errorMessage);
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip fetch on signup page', async () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { pathname: '/signup' },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.config).toEqual({ enableLogin: true });
|
||||
});
|
||||
|
||||
expect(apiClient.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip fetch on auth callback page', async () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { pathname: '/auth/callback' },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.config).toEqual({ enableLogin: true });
|
||||
});
|
||||
|
||||
expect(apiClient.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip fetch on invite accept page', async () => {
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { pathname: '/invite/abc123' },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
expect(result.current.config).toEqual({ enableLogin: true });
|
||||
});
|
||||
|
||||
expect(apiClient.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should refetch config when jwt-available event is triggered', async () => {
|
||||
const initialConfig = {
|
||||
enableLogin: true,
|
||||
appNameNavbar: 'Stirling PDF',
|
||||
};
|
||||
|
||||
const updatedConfig = {
|
||||
enableLogin: true,
|
||||
appNameNavbar: 'Stirling PDF',
|
||||
isAdmin: true,
|
||||
enableAnalytics: true,
|
||||
};
|
||||
|
||||
// First call returns initial config
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: initialConfig,
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.config).toEqual(initialConfig);
|
||||
});
|
||||
|
||||
// Setup second call for refetch
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: updatedConfig,
|
||||
} as any);
|
||||
|
||||
// Trigger jwt-available event wrapped in act
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||
// Wait a tick for event handler to run
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.config).toEqual(updatedConfig);
|
||||
});
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should provide refetch function', async () => {
|
||||
const mockConfig = {
|
||||
enableLogin: false,
|
||||
appNameNavbar: 'Test App',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
status: 200,
|
||||
data: mockConfig,
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.config).toEqual(mockConfig);
|
||||
});
|
||||
|
||||
// Call refetch wrapped in act
|
||||
await act(async () => {
|
||||
await result.current.refetch();
|
||||
});
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should not fetch twice without force flag', async () => {
|
||||
const mockConfig = {
|
||||
enableLogin: false,
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
status: 200,
|
||||
data: mockConfig,
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(() => useAppConfig(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.config).toEqual(mockConfig);
|
||||
});
|
||||
|
||||
// Should only be called once (no duplicate fetches)
|
||||
expect(apiClient.get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle initial config prop', async () => {
|
||||
const initialConfig = {
|
||||
enableLogin: false,
|
||||
appNameNavbar: 'Initial App',
|
||||
};
|
||||
|
||||
const customWrapper = ({ children }: { children: ReactNode }) => (
|
||||
<AppConfigProvider initialConfig={initialConfig}>
|
||||
{children}
|
||||
</AppConfigProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useAppConfig(), {
|
||||
wrapper: customWrapper,
|
||||
});
|
||||
|
||||
// With blocking mode (default), should still fetch even with initial config
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
// Should still make API call
|
||||
expect(apiClient.get).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use suppressErrorToast for all config requests', async () => {
|
||||
const mockConfig = { enableLogin: true };
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: mockConfig,
|
||||
} as any);
|
||||
|
||||
renderHook(() => useAppConfig(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/api/v1/config/app-config', {
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -114,7 +114,8 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
|
||||
}
|
||||
|
||||
// apiClient automatically adds JWT header if available via interceptors
|
||||
const response = await apiClient.get<AppConfig>('/api/v1/config/app-config', !isBlockingMode ? { suppressErrorToast: true } : undefined);
|
||||
// Always suppress error toast - we handle 401 errors locally
|
||||
const response = await apiClient.get<AppConfig>('/api/v1/config/app-config', { suppressErrorToast: true });
|
||||
const data = response.data;
|
||||
|
||||
console.debug('[AppConfig] Config fetched successfully:', data);
|
||||
@@ -159,8 +160,25 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
|
||||
}, [fetchCount, hasResolvedConfig, isBlockingMode, maxRetries, initialDelay]);
|
||||
|
||||
useEffect(() => {
|
||||
// Always try to fetch config to check if login is disabled
|
||||
// The endpoint should be public and return proper JSON
|
||||
// Skip config fetch on auth pages (/login, /signup, /auth/callback, /invite/*)
|
||||
// Config will be fetched after successful authentication via jwt-available event
|
||||
const currentPath = window.location.pathname;
|
||||
const isAuthPage = currentPath.includes('/login') ||
|
||||
currentPath.includes('/signup') ||
|
||||
currentPath.includes('/auth/callback') ||
|
||||
currentPath.includes('/invite/');
|
||||
|
||||
// On auth pages, always skip the config fetch
|
||||
// The config will be fetched after authentication via jwt-available event
|
||||
if (isAuthPage) {
|
||||
console.debug('[AppConfig] On auth page - using default config, skipping fetch');
|
||||
setConfig({ enableLogin: true });
|
||||
setHasResolvedConfig(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// On non-auth pages, fetch config (will validate JWT if present)
|
||||
if (autoFetch) {
|
||||
fetchConfig();
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* Memory management handled by FileLifecycleManager (PDF.js cleanup, blob URL revocation).
|
||||
*/
|
||||
|
||||
import { useReducer, useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import { useReducer, useCallback, useEffect, useRef, useMemo, useState } from 'react';
|
||||
import {
|
||||
FileContextProviderProps,
|
||||
FileContextSelectors,
|
||||
@@ -22,17 +22,27 @@ import {
|
||||
FileId,
|
||||
StirlingFileStub,
|
||||
StirlingFile,
|
||||
createStirlingFile,
|
||||
} from '@app/types/fileContext';
|
||||
|
||||
// Import modular components
|
||||
import { fileContextReducer, initialFileContextState } from '@app/contexts/file/FileReducer';
|
||||
import { createFileSelectors } from '@app/contexts/file/fileSelectors';
|
||||
import { addFiles, addStirlingFileStubs, consumeFiles, undoConsumeFiles, createFileActions } from '@app/contexts/file/fileActions';
|
||||
import { addFiles, addStirlingFileStubs, consumeFiles, undoConsumeFiles, createFileActions, createChildStub, generateProcessedFileMetadata } from '@app/contexts/file/fileActions';
|
||||
import { FileLifecycleManager } from '@app/contexts/file/lifecycle';
|
||||
import { FileStateContext, FileActionsContext } from '@app/contexts/file/contexts';
|
||||
import { IndexedDBProvider, useIndexedDB } from '@app/contexts/IndexedDBContext';
|
||||
import { useZipConfirmation } from '@app/hooks/useZipConfirmation';
|
||||
import ZipWarningModal from '@app/components/shared/ZipWarningModal';
|
||||
import EncryptedPdfUnlockModal from '@app/components/shared/EncryptedPdfUnlockModal';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { buildRemovePasswordFormData } from '@app/hooks/tools/removePassword/buildRemovePasswordFormData';
|
||||
import type { RemovePasswordParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { processResponse } from '@app/utils/toolResponseProcessor';
|
||||
import { ToolOperation } from '@app/types/file';
|
||||
import { handlePasswordError } from '@app/utils/toolErrorHandler';
|
||||
|
||||
const DEBUG = process.env.NODE_ENV === 'development';
|
||||
|
||||
@@ -63,6 +73,98 @@ function FileContextInner({
|
||||
lifecycleManagerRef.current = new FileLifecycleManager(filesRef, dispatch);
|
||||
}
|
||||
const lifecycleManager = lifecycleManagerRef.current;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [encryptedQueue, setEncryptedQueue] = useState<FileId[]>([]);
|
||||
const [activeEncryptedFileId, setActiveEncryptedFileId] = useState<FileId | null>(null);
|
||||
const [unlockPassword, setUnlockPassword] = useState('');
|
||||
const [unlockError, setUnlockError] = useState<string | null>(null);
|
||||
const [isUnlocking, setIsUnlocking] = useState(false);
|
||||
const dismissedEncryptedFilesRef = useRef<Set<FileId>>(new Set());
|
||||
const observedFileIdsRef = useRef<Set<FileId>>(new Set());
|
||||
|
||||
const enqueueEncryptedFiles = useCallback((fileIds: FileId[]) => {
|
||||
if (fileIds.length === 0) return;
|
||||
setEncryptedQueue(prevQueue => {
|
||||
const existing = new Set(prevQueue);
|
||||
const next = [...prevQueue];
|
||||
for (const id of fileIds) {
|
||||
if (dismissedEncryptedFilesRef.current.has(id)) continue;
|
||||
if (id === activeEncryptedFileId) continue;
|
||||
if (existing.has(id)) continue;
|
||||
existing.add(id);
|
||||
next.push(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [activeEncryptedFileId]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousIds = observedFileIdsRef.current;
|
||||
const nextIds = new Set(state.files.ids);
|
||||
const newEncryptedIds: FileId[] = [];
|
||||
|
||||
for (const id of state.files.ids) {
|
||||
if (!previousIds.has(id)) {
|
||||
const stub = state.files.byId[id];
|
||||
if ((stub?.versionNumber ?? 1) <= 1 && stub?.processedFile?.isEncrypted) {
|
||||
newEncryptedIds.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newEncryptedIds.length > 0) {
|
||||
enqueueEncryptedFiles(newEncryptedIds);
|
||||
}
|
||||
|
||||
observedFileIdsRef.current = nextIds;
|
||||
}, [state.files.ids, state.files.byId, enqueueEncryptedFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeEncryptedFileId && encryptedQueue.length > 0) {
|
||||
setActiveEncryptedFileId(encryptedQueue[0]);
|
||||
setEncryptedQueue(prev => prev.slice(1));
|
||||
}
|
||||
}, [activeEncryptedFileId, encryptedQueue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeEncryptedFileId && !state.files.ids.includes(activeEncryptedFileId)) {
|
||||
setActiveEncryptedFileId(null);
|
||||
}
|
||||
}, [activeEncryptedFileId, state.files.ids]);
|
||||
|
||||
useEffect(() => {
|
||||
setUnlockPassword('');
|
||||
setUnlockError(null);
|
||||
}, [activeEncryptedFileId]);
|
||||
|
||||
const handleUnlockSkip = useCallback(() => {
|
||||
if (activeEncryptedFileId) {
|
||||
dismissedEncryptedFilesRef.current.add(activeEncryptedFileId);
|
||||
}
|
||||
setActiveEncryptedFileId(null);
|
||||
}, [activeEncryptedFileId]);
|
||||
|
||||
const promptEncryptedUnlock = useCallback((fileId: FileId) => {
|
||||
const stub = stateRef.current.files.byId[fileId];
|
||||
if (!stub?.processedFile?.isEncrypted) {
|
||||
return;
|
||||
}
|
||||
|
||||
dismissedEncryptedFilesRef.current.delete(fileId);
|
||||
|
||||
setEncryptedQueue(prevQueue => prevQueue.filter(id => id !== fileId));
|
||||
|
||||
setActiveEncryptedFileId(currentActiveId => {
|
||||
if (currentActiveId && currentActiveId !== fileId) {
|
||||
setEncryptedQueue(prevQueue => {
|
||||
const withoutDuplicates = prevQueue.filter(id => id !== currentActiveId && id !== fileId);
|
||||
return [currentActiveId, ...withoutDuplicates];
|
||||
});
|
||||
}
|
||||
return fileId;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Create stable selectors (memoized once to avoid re-renders)
|
||||
const selectors = useMemo<FileContextSelectors>(() =>
|
||||
@@ -131,6 +233,80 @@ function FileContextInner({
|
||||
return consumeFiles(inputFileIds, outputStirlingFiles, outputStirlingFileStubs, filesRef, dispatch);
|
||||
}, []);
|
||||
|
||||
const runAutomaticPasswordRemoval = useCallback(async (fileId: FileId, password: string): Promise<void> => {
|
||||
const file = filesRef.current.get(fileId);
|
||||
const parentStub = stateRef.current.files.byId[fileId];
|
||||
|
||||
if (!file || !parentStub) {
|
||||
throw new Error(t('encryptedPdfUnlock.missingFile', 'The selected file is no longer available.'));
|
||||
}
|
||||
|
||||
const params: RemovePasswordParameters = { password };
|
||||
const formData = buildRemovePasswordFormData(params, file);
|
||||
|
||||
const response = await apiClient.post('/api/v1/security/remove-password', formData, {
|
||||
responseType: 'blob',
|
||||
suppressErrorToast: true // Handle errors in modal UI instead of toast
|
||||
});
|
||||
const responseFiles = await processResponse(response.data, [file]);
|
||||
|
||||
const unlockedFile = responseFiles[0];
|
||||
if (!unlockedFile) {
|
||||
throw new Error(t('encryptedPdfUnlock.emptyResponse', 'Password removal did not produce a file.'));
|
||||
}
|
||||
|
||||
const processedMetadata = await generateProcessedFileMetadata(unlockedFile);
|
||||
const thumbnail = processedMetadata?.thumbnailUrl;
|
||||
|
||||
const operation: ToolOperation = {
|
||||
toolId: 'removePassword',
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
const childStub = createChildStub(parentStub, operation, unlockedFile, thumbnail, processedMetadata);
|
||||
const stirlingUnlockedFile = createStirlingFile(unlockedFile, childStub.id);
|
||||
|
||||
await consumeFilesWrapper([fileId], [stirlingUnlockedFile], [childStub]);
|
||||
}, [consumeFilesWrapper, t]);
|
||||
|
||||
const handleUnlockSubmit = useCallback(async () => {
|
||||
if (!activeEncryptedFileId) return;
|
||||
if (!unlockPassword.trim()) {
|
||||
setUnlockError(t('encryptedPdfUnlock.required', 'Enter the password to continue.'));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUnlocking(true);
|
||||
setUnlockError(null);
|
||||
try {
|
||||
await runAutomaticPasswordRemoval(activeEncryptedFileId, unlockPassword.trim());
|
||||
const fileName = stateRef.current.files.byId[activeEncryptedFileId]?.name;
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('encryptedPdfUnlock.successTitle', 'Password removed'),
|
||||
body: fileName
|
||||
? t('encryptedPdfUnlock.successBodyWithName', {
|
||||
defaultValue: 'Removed password from {{fileName}}',
|
||||
fileName,
|
||||
})
|
||||
: t('encryptedPdfUnlock.successBody', 'Password removed successfully.'),
|
||||
expandable: false,
|
||||
isPersistentPopup: false,
|
||||
});
|
||||
dismissedEncryptedFilesRef.current.delete(activeEncryptedFileId);
|
||||
setActiveEncryptedFileId(null);
|
||||
} catch (error) {
|
||||
const errorMessage = await handlePasswordError(
|
||||
error,
|
||||
t('encryptedPdfUnlock.incorrectPassword', 'Incorrect password'),
|
||||
t('removePassword.error.failed', 'An error occurred while removing the password from the PDF.')
|
||||
);
|
||||
setUnlockError(errorMessage);
|
||||
} finally {
|
||||
setIsUnlocking(false);
|
||||
}
|
||||
}, [activeEncryptedFileId, unlockPassword, runAutomaticPasswordRemoval, t]);
|
||||
|
||||
const undoConsumeFilesWrapper = useCallback(async (inputFiles: File[], inputStirlingFileStubs: StirlingFileStub[], outputFileIds: FileId[]): Promise<void> => {
|
||||
return undoConsumeFiles(inputFiles, inputStirlingFileStubs, outputFileIds, filesRef, dispatch, indexedDB);
|
||||
}, [indexedDB]);
|
||||
@@ -199,7 +375,8 @@ function FileContextInner({
|
||||
trackBlobUrl: lifecycleManager.trackBlobUrl,
|
||||
cleanupFile: (fileId: FileId) => lifecycleManager.cleanupFile(fileId, stateRef),
|
||||
scheduleCleanup: (fileId: FileId, delay?: number) =>
|
||||
lifecycleManager.scheduleCleanup(fileId, delay, stateRef)
|
||||
lifecycleManager.scheduleCleanup(fileId, delay, stateRef),
|
||||
openEncryptedUnlockPrompt: promptEncryptedUnlock
|
||||
}), [
|
||||
baseActions,
|
||||
addRawFiles,
|
||||
@@ -211,7 +388,8 @@ function FileContextInner({
|
||||
pinFileWrapper,
|
||||
unpinFileWrapper,
|
||||
indexedDB,
|
||||
enablePersistence
|
||||
enablePersistence,
|
||||
promptEncryptedUnlock
|
||||
]);
|
||||
|
||||
// Split context values to minimize re-renders
|
||||
@@ -225,6 +403,9 @@ function FileContextInner({
|
||||
dispatch
|
||||
}), [actions]);
|
||||
|
||||
const activeEncryptedStub = activeEncryptedFileId ? state.files.byId[activeEncryptedFileId] : undefined;
|
||||
const isUnlockModalOpen = Boolean(activeEncryptedFileId && activeEncryptedStub);
|
||||
|
||||
// Persistence loading disabled - files only loaded on explicit user action
|
||||
// useEffect(() => {
|
||||
// if (!enablePersistence || !indexedDB) return;
|
||||
@@ -251,6 +432,16 @@ function FileContextInner({
|
||||
fileCount={confirmationState.fileCount}
|
||||
zipFileName={confirmationState.fileName}
|
||||
/>
|
||||
<EncryptedPdfUnlockModal
|
||||
opened={isUnlockModalOpen}
|
||||
fileName={activeEncryptedStub?.name}
|
||||
password={unlockPassword}
|
||||
errorMessage={unlockError}
|
||||
isProcessing={isUnlocking}
|
||||
onPasswordChange={setUnlockPassword}
|
||||
onUnlock={handleUnlockSubmit}
|
||||
onSkip={handleUnlockSkip}
|
||||
/>
|
||||
</FileActionsContext.Provider>
|
||||
</FileStateContext.Provider>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useReducer, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useToolManagement } from '@app/hooks/useToolManagement';
|
||||
import { useToolManagement, type ToolAvailabilityMap } from '@app/hooks/useToolManagement';
|
||||
import { PageEditorFunctions } from '@app/types/pageEditor';
|
||||
import { ToolRegistryEntry, ToolRegistry } from '@app/data/toolsTaxonomy';
|
||||
import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext';
|
||||
@@ -44,6 +44,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
|
||||
selectedTool: ToolRegistryEntry | null;
|
||||
toolRegistry: Partial<ToolRegistry>;
|
||||
getSelectedTool: (toolId: ToolId | null) => ToolRegistryEntry | null;
|
||||
toolAvailability: ToolAvailabilityMap;
|
||||
|
||||
// UI Actions
|
||||
setSidebarsVisible: (visible: boolean) => void;
|
||||
@@ -112,7 +113,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
const navigationState = useNavigationState();
|
||||
|
||||
// Tool management hook
|
||||
const { toolRegistry, getSelectedTool } = useToolManagement();
|
||||
const { toolRegistry, getSelectedTool, toolAvailability } = useToolManagement();
|
||||
const { allTools } = useToolRegistry();
|
||||
|
||||
// Tool history hook
|
||||
@@ -258,6 +259,11 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
|
||||
// Workflow actions (compound actions that coordinate multiple state changes)
|
||||
const handleToolSelect = useCallback((toolId: ToolId) => {
|
||||
const availabilityInfo = toolAvailability[toolId];
|
||||
const isExplicitlyDisabled = availabilityInfo ? availabilityInfo.available === false : false;
|
||||
if (toolId !== 'read' && toolId !== 'multiTool' && isExplicitlyDisabled) {
|
||||
return;
|
||||
}
|
||||
// If we're currently on a custom workbench (e.g., Validate Signature report),
|
||||
// selecting any tool should take the user back to the default file manager view.
|
||||
const wasInCustomWorkbench = !isBaseWorkbench(navigationState.workbench);
|
||||
@@ -299,7 +305,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
setSearchQuery('');
|
||||
setLeftPanelView('toolContent');
|
||||
setReaderMode(false); // Disable read mode when selecting tools
|
||||
}, [actions, getSelectedTool, navigationState.workbench, setLeftPanelView, setReaderMode, setSearchQuery]);
|
||||
}, [actions, getSelectedTool, navigationState.workbench, setLeftPanelView, setReaderMode, setSearchQuery, toolAvailability]);
|
||||
|
||||
const handleBackToTools = useCallback(() => {
|
||||
setLeftPanelView('toolPicker');
|
||||
@@ -354,6 +360,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
toolResetFunctions,
|
||||
registerToolReset,
|
||||
resetTool,
|
||||
toolAvailability,
|
||||
|
||||
// Workflow Actions
|
||||
handleToolSelect,
|
||||
@@ -381,6 +388,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
selectedTool,
|
||||
toolRegistry,
|
||||
getSelectedTool,
|
||||
toolAvailability,
|
||||
setSidebarsVisible,
|
||||
setLeftPanelView,
|
||||
setReaderMode,
|
||||
|
||||
@@ -63,7 +63,7 @@ export function createProcessedFile(
|
||||
thumbnail?: string,
|
||||
pageRotations?: number[],
|
||||
pageDimensions?: Array<{ width: number; height: number }>
|
||||
) {
|
||||
): ProcessedFileMetadata {
|
||||
return {
|
||||
totalPages: pageCount,
|
||||
pages: Array.from({ length: pageCount }, (_, index) => ({
|
||||
@@ -106,6 +106,10 @@ export async function generateProcessedFileMetadata(file: File): Promise<Process
|
||||
// Use rotated thumbnail for file manager
|
||||
processedFile.thumbnailUrl = rotatedResult.thumbnail;
|
||||
|
||||
if (unrotatedResult.isEncrypted || rotatedResult.isEncrypted) {
|
||||
processedFile.isEncrypted = true;
|
||||
}
|
||||
|
||||
return processedFile;
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 Failed to generate processedFileMetadata for ${file.name}:`, error);
|
||||
|
||||
@@ -188,6 +188,7 @@ export function useFileContext() {
|
||||
|
||||
// Active files
|
||||
activeFiles: selectors.getFiles(),
|
||||
openEncryptedUnlockPrompt: actions.openEncryptedUnlockPrompt,
|
||||
|
||||
// Direct access to actions and selectors (for advanced use cases)
|
||||
actions,
|
||||
|
||||
@@ -82,6 +82,7 @@ import { adjustPageScaleOperationConfig } from "@app/hooks/tools/adjustPageScale
|
||||
import { scannerImageSplitOperationConfig } from "@app/hooks/tools/scannerImageSplit/useScannerImageSplitOperation";
|
||||
import { addPageNumbersOperationConfig } from "@app/components/tools/addPageNumbers/useAddPageNumbersOperation";
|
||||
import { extractPagesOperationConfig } from "@app/hooks/tools/extractPages/useExtractPagesOperation";
|
||||
import { ENDPOINTS as SPLIT_ENDPOINT_NAMES } from '@app/constants/splitConstants';
|
||||
import CompressSettings from "@app/components/tools/compress/CompressSettings";
|
||||
import AddPasswordSettings from "@app/components/tools/addPassword/AddPasswordSettings";
|
||||
import RemovePasswordSettings from "@app/components/tools/removePassword/RemovePasswordSettings";
|
||||
@@ -300,6 +301,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
description: t("home.getPdfInfo.desc", "Grabs any and all information possible on PDFs"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.VERIFICATION,
|
||||
endpoints: ["get-info-on-pdf"],
|
||||
synonyms: getSynonyms(t, "getPdfInfo"),
|
||||
supportsAutomate: false,
|
||||
automationSettings: null
|
||||
@@ -398,6 +400,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
description: t("home.split.desc", "Split PDFs into multiple documents"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
endpoints: Array.from(new Set(Object.values(SPLIT_ENDPOINT_NAMES))),
|
||||
operationConfig: splitOperationConfig,
|
||||
automationSettings: SplitAutomationSettings,
|
||||
synonyms: getSynonyms(t, "split")
|
||||
@@ -465,6 +468,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
description: t("home.bookletImposition.desc", "Create booklets with proper page ordering and multi-page layout for printing and binding"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
endpoints: ["booklet-imposition"],
|
||||
},
|
||||
pdfToSinglePage: {
|
||||
|
||||
@@ -559,6 +563,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["remove-annotations"],
|
||||
operationConfig: removeAnnotationsOperationConfig,
|
||||
automationSettings: null,
|
||||
synonyms: getSynonyms(t, "removeAnnotations")
|
||||
@@ -597,7 +602,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["remove-certificate-sign"],
|
||||
endpoints: ["remove-cert-sign"],
|
||||
operationConfig: removeCertificateSignOperationConfig,
|
||||
synonyms: getSynonyms(t, "removeCertSign"),
|
||||
automationSettings: null,
|
||||
@@ -626,7 +631,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
name: t("home.autoRename.title", "Auto Rename PDF File"),
|
||||
component: AutoRename,
|
||||
maxFiles: -1,
|
||||
endpoints: ["remove-certificate-sign"],
|
||||
endpoints: ["auto-rename"],
|
||||
operationConfig: autoRenameOperationConfig,
|
||||
description: t("home.autoRename.desc", "Automatically rename PDF files based on their content"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
@@ -681,6 +686,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
description: t("home.overlay-pdfs.desc", "Overlay one PDF on top of another"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
endpoints: ["overlay-pdf"],
|
||||
operationConfig: overlayPdfsOperationConfig,
|
||||
synonyms: getSynonyms(t, "overlay-pdfs"),
|
||||
automationSettings: OverlayPdfsSettings
|
||||
@@ -705,6 +711,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
description: t("home.addImage.desc", "Add images to PDF documents"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
endpoints: ["add-image"],
|
||||
synonyms: getSynonyms(t, "addImage"),
|
||||
automationSettings: null
|
||||
},
|
||||
@@ -715,6 +722,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
description: t("home.scannerEffect.desc", "Create a PDF that looks like it was scanned"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
|
||||
endpoints: ["scanner-effect"],
|
||||
synonyms: getSynonyms(t, "scannerEffect"),
|
||||
automationSettings: null
|
||||
},
|
||||
@@ -805,6 +813,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["compress-pdf"],
|
||||
operationConfig: compressOperationConfig,
|
||||
automationSettings: CompressSettings,
|
||||
synonyms: getSynonyms(t, "compress")
|
||||
@@ -848,6 +857,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
|
||||
subcategoryId: SubcategoryId.GENERAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["ocr-pdf"],
|
||||
operationConfig: ocrOperationConfig,
|
||||
automationSettings: OCRSettings,
|
||||
synonyms: getSynonyms(t, "ocr")
|
||||
|
||||
@@ -9,6 +9,7 @@ import { SPLIT_METHODS } from '@app/constants/splitConstants';
|
||||
const CompressIcon = () => React.createElement(LocalIcon, { icon: 'compress', width: '1.5rem', height: '1.5rem' });
|
||||
const SecurityIcon = () => React.createElement(LocalIcon, { icon: 'security', width: '1.5rem', height: '1.5rem' });
|
||||
const StarIcon = () => React.createElement(LocalIcon, { icon: 'star', width: '1.5rem', height: '1.5rem' });
|
||||
const PrivacyIcon = () => React.createElement(LocalIcon, { icon: 'shield-lock', width: '1.5rem', height: '1.5rem' });
|
||||
|
||||
export function useSuggestedAutomations(): SuggestedAutomation[] {
|
||||
const { t } = useTranslation();
|
||||
@@ -67,6 +68,63 @@ export function useSuggestedAutomations(): SuggestedAutomation[] {
|
||||
updatedAt: now,
|
||||
icon: SecurityIcon,
|
||||
},
|
||||
{
|
||||
id: "pre-publish-sanitization",
|
||||
name: t("automation.suggested.prePublishSanitization", "Pre-publish Sanitization"),
|
||||
description: t("automation.suggested.prePublishSanitizationDesc", "Sanitization workflow that removes all hidden metadata, JavaScript, embedded files, annotations, and flattens forms to prevent data leakage before publishing PDFs online."),
|
||||
operations: [
|
||||
{
|
||||
operation: "sanitize",
|
||||
parameters: {
|
||||
removeJavaScript: true,
|
||||
removeEmbeddedFiles: true,
|
||||
removeXMPMetadata: true,
|
||||
removeMetadata: true,
|
||||
removeLinks: true,
|
||||
removeFonts: false,
|
||||
}
|
||||
},
|
||||
{
|
||||
operation: "flatten",
|
||||
parameters: {
|
||||
flattenOnlyForms: true,
|
||||
}
|
||||
},
|
||||
{
|
||||
operation: "removeAnnotations",
|
||||
parameters: {}
|
||||
},
|
||||
{
|
||||
operation: "changeMetadata",
|
||||
parameters: {
|
||||
deleteAll: true,
|
||||
author: '',
|
||||
creationDate: '',
|
||||
creator: '',
|
||||
keywords: '',
|
||||
modificationDate: '',
|
||||
producer: '',
|
||||
subject: '',
|
||||
title: '',
|
||||
trapped: '',
|
||||
}
|
||||
},
|
||||
{
|
||||
operation: "compress",
|
||||
parameters: {
|
||||
compressionLevel: 3,
|
||||
grayscale: false,
|
||||
expectedSize: '',
|
||||
compressionMethod: 'quality',
|
||||
fileSizeValue: '',
|
||||
fileSizeUnit: 'MB',
|
||||
}
|
||||
},
|
||||
],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
icon: PrivacyIcon,
|
||||
},
|
||||
{
|
||||
id: "email-preparation",
|
||||
name: t("automation.suggested.emailPreparation", "Email Preparation"),
|
||||
|
||||
@@ -40,13 +40,15 @@ export const buildChangeMetadataFormData = (parameters: ChangeMetadataParameters
|
||||
|
||||
// Custom metadata - backend expects them as values to 'allRequestParams[customKeyX/customValueX]'
|
||||
let keyNumber = 0;
|
||||
parameters.customMetadata.forEach((entry) => {
|
||||
if (entry.key.trim() && entry.value.trim()) {
|
||||
keyNumber += 1;
|
||||
formData.append(`allRequestParams[customKey${keyNumber}]`, entry.key.trim());
|
||||
formData.append(`allRequestParams[customValue${keyNumber}]`, entry.value.trim());
|
||||
}
|
||||
});
|
||||
if (parameters.customMetadata && Array.isArray(parameters.customMetadata)) {
|
||||
parameters.customMetadata.forEach((entry) => {
|
||||
if (entry.key.trim() && entry.value.trim()) {
|
||||
keyNumber += 1;
|
||||
formData.append(`allRequestParams[customKey${keyNumber}]`, entry.key.trim());
|
||||
formData.append(`allRequestParams[customValue${keyNumber}]`, entry.value.trim());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
+1
-1
@@ -14,6 +14,6 @@ export type RemoveCertificateSignParametersHook = BaseParametersHook<RemoveCerti
|
||||
export const useRemoveCertificateSignParameters = (): RemoveCertificateSignParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'remove-certificate-sign',
|
||||
endpointName: 'remove-cert-sign',
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { RemovePasswordParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters';
|
||||
|
||||
/**
|
||||
* Builds FormData for remove password API request.
|
||||
* Separated from operation config to avoid circular dependencies with FileContext.
|
||||
*/
|
||||
export const buildRemovePasswordFormData = (parameters: RemovePasswordParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
formData.append("password", parameters.password);
|
||||
return formData;
|
||||
};
|
||||
@@ -2,14 +2,10 @@ import { useTranslation } from 'react-i18next';
|
||||
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
|
||||
import { RemovePasswordParameters, defaultParameters } from '@app/hooks/tools/removePassword/useRemovePasswordParameters';
|
||||
import { buildRemovePasswordFormData } from '@app/hooks/tools/removePassword/buildRemovePasswordFormData';
|
||||
|
||||
// Static function that can be used by both the hook and automation executor
|
||||
export const buildRemovePasswordFormData = (parameters: RemovePasswordParameters, file: File): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
formData.append("password", parameters.password);
|
||||
return formData;
|
||||
};
|
||||
// Re-export for backwards compatibility with any other imports
|
||||
export { buildRemovePasswordFormData };
|
||||
|
||||
// Static configuration object
|
||||
export const removePasswordOperationConfig = {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability';
|
||||
|
||||
// Track globally fetched endpoint sets to prevent duplicate fetches across components
|
||||
const globalFetchedSets = new Set<string>();
|
||||
const globalEndpointCache: Record<string, boolean> = {};
|
||||
const globalEndpointCache: Record<string, EndpointAvailabilityDetails> = {};
|
||||
|
||||
/**
|
||||
* Hook to check if a specific endpoint is enabled
|
||||
@@ -59,11 +60,13 @@ export function useEndpointEnabled(endpoint: string): {
|
||||
*/
|
||||
export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
endpointStatus: Record<string, boolean>;
|
||||
endpointDetails: Record<string, EndpointAvailabilityDetails>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
} {
|
||||
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>({});
|
||||
const [endpointDetails, setEndpointDetails] = useState<Record<string, EndpointAvailabilityDetails>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -73,31 +76,25 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
// Skip if we already fetched these exact endpoints globally
|
||||
if (!force && globalFetchedSets.has(endpointsKey)) {
|
||||
console.debug('[useEndpointConfig] Already fetched these endpoints globally, using cache');
|
||||
const cachedStatus = endpoints.reduce((acc, endpoint) => {
|
||||
if (endpoint in globalEndpointCache) {
|
||||
acc[endpoint] = globalEndpointCache[endpoint];
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
setEndpointStatus(cachedStatus);
|
||||
const cached = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const cachedDetails = globalEndpointCache[endpoint];
|
||||
if (cachedDetails) {
|
||||
acc.status[endpoint] = cachedDetails.enabled;
|
||||
acc.details[endpoint] = cachedDetails;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
);
|
||||
setEndpointStatus(cached.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...cached.details }));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
setEndpointStatus({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if JWT exists - if not, optimistically enable all endpoints
|
||||
const hasJwt = !!localStorage.getItem('stirling_jwt');
|
||||
if (!hasJwt) {
|
||||
console.debug('[useEndpointConfig] No JWT found - optimistically enabling all endpoints');
|
||||
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
|
||||
acc[endpoint] = true;
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
setEndpointStatus(optimisticStatus);
|
||||
setEndpointDetails({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -110,11 +107,19 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
const newEndpoints = endpoints.filter(ep => !(ep in globalEndpointCache));
|
||||
if (newEndpoints.length === 0) {
|
||||
console.debug('[useEndpointConfig] All endpoints already in global cache');
|
||||
const cachedStatus = endpoints.reduce((acc, endpoint) => {
|
||||
acc[endpoint] = globalEndpointCache[endpoint];
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
setEndpointStatus(cachedStatus);
|
||||
const cached = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const cachedDetails = globalEndpointCache[endpoint];
|
||||
if (cachedDetails) {
|
||||
acc.status[endpoint] = cachedDetails.enabled;
|
||||
acc.details[endpoint] = cachedDetails;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
);
|
||||
setEndpointStatus(cached.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...cached.details }));
|
||||
globalFetchedSets.add(endpointsKey);
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -123,30 +128,51 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
// Use batch API for efficiency - only fetch new endpoints
|
||||
const endpointsParam = newEndpoints.join(',');
|
||||
|
||||
const response = await apiClient.get<Record<string, boolean>>(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`);
|
||||
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`);
|
||||
const statusMap = response.data;
|
||||
|
||||
// Update global cache with new results
|
||||
Object.assign(globalEndpointCache, statusMap);
|
||||
Object.entries(statusMap).forEach(([endpoint, details]) => {
|
||||
globalEndpointCache[endpoint] = {
|
||||
enabled: details?.enabled ?? true,
|
||||
reason: details?.reason ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
// Get all requested endpoints from cache (including previously cached ones)
|
||||
const fullStatus = endpoints.reduce((acc, endpoint) => {
|
||||
acc[endpoint] = globalEndpointCache[endpoint] ?? true; // Default to true if not in cache
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
const fullStatus = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const cachedDetails = globalEndpointCache[endpoint];
|
||||
if (cachedDetails) {
|
||||
acc.status[endpoint] = cachedDetails.enabled;
|
||||
acc.details[endpoint] = cachedDetails;
|
||||
} else {
|
||||
acc.status[endpoint] = true;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
);
|
||||
|
||||
setEndpointStatus(fullStatus);
|
||||
setEndpointStatus(fullStatus.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...fullStatus.details }));
|
||||
globalFetchedSets.add(endpointsKey);
|
||||
} catch (err: any) {
|
||||
// On 401 (auth error), use optimistic fallback instead of disabling
|
||||
if (err.response?.status === 401) {
|
||||
console.warn('[useEndpointConfig] 401 error - using optimistic fallback');
|
||||
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
|
||||
acc[endpoint] = true;
|
||||
globalEndpointCache[endpoint] = true; // Cache the optimistic value
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
setEndpointStatus(optimisticStatus);
|
||||
const optimisticStatus = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
|
||||
acc.status[endpoint] = true;
|
||||
acc.details[endpoint] = optimisticDetails;
|
||||
globalEndpointCache[endpoint] = optimisticDetails;
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
);
|
||||
setEndpointStatus(optimisticStatus.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...optimisticStatus.details }));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -156,11 +182,17 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
console.error('[EndpointConfig] Failed to check multiple endpoints:', err);
|
||||
|
||||
// Fallback: assume all endpoints are enabled on error (optimistic)
|
||||
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
|
||||
acc[endpoint] = true;
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
setEndpointStatus(optimisticStatus);
|
||||
const optimisticStatus = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
|
||||
acc.status[endpoint] = true;
|
||||
acc.details[endpoint] = optimisticDetails;
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
);
|
||||
setEndpointStatus(optimisticStatus.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...optimisticStatus.details }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -186,6 +218,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
|
||||
return {
|
||||
endpointStatus,
|
||||
endpointDetails,
|
||||
loading,
|
||||
error,
|
||||
refetch: () => fetchAllEndpointStatuses(true),
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
|
||||
import { usePreferences } from '@app/contexts/PreferencesContext';
|
||||
import { getAllEndpoints, type ToolRegistryEntry, type ToolRegistry } from "@app/data/toolsTaxonomy";
|
||||
import { useMultipleEndpointsEnabled } from "@app/hooks/useEndpointConfig";
|
||||
import { FileId } from '@app/types/file';
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import type { EndpointDisableReason } from '@app/types/endpointAvailability';
|
||||
|
||||
export type ToolDisableCause = 'disabledByAdmin' | 'missingDependency' | 'unknown';
|
||||
|
||||
export interface ToolAvailabilityInfo {
|
||||
available: boolean;
|
||||
reason?: ToolDisableCause;
|
||||
}
|
||||
|
||||
export type ToolAvailabilityMap = Partial<Record<ToolId, ToolAvailabilityInfo>>;
|
||||
|
||||
interface ToolManagementResult {
|
||||
selectedTool: ToolRegistryEntry | null;
|
||||
@@ -11,6 +22,7 @@ interface ToolManagementResult {
|
||||
toolRegistry: Partial<ToolRegistry>;
|
||||
setToolSelectedFileIds: (fileIds: FileId[]) => void;
|
||||
getSelectedTool: (toolKey: ToolId | null) => ToolRegistryEntry | null;
|
||||
toolAvailability: ToolAvailabilityMap;
|
||||
}
|
||||
|
||||
export const useToolManagement = (): ToolManagementResult => {
|
||||
@@ -19,9 +31,10 @@ export const useToolManagement = (): ToolManagementResult => {
|
||||
// Build endpoints list from registry entries with fallback to legacy mapping
|
||||
const { allTools } = useToolRegistry();
|
||||
const baseRegistry = allTools;
|
||||
const { preferences } = usePreferences();
|
||||
|
||||
const allEndpoints = useMemo(() => getAllEndpoints(baseRegistry), [baseRegistry]);
|
||||
const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
|
||||
const { endpointStatus, endpointDetails, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
|
||||
|
||||
const isToolAvailable = useCallback((toolKey: string): boolean => {
|
||||
// Keep tools enabled during loading (optimistic UX)
|
||||
@@ -38,22 +51,64 @@ export const useToolManagement = (): ToolManagementResult => {
|
||||
return endpoints.some((endpoint: string) => endpointStatus[endpoint] !== false);
|
||||
}, [endpointsLoading, endpointStatus, baseRegistry]);
|
||||
|
||||
const deriveToolDisableReason = useCallback((toolKey: ToolId): ToolDisableCause => {
|
||||
const tool = baseRegistry[toolKey];
|
||||
if (!tool) {
|
||||
return 'unknown';
|
||||
}
|
||||
const endpoints = tool.endpoints || [];
|
||||
const disabledReasons: EndpointDisableReason[] = endpoints
|
||||
.filter(endpoint => endpointStatus[endpoint] === false)
|
||||
.map(endpoint => endpointDetails[endpoint]?.reason ?? 'CONFIG');
|
||||
|
||||
if (disabledReasons.some(reason => reason === 'DEPENDENCY')) {
|
||||
return 'missingDependency';
|
||||
}
|
||||
if (disabledReasons.some(reason => reason === 'CONFIG')) {
|
||||
return 'disabledByAdmin';
|
||||
}
|
||||
if (disabledReasons.length > 0) {
|
||||
return 'unknown';
|
||||
}
|
||||
return 'unknown';
|
||||
}, [baseRegistry, endpointDetails, endpointStatus]);
|
||||
|
||||
const toolAvailability = useMemo(() => {
|
||||
if (endpointsLoading) {
|
||||
return {};
|
||||
}
|
||||
const availability: ToolAvailabilityMap = {};
|
||||
(Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => {
|
||||
const available = isToolAvailable(toolKey);
|
||||
availability[toolKey] = available
|
||||
? { available: true }
|
||||
: { available: false, reason: deriveToolDisableReason(toolKey) };
|
||||
});
|
||||
return availability;
|
||||
}, [baseRegistry, deriveToolDisableReason, endpointsLoading, isToolAvailable]);
|
||||
|
||||
const toolRegistry: Partial<ToolRegistry> = useMemo(() => {
|
||||
const availableToolRegistry: Partial<ToolRegistry> = {};
|
||||
(Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => {
|
||||
if (isToolAvailable(toolKey)) {
|
||||
const baseTool = baseRegistry[toolKey];
|
||||
if (baseTool) {
|
||||
availableToolRegistry[toolKey] = {
|
||||
...baseTool,
|
||||
name: baseTool.name,
|
||||
description: baseTool.description,
|
||||
};
|
||||
}
|
||||
const baseTool = baseRegistry[toolKey];
|
||||
if (!baseTool) return;
|
||||
const availabilityInfo = toolAvailability[toolKey];
|
||||
const isAvailable = availabilityInfo ? availabilityInfo.available !== false : true;
|
||||
|
||||
// Check if tool is "coming soon" (has no component and no link)
|
||||
const isComingSoon = !baseTool.component && !baseTool.link && toolKey !== 'read' && toolKey !== 'multiTool';
|
||||
|
||||
if (preferences.hideUnavailableTools && (!isAvailable || isComingSoon)) {
|
||||
return;
|
||||
}
|
||||
availableToolRegistry[toolKey] = {
|
||||
...baseTool,
|
||||
name: baseTool.name,
|
||||
description: baseTool.description,
|
||||
};
|
||||
});
|
||||
return availableToolRegistry;
|
||||
}, [isToolAvailable, baseRegistry]);
|
||||
}, [baseRegistry, preferences.hideUnavailableTools, toolAvailability]);
|
||||
|
||||
const getSelectedTool = useCallback((toolKey: ToolId | null): ToolRegistryEntry | null => {
|
||||
return toolKey ? toolRegistry[toolKey] || null : null;
|
||||
@@ -65,5 +120,6 @@ export const useToolManagement = (): ToolManagementResult => {
|
||||
toolRegistry,
|
||||
setToolSelectedFileIds,
|
||||
getSelectedTool,
|
||||
toolAvailability,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface UserPreferences {
|
||||
toolPanelModePromptSeen: boolean;
|
||||
showLegacyToolDescriptions: boolean;
|
||||
hasCompletedOnboarding: boolean;
|
||||
hideUnavailableTools: boolean;
|
||||
hideUnavailableConversions: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
@@ -19,6 +21,8 @@ export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
toolPanelModePromptSeen: false,
|
||||
showLegacyToolDescriptions: false,
|
||||
hasCompletedOnboarding: false,
|
||||
hideUnavailableTools: false,
|
||||
hideUnavailableConversions: false,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'stirlingpdf_preferences';
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY;
|
||||
|
||||
// Check if Supabase is configured
|
||||
export const isSupabaseConfigured = !!(supabaseUrl && supabaseAnonKey);
|
||||
|
||||
// Create client only if configured, otherwise export null
|
||||
export const supabase: SupabaseClient | null = isSupabaseConfigured
|
||||
? createClient(supabaseUrl, supabaseAnonKey)
|
||||
: null;
|
||||
|
||||
// Log warning if not configured (for self-hosted installations)
|
||||
if (!isSupabaseConfigured) {
|
||||
console.warn(
|
||||
'Supabase is not configured. Checkout and billing features will be disabled. ' +
|
||||
'Static plan information will be displayed instead.'
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
export interface UpdateSummary {
|
||||
latest_version: string;
|
||||
latest_stable_version?: string;
|
||||
latest_version: string | null;
|
||||
latest_stable_version?: string | null;
|
||||
max_priority: 'urgent' | 'normal' | 'minor' | 'low';
|
||||
recommended_action?: string;
|
||||
any_breaking: boolean;
|
||||
|
||||
@@ -8,10 +8,12 @@ import { useAddAttachmentsParameters } from "@app/hooks/tools/addAttachments/use
|
||||
import { useAddAttachmentsOperation } from "@app/hooks/tools/addAttachments/useAddAttachmentsOperation";
|
||||
import { useAccordionSteps } from "@app/hooks/tools/shared/useAccordionSteps";
|
||||
import AddAttachmentsSettings from "@app/components/tools/addAttachments/AddAttachmentsSettings";
|
||||
import { useAddAttachmentsTips } from "@app/components/tooltips/useAddAttachmentsTips";
|
||||
|
||||
const AddAttachments = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { selectedFiles } = useFileSelection();
|
||||
const addAttachmentsTips = useAddAttachmentsTips();
|
||||
|
||||
const params = useAddAttachmentsParameters();
|
||||
const operation = useAddAttachmentsOperation();
|
||||
@@ -64,6 +66,7 @@ const AddAttachments = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
|
||||
isCollapsed: accordion.getCollapsedState(AddAttachmentsStep.ATTACHMENTS),
|
||||
onCollapsedClick: () => accordion.handleStepToggle(AddAttachmentsStep.ATTACHMENTS),
|
||||
isVisible: true,
|
||||
tooltip: addAttachmentsTips,
|
||||
content: (
|
||||
<AddAttachmentsSettings
|
||||
parameters={params.parameters}
|
||||
|
||||
@@ -9,21 +9,28 @@ import { useAutoRenameTips } from "@app/components/tooltips/useAutoRenameTips";
|
||||
|
||||
const AutoRename =(props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const autoRenameTips = useAutoRenameTips();
|
||||
|
||||
const base = useBaseTool(
|
||||
'"auto-rename-pdf-file',
|
||||
'auto-rename-pdf-file',
|
||||
useAutoRenameParameters,
|
||||
useAutoRenameOperation,
|
||||
props
|
||||
);
|
||||
|
||||
return createToolFlow({
|
||||
title: { title:t("auto-rename.title", "Auto Rename PDF"), description: t("auto-rename.description", "Auto Rename PDF"), tooltip: useAutoRenameTips()},
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: base.hasResults,
|
||||
},
|
||||
steps: [],
|
||||
steps: [
|
||||
{
|
||||
title: t("auto-rename.settings.title", "About"),
|
||||
isCollapsed: false,
|
||||
tooltip: autoRenameTips,
|
||||
content: null,
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t("auto-rename.submit", "Auto Rename"),
|
||||
isVisible: !base.hasResults,
|
||||
|
||||
@@ -5,9 +5,11 @@ import { useRemoveAnnotationsParameters } from "@app/hooks/tools/removeAnnotatio
|
||||
import { useRemoveAnnotationsOperation } from "@app/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation";
|
||||
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||
import { useRemoveAnnotationsTips } from "@app/components/tooltips/useRemoveAnnotationsTips";
|
||||
|
||||
const RemoveAnnotations = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const removeAnnotationsTips = useRemoveAnnotationsTips();
|
||||
|
||||
const base = useBaseTool(
|
||||
'removeAnnotations',
|
||||
@@ -26,6 +28,7 @@ const RemoveAnnotations = (props: BaseToolProps) => {
|
||||
title: t("removeAnnotations.settings.title", "Settings"),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
|
||||
tooltip: removeAnnotationsTips,
|
||||
content: <RemoveAnnotationsSettings />,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export type EndpointDisableReason = 'CONFIG' | 'DEPENDENCY' | 'UNKNOWN' | null;
|
||||
|
||||
export interface EndpointAvailabilityDetails {
|
||||
enabled: boolean;
|
||||
reason?: EndpointDisableReason;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ export interface ProcessedFileMetadata {
|
||||
pages: ProcessedFilePage[];
|
||||
totalPages?: number;
|
||||
lastProcessed?: number;
|
||||
isEncrypted?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@@ -301,6 +302,7 @@ export interface FileContextActions {
|
||||
trackBlobUrl: (url: string) => void;
|
||||
scheduleCleanup: (fileId: FileId, delay?: number) => void;
|
||||
cleanupFile: (fileId: FileId) => void;
|
||||
openEncryptedUnlockPrompt: (fileId: FileId) => void;
|
||||
}
|
||||
|
||||
// File selectors (separate from actions to avoid re-renders)
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface ThumbnailWithMetadata {
|
||||
pageCount: number;
|
||||
pageRotations?: number[]; // Rotation for each page (0, 90, 180, 270)
|
||||
pageDimensions?: Array<{ width: number; height: number }>;
|
||||
isEncrypted?: boolean;
|
||||
}
|
||||
|
||||
interface ColorScheme {
|
||||
@@ -451,7 +452,7 @@ export async function generateThumbnailWithMetadata(file: File, applyRotation: b
|
||||
if (error instanceof Error && error.name === "PasswordException") {
|
||||
// Handle encrypted PDFs
|
||||
const thumbnail = generateEncryptedPDFThumbnail(file);
|
||||
return { thumbnail, pageCount: 1 };
|
||||
return { thumbnail, pageCount: 1, isEncrypted: true };
|
||||
}
|
||||
|
||||
const thumbnail = generatePlaceholderThumbnail(file);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* Standardized error handling utilities for tool operations
|
||||
*/
|
||||
|
||||
import { normalizeAxiosErrorData } from '@app/services/errorUtils';
|
||||
|
||||
/**
|
||||
* Default error extractor that follows the standard pattern
|
||||
*/
|
||||
@@ -30,4 +32,36 @@ export const createStandardErrorHandler = (fallbackMessage: string) => {
|
||||
}
|
||||
return fallbackMessage;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles password-related errors with status code checking
|
||||
* @param error - The error object from axios
|
||||
* @param incorrectPasswordMessage - Message to show for incorrect password (typically 500 status)
|
||||
* @param fallbackMessage - Message to show for other errors
|
||||
* @returns Error message string
|
||||
*/
|
||||
export const handlePasswordError = async (
|
||||
error: any,
|
||||
incorrectPasswordMessage: string,
|
||||
fallbackMessage: string
|
||||
): Promise<string> => {
|
||||
const status = error?.response?.status;
|
||||
|
||||
// Handle specific error cases with user-friendly messages
|
||||
if (status === 500) {
|
||||
// 500 typically means incorrect password for encrypted PDFs
|
||||
return incorrectPasswordMessage;
|
||||
}
|
||||
|
||||
// For other errors, try to extract the message
|
||||
const normalizedData = await normalizeAxiosErrorData(error?.response?.data);
|
||||
const errorWithNormalizedData = {
|
||||
...error,
|
||||
response: {
|
||||
...error?.response,
|
||||
data: normalizedData
|
||||
}
|
||||
};
|
||||
return extractErrorMessage(errorWithNormalizedData) || fallbackMessage;
|
||||
};
|
||||
@@ -4,8 +4,10 @@ import { useTranslation } from 'react-i18next';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { isBackendNotReadyError } from '@app/constants/backendErrors';
|
||||
import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability';
|
||||
import { connectionModeService } from '@desktop/services/connectionModeService';
|
||||
|
||||
|
||||
interface EndpointConfig {
|
||||
backendUrl: string;
|
||||
}
|
||||
@@ -128,6 +130,7 @@ export function useEndpointEnabled(endpoint: string): {
|
||||
|
||||
export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
endpointStatus: Record<string, boolean>;
|
||||
endpointDetails: Record<string, EndpointAvailabilityDetails>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
@@ -140,6 +143,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
});
|
||||
const [endpointDetails, setEndpointDetails] = useState<Record<string, EndpointAvailabilityDetails>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
@@ -174,13 +178,27 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
|
||||
const endpointsParam = endpoints.join(',');
|
||||
|
||||
const response = await apiClient.get<Record<string, boolean>>('/api/v1/config/endpoints-enabled', {
|
||||
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>('/api/v1/config/endpoints-availability', {
|
||||
params: { endpoints: endpointsParam },
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
setEndpointStatus(response.data);
|
||||
const details = Object.entries(response.data).reduce((acc, [endpointName, detail]) => {
|
||||
acc[endpointName] = {
|
||||
enabled: detail?.enabled ?? true,
|
||||
reason: detail?.reason ?? null,
|
||||
};
|
||||
return acc;
|
||||
}, {} as Record<string, EndpointAvailabilityDetails>);
|
||||
|
||||
const statusMap = Object.keys(details).reduce((acc, key) => {
|
||||
acc[key] = details[key].enabled;
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
|
||||
setEndpointDetails(prev => ({ ...prev, ...details }));
|
||||
setEndpointStatus(statusMap);
|
||||
} catch (err: unknown) {
|
||||
const isBackendStarting = isBackendNotReadyError(err);
|
||||
const message = getErrorMessage(err);
|
||||
@@ -188,10 +206,13 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
setError(isBackendStarting ? t('backendHealth.starting', 'Backend starting up...') : message);
|
||||
|
||||
const fallbackStatus = endpoints.reduce((acc, endpointName) => {
|
||||
acc[endpointName] = true;
|
||||
const fallbackDetail: EndpointAvailabilityDetails = { enabled: true, reason: null };
|
||||
acc.status[endpointName] = true;
|
||||
acc.details[endpointName] = fallbackDetail;
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
setEndpointStatus(fallbackStatus);
|
||||
}, { status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> });
|
||||
setEndpointStatus(fallbackStatus.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...fallbackStatus.details }));
|
||||
|
||||
if (!retryTimeoutRef.current) {
|
||||
retryTimeoutRef.current = setTimeout(() => {
|
||||
@@ -209,6 +230,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
useEffect(() => {
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
setEndpointStatus({});
|
||||
setEndpointDetails({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -230,6 +252,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
|
||||
return {
|
||||
endpointStatus,
|
||||
endpointDetails,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchAllEndpointStatuses,
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { springAuth } from '@app/auth/springAuthClient';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
// Mock apiClient
|
||||
vi.mock('@app/services/apiClient');
|
||||
|
||||
describe('SpringAuthClient', () => {
|
||||
beforeEach(() => {
|
||||
// Clear localStorage before each test
|
||||
localStorage.clear();
|
||||
// Clear all mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('getSession', () => {
|
||||
it('should return null session when no JWT in localStorage', async () => {
|
||||
const result = await springAuth.getSession();
|
||||
|
||||
expect(result.data.session).toBeNull();
|
||||
expect(result.error).toBeNull();
|
||||
expect(apiClient.get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should validate JWT and return session when JWT exists', async () => {
|
||||
const mockToken = 'mock-jwt-token';
|
||||
const mockUser = {
|
||||
id: '123',
|
||||
email: 'test@example.com',
|
||||
username: 'testuser',
|
||||
role: 'USER',
|
||||
};
|
||||
|
||||
localStorage.setItem('stirling_jwt', mockToken);
|
||||
|
||||
vi.mocked(apiClient.get).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: { user: mockUser },
|
||||
} as any);
|
||||
|
||||
const result = await springAuth.getSession();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/api/v1/auth/me', {
|
||||
headers: { Authorization: `Bearer ${mockToken}` },
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
expect(result.data.session).toBeTruthy();
|
||||
expect(result.data.session?.user).toEqual(mockUser);
|
||||
expect(result.data.session?.access_token).toBe(mockToken);
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it('should clear invalid JWT on 401 error', async () => {
|
||||
const mockToken = 'invalid-jwt-token';
|
||||
localStorage.setItem('stirling_jwt', mockToken);
|
||||
|
||||
const mockError = new AxiosError(
|
||||
'Unauthorized',
|
||||
'ERR_BAD_REQUEST',
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
data: {},
|
||||
headers: {},
|
||||
config: {} as any,
|
||||
}
|
||||
);
|
||||
|
||||
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
|
||||
|
||||
const result = await springAuth.getSession();
|
||||
|
||||
expect(localStorage.getItem('stirling_jwt')).toBeNull();
|
||||
expect(result.data.session).toBeNull();
|
||||
// 401 is handled gracefully, so error should be null
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it('should clear invalid JWT on 403 error', async () => {
|
||||
const mockToken = 'forbidden-jwt-token';
|
||||
localStorage.setItem('stirling_jwt', mockToken);
|
||||
|
||||
const mockError = new AxiosError(
|
||||
'Forbidden',
|
||||
'ERR_BAD_REQUEST',
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
data: {},
|
||||
headers: {},
|
||||
config: {} as any,
|
||||
}
|
||||
);
|
||||
|
||||
vi.mocked(apiClient.get).mockRejectedValueOnce(mockError);
|
||||
|
||||
const result = await springAuth.getSession();
|
||||
|
||||
expect(localStorage.getItem('stirling_jwt')).toBeNull();
|
||||
expect(result.data.session).toBeNull();
|
||||
// 403 is handled gracefully, so error should be null
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('signInWithPassword', () => {
|
||||
it('should successfully sign in with email and password', async () => {
|
||||
const credentials = {
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
};
|
||||
|
||||
const mockToken = 'new-jwt-token';
|
||||
const mockUser = {
|
||||
id: '123',
|
||||
email: credentials.email,
|
||||
username: credentials.email,
|
||||
role: 'USER',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: {
|
||||
user: mockUser,
|
||||
session: {
|
||||
access_token: mockToken,
|
||||
expires_in: 3600,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
// Spy on window.dispatchEvent
|
||||
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
|
||||
|
||||
const result = await springAuth.signInWithPassword(credentials);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/api/v1/auth/login',
|
||||
{
|
||||
username: credentials.email,
|
||||
password: credentials.password,
|
||||
},
|
||||
{ withCredentials: true }
|
||||
);
|
||||
expect(localStorage.getItem('stirling_jwt')).toBe(mockToken);
|
||||
expect(dispatchEventSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'jwt-available' })
|
||||
);
|
||||
expect(result.user).toEqual(mockUser);
|
||||
expect(result.session?.access_token).toBe(mockToken);
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error on failed login', async () => {
|
||||
const credentials = {
|
||||
email: 'wrong@example.com',
|
||||
password: 'wrongpassword',
|
||||
};
|
||||
|
||||
const errorMessage = 'Invalid credentials';
|
||||
const mockError = Object.assign(new Error(errorMessage), {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 401,
|
||||
data: { message: errorMessage },
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
|
||||
|
||||
const result = await springAuth.signInWithPassword(credentials);
|
||||
|
||||
expect(result.user).toBeNull();
|
||||
expect(result.session).toBeNull();
|
||||
expect(result.error).toBeTruthy();
|
||||
expect(result.error?.message).toBe(errorMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signUp', () => {
|
||||
it('should successfully register new user', async () => {
|
||||
const credentials = {
|
||||
email: 'newuser@example.com',
|
||||
password: 'newpassword123',
|
||||
};
|
||||
|
||||
const mockUser = {
|
||||
id: '456',
|
||||
email: credentials.email,
|
||||
username: credentials.email,
|
||||
role: 'USER',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: { user: mockUser },
|
||||
} as any);
|
||||
|
||||
const result = await springAuth.signUp(credentials);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/api/v1/user/register',
|
||||
{
|
||||
username: credentials.email,
|
||||
password: credentials.password,
|
||||
},
|
||||
{ withCredentials: true }
|
||||
);
|
||||
expect(result.user).toEqual(mockUser);
|
||||
expect(result.session).toBeNull(); // No auto-login on signup
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error on failed registration', async () => {
|
||||
const credentials = {
|
||||
email: 'existing@example.com',
|
||||
password: 'password123',
|
||||
};
|
||||
|
||||
const errorMessage = 'User already exists';
|
||||
const mockError = Object.assign(new Error(errorMessage), {
|
||||
isAxiosError: true,
|
||||
response: {
|
||||
status: 409,
|
||||
data: { message: errorMessage },
|
||||
},
|
||||
});
|
||||
|
||||
vi.mocked(apiClient.post).mockRejectedValueOnce(mockError);
|
||||
|
||||
const result = await springAuth.signUp(credentials);
|
||||
|
||||
expect(result.user).toBeNull();
|
||||
expect(result.session).toBeNull();
|
||||
expect(result.error).toBeTruthy();
|
||||
expect(result.error?.message).toBe(errorMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe('signOut', () => {
|
||||
it('should successfully sign out and clear JWT', async () => {
|
||||
const mockToken = 'jwt-to-clear';
|
||||
localStorage.setItem('stirling_jwt', mockToken);
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: {},
|
||||
} as any);
|
||||
|
||||
const result = await springAuth.signOut();
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/api/v1/auth/logout',
|
||||
null,
|
||||
expect.objectContaining({ withCredentials: true })
|
||||
);
|
||||
expect(localStorage.getItem('stirling_jwt')).toBeNull();
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it('should clear JWT even if logout request fails', async () => {
|
||||
const mockToken = 'jwt-to-clear';
|
||||
localStorage.setItem('stirling_jwt', mockToken);
|
||||
|
||||
vi.mocked(apiClient.post).mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 500 },
|
||||
message: 'Server error',
|
||||
});
|
||||
|
||||
const result = await springAuth.signOut();
|
||||
|
||||
expect(localStorage.getItem('stirling_jwt')).toBeNull();
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshSession', () => {
|
||||
it('should refresh JWT token successfully', async () => {
|
||||
const newToken = 'refreshed-jwt-token';
|
||||
const mockUser = {
|
||||
id: '123',
|
||||
email: 'test@example.com',
|
||||
username: 'testuser',
|
||||
role: 'USER',
|
||||
};
|
||||
|
||||
vi.mocked(apiClient.post).mockResolvedValueOnce({
|
||||
status: 200,
|
||||
data: {
|
||||
user: mockUser,
|
||||
session: {
|
||||
access_token: newToken,
|
||||
expires_in: 3600,
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
|
||||
|
||||
const result = await springAuth.refreshSession();
|
||||
|
||||
expect(localStorage.getItem('stirling_jwt')).toBe(newToken);
|
||||
expect(dispatchEventSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'jwt-available' })
|
||||
);
|
||||
expect(result.data.session?.access_token).toBe(newToken);
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
|
||||
it('should clear JWT and return error on 401', async () => {
|
||||
localStorage.setItem('stirling_jwt', 'expired-token');
|
||||
|
||||
vi.mocked(apiClient.post).mockRejectedValueOnce({
|
||||
isAxiosError: true,
|
||||
response: { status: 401 },
|
||||
message: 'Token expired',
|
||||
});
|
||||
|
||||
const result = await springAuth.refreshSession();
|
||||
|
||||
expect(localStorage.getItem('stirling_jwt')).toBeNull();
|
||||
expect(result.data.session).toBeNull();
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('signInWithOAuth', () => {
|
||||
it('should redirect to OAuth provider', async () => {
|
||||
const mockAssign = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { assign: mockAssign },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
const result = await springAuth.signInWithOAuth({
|
||||
provider: 'github',
|
||||
options: { redirectTo: '/auth/callback' },
|
||||
});
|
||||
|
||||
expect(mockAssign).toHaveBeenCalledWith('/oauth2/authorization/github');
|
||||
expect(result.error).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -134,6 +134,7 @@ class SpringAuthClient {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
|
||||
});
|
||||
|
||||
console.debug('[SpringAuth] /me response status:', response.status);
|
||||
@@ -314,6 +315,7 @@ class SpringAuthClient {
|
||||
'X-XSRF-TOKEN': this.getCsrfToken() || '',
|
||||
},
|
||||
withCredentials: true,
|
||||
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { AppProviders as CoreAppProviders, AppProvidersProps } from "@core/components/AppProviders";
|
||||
import { AuthProvider } from "@app/auth/UseSession";
|
||||
import { LicenseProvider } from "@app/contexts/LicenseContext";
|
||||
import { CheckoutProvider } from "@app/contexts/CheckoutContext";
|
||||
import UpgradeBanner from "@app/components/shared/UpgradeBanner";
|
||||
|
||||
export function AppProviders({ children, appConfigRetryOptions, appConfigProviderProps }: AppProvidersProps) {
|
||||
return (
|
||||
@@ -8,7 +11,12 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
appConfigProviderProps={appConfigProviderProps}
|
||||
>
|
||||
<AuthProvider>
|
||||
{children}
|
||||
<LicenseProvider>
|
||||
<CheckoutProvider>
|
||||
<UpgradeBanner />
|
||||
{children}
|
||||
</CheckoutProvider>
|
||||
</LicenseProvider>
|
||||
</AuthProvider>
|
||||
</CoreAppProviders>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import licenseService from '@app/services/licenseService';
|
||||
import { alert } from '@app/components/toast';
|
||||
|
||||
interface ManageBillingButtonProps {
|
||||
returnUrl?: string;
|
||||
}
|
||||
|
||||
export const ManageBillingButton: React.FC<ManageBillingButtonProps> = ({
|
||||
returnUrl = window.location.href,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleClick = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Get current license key for authentication
|
||||
const licenseInfo = await licenseService.getLicenseInfo();
|
||||
|
||||
if (!licenseInfo.licenseKey) {
|
||||
throw new Error('No license key found. Please activate a license first.');
|
||||
}
|
||||
|
||||
// Create billing portal session with license key
|
||||
const response = await licenseService.createBillingPortalSession(
|
||||
returnUrl,
|
||||
licenseInfo.licenseKey
|
||||
);
|
||||
|
||||
// Open billing portal in new tab
|
||||
window.open(response.url, '_blank');
|
||||
setLoading(false);
|
||||
} catch (error: any) {
|
||||
console.error('Failed to open billing portal:', error);
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('billing.portal.error', 'Failed to open billing portal'),
|
||||
body: error.message || 'Please try again or contact support.',
|
||||
});
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="outline" onClick={handleClick} loading={loading}>
|
||||
{t('billing.manageBilling', 'Manage Billing')}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,507 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Modal, Button, Text, Alert, Loader, Stack, Group, Paper, SegmentedControl, Grid, Code } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import { EmbeddedCheckoutProvider, EmbeddedCheckout } from '@stripe/react-stripe-js';
|
||||
import licenseService, { PlanTierGroup } from '@app/services/licenseService';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
import { pollLicenseKeyWithBackoff, activateLicenseKey, resyncExistingLicense } from '@app/utils/licenseCheckoutUtils';
|
||||
|
||||
// Validate Stripe key (static validation, no dynamic imports)
|
||||
const STRIPE_KEY = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
|
||||
|
||||
if (!STRIPE_KEY) {
|
||||
console.error(
|
||||
'VITE_STRIPE_PUBLISHABLE_KEY environment variable is required. ' +
|
||||
'Please add it to your .env file. ' +
|
||||
'Get your key from https://dashboard.stripe.com/apikeys'
|
||||
);
|
||||
}
|
||||
|
||||
if (STRIPE_KEY && !STRIPE_KEY.startsWith('pk_')) {
|
||||
console.error(
|
||||
`Invalid Stripe publishable key format. ` +
|
||||
`Expected key starting with 'pk_', got: ${STRIPE_KEY.substring(0, 10)}...`
|
||||
);
|
||||
}
|
||||
|
||||
const stripePromise = STRIPE_KEY ? loadStripe(STRIPE_KEY) : null;
|
||||
|
||||
interface StripeCheckoutProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
planGroup: PlanTierGroup;
|
||||
minimumSeats?: number;
|
||||
onSuccess?: (sessionId: string) => void;
|
||||
onError?: (error: string) => void;
|
||||
onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void;
|
||||
hostedCheckoutSuccess?: {
|
||||
isUpgrade: boolean;
|
||||
licenseKey?: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
type CheckoutState = {
|
||||
status: 'idle' | 'loading' | 'ready' | 'success' | 'error';
|
||||
clientSecret?: string;
|
||||
error?: string;
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
const StripeCheckout: React.FC<StripeCheckoutProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
planGroup,
|
||||
minimumSeats = 1,
|
||||
onSuccess,
|
||||
onError,
|
||||
onLicenseActivated,
|
||||
hostedCheckoutSuccess,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<CheckoutState>({ status: 'idle' });
|
||||
// Default to yearly if available (better value), otherwise monthly
|
||||
const [selectedPeriod, setSelectedPeriod] = useState<'monthly' | 'yearly'>(
|
||||
planGroup.yearly ? 'yearly' : 'monthly'
|
||||
);
|
||||
const [installationId, setInstallationId] = useState<string | null>(null);
|
||||
const [currentLicenseKey, setCurrentLicenseKey] = useState<string | null>(null);
|
||||
const [licenseKey, setLicenseKey] = useState<string | null>(null);
|
||||
const [pollingStatus, setPollingStatus] = useState<'idle' | 'polling' | 'ready' | 'timeout'>('idle');
|
||||
|
||||
// Refs for polling cleanup
|
||||
const isMountedRef = React.useRef(true);
|
||||
const pollingTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Get the selected plan based on period
|
||||
const selectedPlan = selectedPeriod === 'yearly' ? planGroup.yearly : planGroup.monthly;
|
||||
|
||||
const createCheckoutSession = async () => {
|
||||
if (!selectedPlan) {
|
||||
setState({
|
||||
status: 'error',
|
||||
error: 'Selected plan period is not available',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setState({ status: 'loading' });
|
||||
|
||||
// Fetch installation ID from backend
|
||||
let fetchedInstallationId = installationId;
|
||||
if (!fetchedInstallationId) {
|
||||
fetchedInstallationId = await licenseService.getInstallationId();
|
||||
setInstallationId(fetchedInstallationId);
|
||||
}
|
||||
|
||||
// Fetch current license key for upgrades
|
||||
let existingLicenseKey: string | undefined;
|
||||
try {
|
||||
const licenseInfo = await licenseService.getLicenseInfo();
|
||||
if (licenseInfo && licenseInfo.licenseKey) {
|
||||
existingLicenseKey = licenseInfo.licenseKey;
|
||||
setCurrentLicenseKey(existingLicenseKey);
|
||||
console.log('Found existing license for upgrade');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not fetch license info, proceeding as new license:', error);
|
||||
}
|
||||
|
||||
const response = await licenseService.createCheckoutSession({
|
||||
lookup_key: selectedPlan.lookupKey,
|
||||
installation_id: fetchedInstallationId,
|
||||
current_license_key: existingLicenseKey,
|
||||
requires_seats: selectedPlan.requiresSeats,
|
||||
seat_count: Math.max(1, Math.min(minimumSeats || 1, 10000)),
|
||||
});
|
||||
|
||||
// Check if we got a redirect URL (hosted checkout for HTTP)
|
||||
if (response.url) {
|
||||
console.log('Redirecting to Stripe hosted checkout:', response.url);
|
||||
// Redirect to Stripe's hosted checkout page
|
||||
window.location.href = response.url;
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, use embedded checkout (HTTPS)
|
||||
setState({
|
||||
status: 'ready',
|
||||
clientSecret: response.clientSecret,
|
||||
sessionId: response.sessionId,
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Failed to create checkout session';
|
||||
setState({
|
||||
status: 'error',
|
||||
error: errorMessage,
|
||||
});
|
||||
onError?.(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const pollForLicenseKey = useCallback(async (installId: string) => {
|
||||
// Use shared polling utility
|
||||
const result = await pollLicenseKeyWithBackoff(installId, {
|
||||
isMounted: () => isMountedRef.current,
|
||||
onStatusChange: setPollingStatus,
|
||||
});
|
||||
|
||||
if (result.success && result.licenseKey) {
|
||||
setLicenseKey(result.licenseKey);
|
||||
|
||||
// Activate the license key
|
||||
const activation = await activateLicenseKey(result.licenseKey, {
|
||||
isMounted: () => isMountedRef.current,
|
||||
onActivated: onLicenseActivated,
|
||||
});
|
||||
|
||||
if (!activation.success) {
|
||||
console.error('Failed to activate license key:', activation.error);
|
||||
}
|
||||
} else if (result.timedOut) {
|
||||
console.warn('License key polling timed out');
|
||||
} else if (result.error) {
|
||||
console.error('License key polling failed:', result.error);
|
||||
}
|
||||
}, [onLicenseActivated]);
|
||||
|
||||
const handlePaymentComplete = async () => {
|
||||
// Preserve state when changing status
|
||||
setState(prev => ({ ...prev, status: 'success' }));
|
||||
|
||||
// Check if this is an upgrade (existing license key) or new plan
|
||||
if (currentLicenseKey) {
|
||||
// UPGRADE FLOW: Resync existing license with Keygen
|
||||
console.log('Upgrade detected - resyncing existing license with Keygen');
|
||||
setPollingStatus('polling');
|
||||
|
||||
const activation = await resyncExistingLicense({
|
||||
isMounted: () => true, // Modal is open, no need to check
|
||||
onActivated: onLicenseActivated,
|
||||
});
|
||||
|
||||
if (activation.success) {
|
||||
console.log(`License upgraded successfully: ${activation.licenseType}`);
|
||||
setPollingStatus('ready');
|
||||
} else {
|
||||
console.error('Failed to sync upgraded license:', activation.error);
|
||||
setPollingStatus('timeout');
|
||||
}
|
||||
|
||||
// Notify parent (don't wait - upgrade is complete)
|
||||
onSuccess?.(state.sessionId || '');
|
||||
} else {
|
||||
// NEW PLAN FLOW: Poll for new license key
|
||||
console.log('New subscription - polling for license key');
|
||||
|
||||
if (installationId) {
|
||||
pollForLicenseKey(installationId).finally(() => {
|
||||
// Only notify parent after polling completes or times out
|
||||
onSuccess?.(state.sessionId || '');
|
||||
});
|
||||
} else {
|
||||
// No installation ID, notify immediately
|
||||
onSuccess?.(state.sessionId || '');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Clear any active polling
|
||||
if (pollingTimeoutRef.current) {
|
||||
clearTimeout(pollingTimeoutRef.current);
|
||||
pollingTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
setState({ status: 'idle' });
|
||||
setPollingStatus('idle');
|
||||
setCurrentLicenseKey(null);
|
||||
setLicenseKey(null);
|
||||
// Reset to default period on close
|
||||
setSelectedPeriod(planGroup.yearly ? 'yearly' : 'monthly');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handlePeriodChange = (value: string) => {
|
||||
setSelectedPeriod(value as 'monthly' | 'yearly');
|
||||
// Reset state to trigger checkout reload
|
||||
setState({ status: 'idle' });
|
||||
};
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
if (pollingTimeoutRef.current) {
|
||||
clearTimeout(pollingTimeoutRef.current);
|
||||
pollingTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle hosted checkout success - open directly to success state
|
||||
useEffect(() => {
|
||||
if (opened && hostedCheckoutSuccess) {
|
||||
console.log('Opening modal to success state for hosted checkout return');
|
||||
|
||||
// Set appropriate state based on upgrade vs new subscription
|
||||
if (hostedCheckoutSuccess.isUpgrade) {
|
||||
setCurrentLicenseKey('existing'); // Flag to indicate upgrade
|
||||
setPollingStatus('ready');
|
||||
} else if (hostedCheckoutSuccess.licenseKey) {
|
||||
setLicenseKey(hostedCheckoutSuccess.licenseKey);
|
||||
setPollingStatus('ready');
|
||||
}
|
||||
|
||||
// Set to success state to show success UI
|
||||
setState({ status: 'success' });
|
||||
}
|
||||
}, [opened, hostedCheckoutSuccess]);
|
||||
|
||||
// Initialize checkout when modal opens or period changes
|
||||
useEffect(() => {
|
||||
// Don't reset if we're showing success state (license key)
|
||||
if (state.status === 'success') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip initialization if opening for hosted checkout success
|
||||
if (hostedCheckoutSuccess) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (opened && state.status === 'idle') {
|
||||
createCheckoutSession();
|
||||
} else if (!opened) {
|
||||
setState({ status: 'idle' });
|
||||
}
|
||||
}, [opened, selectedPeriod, state.status, hostedCheckoutSuccess]);
|
||||
|
||||
const renderContent = () => {
|
||||
// Check if Stripe is configured
|
||||
if (!stripePromise) {
|
||||
return (
|
||||
<Alert color="red" title={t('payment.stripeNotConfigured', 'Stripe Not Configured')}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'payment.stripeNotConfiguredMessage',
|
||||
'Stripe payment integration is not configured. Please contact your administrator.'
|
||||
)}
|
||||
</Text>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t('common.close', 'Close')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
switch (state.status) {
|
||||
case 'loading':
|
||||
return (
|
||||
<Stack align="center" justify="center" style={{ padding: '2rem 0' }}>
|
||||
<Loader size="lg" />
|
||||
<Text size="sm" c="dimmed" mt="md">
|
||||
{t('payment.preparing', 'Preparing your checkout...')}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
case 'ready':
|
||||
{
|
||||
if (!state.clientSecret || !selectedPlan) return null;
|
||||
|
||||
// Build period selector data with prices
|
||||
const periodData = [];
|
||||
if (planGroup.monthly) {
|
||||
const monthlyPrice = planGroup.monthly.requiresSeats && planGroup.monthly.seatPrice
|
||||
? `${planGroup.monthly.currency}${planGroup.monthly.price.toFixed(2)}${planGroup.monthly.period} + ${planGroup.monthly.currency}${planGroup.monthly.seatPrice.toFixed(2)}/seat`
|
||||
: `${planGroup.monthly.currency}${planGroup.monthly.price.toFixed(2)}${planGroup.monthly.period}`;
|
||||
|
||||
periodData.push({
|
||||
value: 'monthly',
|
||||
label: `${t('payment.monthly', 'Monthly')} - ${monthlyPrice}`,
|
||||
});
|
||||
}
|
||||
if (planGroup.yearly) {
|
||||
const yearlyPrice = planGroup.yearly.requiresSeats && planGroup.yearly.seatPrice
|
||||
? `${planGroup.yearly.currency}${planGroup.yearly.price.toFixed(2)}${planGroup.yearly.period} + ${planGroup.yearly.currency}${planGroup.yearly.seatPrice.toFixed(2)}/seat`
|
||||
: `${planGroup.yearly.currency}${planGroup.yearly.price.toFixed(2)}${planGroup.yearly.period}`;
|
||||
|
||||
periodData.push({
|
||||
value: 'yearly',
|
||||
label: `${t('payment.yearly', 'Yearly')} - ${yearlyPrice}`,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid gutter="md">
|
||||
{/* Left: Period Selector - only show if both periods available */}
|
||||
{periodData.length > 1 && (
|
||||
<Grid.Col span={3}>
|
||||
<Stack gap="sm" style={{ height: '100%' }}>
|
||||
<Text size="sm" fw={600}>
|
||||
{t('payment.billingPeriod', 'Billing Period')}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={selectedPeriod}
|
||||
onChange={handlePeriodChange}
|
||||
data={periodData}
|
||||
orientation="vertical"
|
||||
fullWidth
|
||||
/>
|
||||
{selectedPlan.requiresSeats && selectedPlan.seatPrice && (
|
||||
<Text size="xs" c="dimmed" mt="md">
|
||||
{t('payment.enterpriseNote', 'Seats can be adjusted in checkout (1-1000).')}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Grid.Col>
|
||||
)}
|
||||
|
||||
{/* Right: Stripe Checkout */}
|
||||
<Grid.Col span={periodData.length > 1 ? 9 : 12}>
|
||||
<EmbeddedCheckoutProvider
|
||||
key={state.clientSecret}
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret: state.clientSecret,
|
||||
onComplete: handlePaymentComplete,
|
||||
}}
|
||||
>
|
||||
<EmbeddedCheckout />
|
||||
</EmbeddedCheckoutProvider>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
case 'success':
|
||||
return (
|
||||
<Alert color="green" title={t('payment.success', 'Payment Successful!')}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'payment.successMessage',
|
||||
'Your subscription has been activated successfully.'
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{/* License Key Polling Status */}
|
||||
{pollingStatus === 'polling' && (
|
||||
<Group gap="xs">
|
||||
<Loader size="sm" />
|
||||
<Text size="sm" c="dimmed">
|
||||
{currentLicenseKey
|
||||
? t('payment.syncingLicense', 'Syncing your upgraded license...')
|
||||
: t('payment.generatingLicense', 'Generating your license key...')}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{pollingStatus === 'ready' && !currentLicenseKey && licenseKey && (
|
||||
<Paper withBorder p="md" radius="md" bg="gray.1">
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={600}>
|
||||
{t('payment.licenseKey', 'Your License Key')}
|
||||
</Text>
|
||||
<Code block>{licenseKey}</Code>
|
||||
<Button
|
||||
variant="light"
|
||||
size="sm"
|
||||
onClick={() => navigator.clipboard.writeText(licenseKey)}
|
||||
>
|
||||
{t('common.copy', 'Copy to Clipboard')}
|
||||
</Button>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t(
|
||||
'payment.licenseInstructions',
|
||||
'Enter this key in Settings → Admin Plan → License Key section'
|
||||
)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{pollingStatus === 'ready' && currentLicenseKey && (
|
||||
<Alert color="green" title={t('payment.upgradeComplete', 'Upgrade Complete')}>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'payment.upgradeCompleteMessage',
|
||||
'Your subscription has been upgraded successfully. Your existing license key has been updated.'
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{pollingStatus === 'timeout' && (
|
||||
<Alert color="yellow" title={t('payment.licenseDelayed', 'License Key Processing')}>
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'payment.licenseDelayedMessage',
|
||||
'Your license key is being generated. Please check your email shortly or contact support.'
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{pollingStatus === 'ready' && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('payment.canCloseWindow', 'You can now close this window.')}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
case 'error':
|
||||
return (
|
||||
<Alert color="red" title={t('payment.error', 'Payment Error')}>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">{state.error}</Text>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
{t('common.close', 'Close')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={
|
||||
<Text fw={600} size="lg">
|
||||
{t('payment.upgradeTitle', 'Upgrade to {{planName}}', { planName: planGroup.name })}
|
||||
</Text>
|
||||
}
|
||||
size="90%"
|
||||
centered
|
||||
withCloseButton={true}
|
||||
closeOnEscape={true}
|
||||
closeOnClickOutside={false}
|
||||
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
|
||||
styles={{
|
||||
body: {
|
||||
minHeight: '85vh',
|
||||
},
|
||||
content: {
|
||||
maxHeight: '95vh',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{renderContent()}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default StripeCheckout;
|
||||
@@ -0,0 +1,144 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Group, Text, Button, ActionIcon, Paper } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { useCheckout } from '@app/contexts/CheckoutContext';
|
||||
import { useLicense } from '@app/contexts/LicenseContext';
|
||||
import { mapLicenseToTier } from '@app/services/licenseService';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { isSupabaseConfigured } from '@app/services/supabaseClient';
|
||||
|
||||
/**
|
||||
* UpgradeBanner - Dismissable top banner encouraging users to upgrade
|
||||
*
|
||||
* This component demonstrates:
|
||||
* - How to check authentication status with useAuth()
|
||||
* - How to check license status with licenseService
|
||||
* - How to open checkout modal with useCheckout()
|
||||
* - How to persist dismissal state with localStorage
|
||||
*
|
||||
* To remove this banner:
|
||||
* 1. Remove the import and component from AppProviders.tsx
|
||||
* 2. Delete this file
|
||||
*/
|
||||
const UpgradeBanner: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const { openCheckout } = useCheckout();
|
||||
const { licenseInfo, loading: licenseLoading } = useLicense();
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
// Check if user should see the banner
|
||||
useEffect(() => {
|
||||
// Don't show if not logged in
|
||||
if (!user) {
|
||||
setIsVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't show if Supabase is not configured (no checkout available)
|
||||
if (!isSupabaseConfigured) {
|
||||
setIsVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't show while license is loading
|
||||
if (licenseLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if banner was dismissed
|
||||
const dismissed = localStorage.getItem('upgradeBannerDismissed');
|
||||
if (dismissed === 'true') {
|
||||
setIsVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check license status from global context
|
||||
const tier = mapLicenseToTier(licenseInfo);
|
||||
|
||||
// Show banner only for free tier users
|
||||
if (tier === 'free' || tier === null) {
|
||||
setIsVisible(true);
|
||||
} else {
|
||||
// Auto-hide banner if user upgrades
|
||||
setIsVisible(false);
|
||||
}
|
||||
}, [user, licenseInfo, licenseLoading]);
|
||||
|
||||
// Handle dismiss
|
||||
const handleDismiss = () => {
|
||||
localStorage.setItem('upgradeBannerDismissed', 'true');
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
// Handle upgrade button click
|
||||
const handleUpgrade = () => {
|
||||
openCheckout('server', {
|
||||
currency: 'gbp',
|
||||
minimumSeats: 1,
|
||||
onSuccess: () => {
|
||||
// Banner will auto-hide on next render when license is detected
|
||||
setIsVisible(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Don't render anything if loading or not visible
|
||||
if (licenseLoading || !isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Paper
|
||||
shadow="sm"
|
||||
p="md"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1000,
|
||||
borderRadius: 0,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="md" wrap="nowrap">
|
||||
<LocalIcon icon="stars-rounded" width="1.5rem" height="1.5rem" />
|
||||
<div>
|
||||
<Text size="sm" fw={600}>
|
||||
{t('upgradeBanner.title', 'Upgrade to Server Plan')}
|
||||
</Text>
|
||||
<Text size="xs" opacity={0.9}>
|
||||
{t('upgradeBanner.message', 'Get the most out of Stirling PDF with unlimited users and advanced features')}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Button
|
||||
variant="white"
|
||||
size="sm"
|
||||
onClick={handleUpgrade}
|
||||
leftSection={<LocalIcon icon="upgrade-rounded" width="1rem" height="1rem" />}
|
||||
>
|
||||
{t('upgradeBanner.upgradeButton', 'Upgrade Now')}
|
||||
</Button>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="white"
|
||||
size="lg"
|
||||
onClick={handleDismiss}
|
||||
aria-label={t('upgradeBanner.dismiss', 'Dismiss banner')}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpgradeBanner;
|
||||
@@ -10,6 +10,7 @@ import AdminDatabaseSection from '@app/components/shared/config/configSections/A
|
||||
import AdminAdvancedSection from '@app/components/shared/config/configSections/AdminAdvancedSection';
|
||||
import AdminLegalSection from '@app/components/shared/config/configSections/AdminLegalSection';
|
||||
import AdminPremiumSection from '@app/components/shared/config/configSections/AdminPremiumSection';
|
||||
import AdminPlanSection from '@app/components/shared/config/configSections/AdminPlanSection';
|
||||
import AdminFeaturesSection from '@app/components/shared/config/configSections/AdminFeaturesSection';
|
||||
import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection';
|
||||
import AdminAuditSection from '@app/components/shared/config/configSections/AdminAuditSection';
|
||||
@@ -136,6 +137,14 @@ export const createConfigNavSections = (
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
|
||||
},
|
||||
{
|
||||
key: 'adminPlan',
|
||||
label: 'Plan',
|
||||
icon: 'star-rounded',
|
||||
component: <AdminPlanSection />,
|
||||
disabled: requiresLogin,
|
||||
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
|
||||
},
|
||||
{
|
||||
key: 'adminAudit',
|
||||
label: 'Audit',
|
||||
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Divider, Loader, Alert, Select, Group, Text, Collapse, Button, TextInput, Stack, Paper } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlans } from '@app/hooks/usePlans';
|
||||
import licenseService, { PlanTierGroup } from '@app/services/licenseService';
|
||||
import { useCheckout } from '@app/contexts/CheckoutContext';
|
||||
import { useLicense } from '@app/contexts/LicenseContext';
|
||||
import AvailablePlansSection from '@app/components/shared/config/configSections/plan/AvailablePlansSection';
|
||||
import StaticPlanSection from '@app/components/shared/config/configSections/plan/StaticPlanSection';
|
||||
import { alert } from '@app/components/toast';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
|
||||
import { ManageBillingButton } from '@app/components/shared/ManageBillingButton';
|
||||
import { isSupabaseConfigured } from '@app/services/supabaseClient';
|
||||
|
||||
const AdminPlanSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { openCheckout } = useCheckout();
|
||||
const { licenseInfo, refetchLicense } = useLicense();
|
||||
const [currency, setCurrency] = useState<string>('gbp');
|
||||
const [useStaticVersion, setUseStaticVersion] = useState(false);
|
||||
const [showLicenseKey, setShowLicenseKey] = useState(false);
|
||||
const [licenseKeyInput, setLicenseKeyInput] = useState<string>('');
|
||||
const [savingLicense, setSavingLicense] = useState(false);
|
||||
const { plans, loading, error, refetch } = usePlans(currency);
|
||||
|
||||
// Check if we should use static version
|
||||
useEffect(() => {
|
||||
// Check if Stripe and Supabase are configured
|
||||
const stripeKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
|
||||
if (!stripeKey || !isSupabaseConfigured || error) {
|
||||
setUseStaticVersion(true);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
const handleSaveLicense = async () => {
|
||||
try {
|
||||
setSavingLicense(true);
|
||||
// Allow empty string to clear/remove license
|
||||
const response = await licenseService.saveLicenseKey(licenseKeyInput.trim());
|
||||
|
||||
if (response.success) {
|
||||
// Refresh license context to update all components
|
||||
await refetchLicense();
|
||||
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('admin.settings.premium.key.success', 'License Key Saved'),
|
||||
body: t('admin.settings.premium.key.successMessage', 'Your license key has been activated successfully. No restart required.'),
|
||||
});
|
||||
|
||||
// Clear input
|
||||
setLicenseKeyInput('');
|
||||
} else {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('admin.error', 'Error'),
|
||||
body: response.error || t('admin.settings.saveError', 'Failed to save license key'),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save license key:', error);
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('admin.error', 'Error'),
|
||||
body: t('admin.settings.saveError', 'Failed to save license key'),
|
||||
});
|
||||
} finally {
|
||||
setSavingLicense(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currencyOptions = [
|
||||
{ value: 'gbp', label: 'British pound (GBP, £)' },
|
||||
{ value: 'usd', label: 'US dollar (USD, $)' },
|
||||
{ value: 'eur', label: 'Euro (EUR, €)' },
|
||||
{ value: 'cny', label: 'Chinese yuan (CNY, ¥)' },
|
||||
{ value: 'inr', label: 'Indian rupee (INR, ₹)' },
|
||||
{ value: 'brl', label: 'Brazilian real (BRL, R$)' },
|
||||
{ value: 'idr', label: 'Indonesian rupiah (IDR, Rp)' },
|
||||
];
|
||||
|
||||
const handleUpgradeClick = useCallback(
|
||||
(planGroup: PlanTierGroup) => {
|
||||
// Only allow upgrades for server and enterprise tiers
|
||||
if (planGroup.tier === 'free') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use checkout context to open checkout modal
|
||||
openCheckout(planGroup.tier, {
|
||||
currency,
|
||||
onSuccess: () => {
|
||||
// Refetch plans after successful payment
|
||||
// License context will auto-update
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
},
|
||||
[openCheckout, currency, refetch]
|
||||
);
|
||||
|
||||
// Show static version if Stripe is not configured or there's an error
|
||||
if (useStaticVersion) {
|
||||
return <StaticPlanSection currentLicenseInfo={licenseInfo ?? undefined} />;
|
||||
}
|
||||
|
||||
// Early returns after all hooks are called
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '2rem 0' }}>
|
||||
<Loader size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
// Fallback to static version on error
|
||||
return <StaticPlanSection currentLicenseInfo={licenseInfo ?? undefined} />;
|
||||
}
|
||||
|
||||
if (!plans || plans.length === 0) {
|
||||
return (
|
||||
<Alert color="yellow" title="No data available">
|
||||
Plans data is not available at the moment.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
{/* Currency Selection & Manage Subscription */}
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="lg" fw={600}>
|
||||
{t('plan.currency', 'Currency')}
|
||||
</Text>
|
||||
<Select
|
||||
value={currency}
|
||||
onChange={(value) => setCurrency(value || 'gbp')}
|
||||
data={currencyOptions}
|
||||
searchable
|
||||
clearable={false}
|
||||
w={300}
|
||||
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
{/* Manage Subscription Button - Only show if user has active license and Supabase is configured */}
|
||||
{licenseInfo?.licenseKey && isSupabaseConfigured && (
|
||||
<Group justify="space-between" align="center">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('plan.manageSubscription.description', 'Manage your subscription, billing, and payment methods')}
|
||||
</Text>
|
||||
<ManageBillingButton />
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<AvailablePlansSection
|
||||
plans={plans}
|
||||
currentLicenseInfo={licenseInfo}
|
||||
onUpgradeClick={handleUpgradeClick}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* License Key Section */}
|
||||
<div>
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<LocalIcon icon={showLicenseKey ? "expand-less-rounded" : "expand-more-rounded"} width="1.25rem" height="1.25rem" />}
|
||||
onClick={() => setShowLicenseKey(!showLicenseKey)}
|
||||
>
|
||||
{t('admin.settings.premium.licenseKey.toggle', 'Got a license key or certificate file?')}
|
||||
</Button>
|
||||
|
||||
<Collapse in={showLicenseKey} mt="md">
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
variant="light"
|
||||
color="blue"
|
||||
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t('admin.settings.premium.licenseKey.info', 'If you have a license key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features.')}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{/* Severe warning if license already exists */}
|
||||
{licenseInfo?.licenseKey && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="red"
|
||||
icon={<LocalIcon icon="warning-rounded" width="1rem" height="1rem" />}
|
||||
title={t('admin.settings.premium.key.overwriteWarning.title', '⚠️ Warning: Existing License Detected')}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{t('admin.settings.premium.key.overwriteWarning.line1', 'Overwriting your current license key cannot be undone.')}
|
||||
</Text>
|
||||
<Text size="sm">
|
||||
{t('admin.settings.premium.key.overwriteWarning.line2', 'Your previous license will be permanently lost unless you have backed it up elsewhere.')}
|
||||
</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{t('admin.settings.premium.key.overwriteWarning.line3', 'Important: Keep license keys private and secure. Never share them publicly.')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={t('admin.settings.premium.key.label', 'License Key')}
|
||||
description={t('admin.settings.premium.key.description', 'Enter your premium or enterprise license key. Premium features will be automatically enabled when a key is provided.')}
|
||||
value={licenseKeyInput}
|
||||
onChange={(e) => setLicenseKeyInput(e.target.value)}
|
||||
placeholder={licenseInfo?.licenseKey || '00000000-0000-0000-0000-000000000000'}
|
||||
type="password"
|
||||
disabled={savingLicense}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={handleSaveLicense} loading={savingLicense} size="sm">
|
||||
{t('admin.settings.save', 'Save Changes')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminPlanSection;
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Button, Collapse } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import licenseService, { PlanTier, PlanTierGroup, LicenseInfo, mapLicenseToTier } from '@app/services/licenseService';
|
||||
import PlanCard from '@app/components/shared/config/configSections/plan/PlanCard';
|
||||
import FeatureComparisonTable from '@app/components/shared/config/configSections/plan/FeatureComparisonTable';
|
||||
|
||||
interface AvailablePlansSectionProps {
|
||||
plans: PlanTier[];
|
||||
currentPlanId?: string;
|
||||
currentLicenseInfo?: LicenseInfo | null;
|
||||
onUpgradeClick: (planGroup: PlanTierGroup) => void;
|
||||
}
|
||||
|
||||
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
|
||||
plans,
|
||||
currentLicenseInfo,
|
||||
onUpgradeClick,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [showComparison, setShowComparison] = useState(false);
|
||||
|
||||
// Group plans by tier (Free, Server, Enterprise)
|
||||
const groupedPlans = useMemo(() => {
|
||||
return licenseService.groupPlansByTier(plans);
|
||||
}, [plans]);
|
||||
|
||||
// Calculate current tier from license info
|
||||
const currentTier = useMemo(() => {
|
||||
return mapLicenseToTier(currentLicenseInfo || null);
|
||||
}, [currentLicenseInfo]);
|
||||
|
||||
// Determine if the current tier matches (checks both Stripe subscription and license)
|
||||
const isCurrentTier = (tierGroup: PlanTierGroup): boolean => {
|
||||
// Check license tier match
|
||||
if (currentTier && tierGroup.tier === currentTier) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Determine if selecting this plan would be a downgrade
|
||||
const isDowngrade = (tierGroup: PlanTierGroup): boolean => {
|
||||
if (!currentTier) return false;
|
||||
|
||||
// Define tier hierarchy: enterprise > server > free
|
||||
const tierHierarchy: Record<string, number> = {
|
||||
'enterprise': 3,
|
||||
'server': 2,
|
||||
'free': 1
|
||||
};
|
||||
|
||||
const currentLevel = tierHierarchy[currentTier] || 0;
|
||||
const targetLevel = tierHierarchy[tierGroup.tier] || 0;
|
||||
|
||||
return currentLevel > targetLevel;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
|
||||
{t('plan.availablePlans.title', 'Available Plans')}
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: '0.25rem 0 1rem 0',
|
||||
color: 'var(--mantine-color-dimmed)',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
{t('plan.availablePlans.subtitle', 'Choose the plan that fits your needs')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: '1rem',
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
{groupedPlans.map((group) => (
|
||||
<PlanCard
|
||||
key={group.tier}
|
||||
planGroup={group}
|
||||
isCurrentTier={isCurrentTier(group)}
|
||||
isDowngrade={isDowngrade(group)}
|
||||
currentLicenseInfo={currentLicenseInfo}
|
||||
onUpgradeClick={onUpgradeClick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<Button variant="subtle" onClick={() => setShowComparison(!showComparison)}>
|
||||
{showComparison
|
||||
? t('plan.hideComparison', 'Hide Feature Comparison')
|
||||
: t('plan.showComparison', 'Compare All Features')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Collapse in={showComparison}>
|
||||
<FeatureComparisonTable plans={groupedPlans} />
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AvailablePlansSection;
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { Card, Badge, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PlanFeature } from '@app/services/licenseService';
|
||||
|
||||
interface PlanWithFeatures {
|
||||
name: string;
|
||||
features: PlanFeature[];
|
||||
popular?: boolean;
|
||||
tier?: string;
|
||||
}
|
||||
|
||||
interface FeatureComparisonTableProps {
|
||||
plans: PlanWithFeatures[];
|
||||
}
|
||||
|
||||
const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({ plans }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Card padding="lg" radius="md" withBorder style={{ marginTop: '1rem' }}>
|
||||
<Text size="lg" fw={600} mb="md">
|
||||
{t('plan.featureComparison', 'Feature Comparison')}
|
||||
</Text>
|
||||
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid var(--mantine-color-gray-3)' }}>
|
||||
<th style={{ textAlign: 'left', padding: '0.75rem' }}>
|
||||
{t('plan.feature.title', 'Feature')}
|
||||
</th>
|
||||
{plans.map((plan, index) => (
|
||||
<th
|
||||
key={plan.tier || plan.name || index}
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
padding: '0.75rem',
|
||||
minWidth: '8rem',
|
||||
position: 'relative'
|
||||
}}
|
||||
>
|
||||
{plan.name}
|
||||
{plan.popular && (
|
||||
<Badge
|
||||
color="blue"
|
||||
variant="filled"
|
||||
size="xs"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '0.5rem',
|
||||
right: '0.5rem',
|
||||
}}
|
||||
>
|
||||
{t('plan.popular', 'Popular')}
|
||||
</Badge>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plans[0]?.features.map((_, featureIndex) => (
|
||||
<tr
|
||||
key={featureIndex}
|
||||
style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}
|
||||
>
|
||||
<td style={{ padding: '0.75rem' }}>
|
||||
{plans[0].features[featureIndex].name}
|
||||
</td>
|
||||
{plans.map((plan, planIndex) => (
|
||||
<td key={planIndex} style={{ textAlign: 'center', padding: '0.75rem' }}>
|
||||
{plan.features[featureIndex]?.included ? (
|
||||
<Text c="green" fw={600} size="lg">
|
||||
✓
|
||||
</Text>
|
||||
) : (
|
||||
<Text c="gray" size="sm">
|
||||
−
|
||||
</Text>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeatureComparisonTable;
|
||||
@@ -0,0 +1,202 @@
|
||||
import React from 'react';
|
||||
import { Button, Card, Badge, Text, Stack, Divider } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PlanTierGroup, LicenseInfo } from '@app/services/licenseService';
|
||||
|
||||
interface PlanCardProps {
|
||||
planGroup: PlanTierGroup;
|
||||
isCurrentTier: boolean;
|
||||
isDowngrade: boolean;
|
||||
currentLicenseInfo?: LicenseInfo | null;
|
||||
onUpgradeClick: (planGroup: PlanTierGroup) => void;
|
||||
}
|
||||
|
||||
const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngrade, currentLicenseInfo, onUpgradeClick }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Render Free plan
|
||||
if (planGroup.tier === 'free') {
|
||||
return (
|
||||
<Card
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '400px',
|
||||
borderColor: isCurrentTier ? 'var(--mantine-color-green-6)' : undefined,
|
||||
borderWidth: isCurrentTier ? '2px' : undefined,
|
||||
}}
|
||||
>
|
||||
{isCurrentTier && (
|
||||
<Badge
|
||||
color="green"
|
||||
variant="filled"
|
||||
size="sm"
|
||||
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
|
||||
>
|
||||
{t('plan.current', 'Current Plan')}
|
||||
</Badge>
|
||||
)}
|
||||
<Stack gap="md" style={{ height: '100%' }}>
|
||||
<div>
|
||||
<Text size="xl" fw={700} mb="xs">
|
||||
{planGroup.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="xs" style={{ opacity: 0 }}>
|
||||
{t('plan.from', 'From')}
|
||||
</Text>
|
||||
<Text size="2.5rem" fw={700} style={{ lineHeight: 1 }}>
|
||||
£0
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
{t('plan.free.forever', 'Forever free')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack gap="xs">
|
||||
{planGroup.highlights.map((highlight, index) => (
|
||||
<Text key={index} size="sm" c="dimmed">
|
||||
• {highlight}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
|
||||
<Button variant="filled" disabled fullWidth>
|
||||
{isCurrentTier
|
||||
? t('plan.current', 'Current Plan')
|
||||
: t('plan.free.included', 'Included')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Render Server or Enterprise plans
|
||||
const { monthly, yearly } = planGroup;
|
||||
const isEnterprise = planGroup.tier === 'enterprise';
|
||||
|
||||
// Calculate "From" pricing - show yearly price divided by 12 for lowest monthly equivalent
|
||||
let displayPrice = monthly?.price || 0;
|
||||
let displaySeatPrice = monthly?.seatPrice;
|
||||
let displayCurrency = monthly?.currency || '£';
|
||||
|
||||
if (yearly) {
|
||||
displayPrice = Math.round(yearly.price / 12);
|
||||
displaySeatPrice = yearly.seatPrice ? Math.round(yearly.seatPrice / 12) : undefined;
|
||||
displayCurrency = yearly.currency;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: '400px',
|
||||
borderColor: isCurrentTier ? 'var(--mantine-color-green-6)' : undefined,
|
||||
borderWidth: isCurrentTier ? '2px' : undefined,
|
||||
}}
|
||||
>
|
||||
{isCurrentTier ? (
|
||||
<Badge
|
||||
color="green"
|
||||
variant="filled"
|
||||
size="sm"
|
||||
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
|
||||
>
|
||||
{t('plan.current', 'Current Plan')}
|
||||
</Badge>
|
||||
) : planGroup.popular ? (
|
||||
<Badge
|
||||
variant="filled"
|
||||
size="sm"
|
||||
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
|
||||
>
|
||||
{t('plan.popular', 'Popular')}
|
||||
</Badge>
|
||||
) : null}
|
||||
|
||||
<Stack gap="md" style={{ height: '100%' }}>
|
||||
{/* Tier Name */}
|
||||
<div>
|
||||
<Text size="xl" fw={700} mb="xs">
|
||||
{planGroup.name}
|
||||
</Text>
|
||||
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t('plan.from', 'From')}
|
||||
</Text>
|
||||
|
||||
{/* Price */}
|
||||
{isEnterprise && displaySeatPrice !== undefined ? (
|
||||
<>
|
||||
<Text size="2.5rem" fw={700} style={{ lineHeight: 1 }}>
|
||||
{displayCurrency}{displayPrice}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
+ {displayCurrency}{displaySeatPrice}/seat {t('plan.perMonth', '/month')}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text size="2.5rem" fw={700} style={{ lineHeight: 1 }}>
|
||||
{displayCurrency}{displayPrice}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
{t('plan.perMonth', '/month')}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Show seat count for enterprise plans when current */}
|
||||
{isEnterprise && isCurrentTier && currentLicenseInfo && currentLicenseInfo.maxUsers > 0 && (
|
||||
<Text size="sm" c="green" fw={500} mt="xs">
|
||||
{t('plan.licensedSeats', 'Licensed: {{count}} seats', { count: currentLicenseInfo.maxUsers })}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Highlights */}
|
||||
<Stack gap="xs">
|
||||
{planGroup.highlights.map((highlight, index) => (
|
||||
<Text key={index} size="sm" c="dimmed">
|
||||
• {highlight}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
|
||||
{/* Single Upgrade Button */}
|
||||
<Button
|
||||
variant={isCurrentTier || isDowngrade ? 'light' : 'filled'}
|
||||
fullWidth
|
||||
onClick={() => onUpgradeClick(planGroup)}
|
||||
disabled={isCurrentTier || isDowngrade}
|
||||
>
|
||||
{isCurrentTier
|
||||
? t('plan.current', 'Current Plan')
|
||||
: isDowngrade
|
||||
? t('plan.includedInCurrent', 'Included in Your Plan')
|
||||
: isEnterprise
|
||||
? t('plan.selectPlan', 'Select Plan')
|
||||
: t('plan.upgrade', 'Upgrade')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default PlanCard;
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, Text, Group, Stack, Badge, Button, Collapse, Alert, TextInput, Paper, Loader, Divider } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
|
||||
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
|
||||
import { useAdminSettings } from '@app/hooks/useAdminSettings';
|
||||
import PendingBadge from '@app/components/shared/config/PendingBadge';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { LicenseInfo, mapLicenseToTier } from '@app/services/licenseService';
|
||||
import { PLAN_FEATURES, PLAN_HIGHLIGHTS } from '@app/constants/planConstants';
|
||||
import FeatureComparisonTable from '@app/components/shared/config/configSections/plan/FeatureComparisonTable';
|
||||
|
||||
interface PremiumSettingsData {
|
||||
key?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface StaticPlanSectionProps {
|
||||
currentLicenseInfo?: LicenseInfo;
|
||||
}
|
||||
|
||||
const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInfo }) => {
|
||||
const { t } = useTranslation();
|
||||
const [showLicenseKey, setShowLicenseKey] = useState(false);
|
||||
const [showComparison, setShowComparison] = useState(false);
|
||||
|
||||
// Premium/License key management
|
||||
const { restartModalOpened, showRestartModal, closeRestartModal, restartServer } = useRestartServer();
|
||||
const {
|
||||
settings: premiumSettings,
|
||||
setSettings: setPremiumSettings,
|
||||
loading: premiumLoading,
|
||||
saving: premiumSaving,
|
||||
fetchSettings: fetchPremiumSettings,
|
||||
saveSettings: savePremiumSettings,
|
||||
isFieldPending,
|
||||
} = useAdminSettings<PremiumSettingsData>({
|
||||
sectionName: 'premium',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchPremiumSettings();
|
||||
}, []);
|
||||
|
||||
const handleSaveLicense = async () => {
|
||||
try {
|
||||
await savePremiumSettings();
|
||||
showRestartModal();
|
||||
} catch (_error) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('admin.error', 'Error'),
|
||||
body: t('admin.settings.saveError', 'Failed to save settings'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const staticPlans = [
|
||||
{
|
||||
id: 'free',
|
||||
name: t('plan.free.name', 'Free'),
|
||||
price: 0,
|
||||
currency: '£',
|
||||
period: '',
|
||||
highlights: PLAN_HIGHLIGHTS.FREE,
|
||||
features: PLAN_FEATURES.FREE,
|
||||
maxUsers: 5,
|
||||
},
|
||||
{
|
||||
id: 'server',
|
||||
name: 'Server',
|
||||
price: 0,
|
||||
currency: '',
|
||||
period: '',
|
||||
popular: false,
|
||||
highlights: PLAN_HIGHLIGHTS.SERVER_MONTHLY,
|
||||
features: PLAN_FEATURES.SERVER,
|
||||
maxUsers: 'Unlimited users',
|
||||
},
|
||||
{
|
||||
id: 'enterprise',
|
||||
name: t('plan.enterprise.name', 'Enterprise'),
|
||||
price: 0,
|
||||
currency: '',
|
||||
period: '',
|
||||
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_MONTHLY,
|
||||
features: PLAN_FEATURES.ENTERPRISE,
|
||||
maxUsers: 'Custom',
|
||||
},
|
||||
];
|
||||
|
||||
const getCurrentPlan = () => {
|
||||
const tier = mapLicenseToTier(currentLicenseInfo || null);
|
||||
if (tier === 'enterprise') return staticPlans[2];
|
||||
if (tier === 'server') return staticPlans[1];
|
||||
return staticPlans[0]; // free
|
||||
};
|
||||
|
||||
const currentPlan = getCurrentPlan();
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
|
||||
{/* Current Plan Section */}
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
|
||||
{t('plan.activePlan.title', 'Active Plan')}
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: '0.25rem 0 1rem 0',
|
||||
color: 'var(--mantine-color-dimmed)',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
{t('plan.activePlan.subtitle', 'Your current subscription details')}
|
||||
</p>
|
||||
|
||||
<Card padding="lg" radius="md" withBorder>
|
||||
<Group justify="space-between" align="center">
|
||||
<Stack gap="xs">
|
||||
<Group gap="sm">
|
||||
<Text size="lg" fw={600}>
|
||||
{currentPlan.name}
|
||||
</Text>
|
||||
<Badge color="green" variant="light">
|
||||
{t('subscription.status.active', 'Active')}
|
||||
</Badge>
|
||||
</Group>
|
||||
{currentLicenseInfo && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('plan.static.maxUsers', 'Max Users')}: {currentLicenseInfo.maxUsers}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<Text size="xl" fw={700}>
|
||||
{currentPlan.price === 0 ? t('plan.free.name', 'Free') : `${currentPlan.currency}${currentPlan.price}${currentPlan.period}`}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Available Plans */}
|
||||
<div>
|
||||
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
|
||||
{t('plan.availablePlans.title', 'Available Plans')}
|
||||
</h3>
|
||||
<p
|
||||
style={{
|
||||
margin: '0.25rem 0 1rem 0',
|
||||
color: 'var(--mantine-color-dimmed)',
|
||||
fontSize: '0.875rem',
|
||||
}}
|
||||
>
|
||||
{t('plan.static.contactToUpgrade', 'Contact us to upgrade or customize your plan')}
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: '1rem',
|
||||
paddingBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
{staticPlans.map((plan) => (
|
||||
<Card
|
||||
key={plan.id}
|
||||
padding="lg"
|
||||
radius="md"
|
||||
withBorder
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderColor: plan.id === currentPlan.id ? 'var(--mantine-color-green-6)' : undefined,
|
||||
borderWidth: plan.id === currentPlan.id ? '2px' : undefined,
|
||||
}}
|
||||
>
|
||||
{plan.id === currentPlan.id && (
|
||||
<Badge
|
||||
color="green"
|
||||
variant="filled"
|
||||
size="sm"
|
||||
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
|
||||
>
|
||||
{t('plan.current', 'Current Plan')}
|
||||
</Badge>
|
||||
)}
|
||||
{plan.popular && plan.id !== currentPlan.id && (
|
||||
<Badge
|
||||
variant="filled"
|
||||
size="xs"
|
||||
style={{ position: 'absolute', top: '0.5rem', right: '0.5rem' }}
|
||||
>
|
||||
{t('plan.popular', 'Popular')}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Stack gap="md" style={{ height: '100%' }}>
|
||||
<div>
|
||||
<Text size="lg" fw={600}>
|
||||
{plan.name}
|
||||
</Text>
|
||||
<Group gap="xs" style={{ alignItems: 'baseline' }}>
|
||||
<Text size="xl" fw={700} style={{ fontSize: '2rem' }}>
|
||||
{plan.price === 0 && plan.id !== 'free'
|
||||
? t('plan.customPricing', 'Custom')
|
||||
: plan.price === 0
|
||||
? t('plan.free.name', 'Free')
|
||||
: `${plan.currency}${plan.price}`}
|
||||
</Text>
|
||||
{plan.period && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{plan.period}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
{typeof plan.maxUsers === 'string'
|
||||
? plan.maxUsers
|
||||
: `${t('plan.static.upTo', 'Up to')} ${plan.maxUsers} ${t('workspace.people.license.users', 'users')}`}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Stack gap="xs">
|
||||
{plan.highlights.map((highlight, index) => (
|
||||
<Text key={index} size="sm" c="dimmed">
|
||||
• {highlight}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<div style={{ flexGrow: 1 }} />
|
||||
|
||||
<Button
|
||||
variant={plan.id === currentPlan.id ? 'light' : 'filled'}
|
||||
disabled={plan.id === currentPlan.id}
|
||||
fullWidth
|
||||
onClick={() =>
|
||||
window.open('https://www.stirling.com/contact', '_blank')
|
||||
}
|
||||
>
|
||||
{plan.id === currentPlan.id
|
||||
? t('plan.current', 'Current Plan')
|
||||
: t('plan.contact', 'Contact Us')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Feature Comparison Toggle */}
|
||||
<div style={{ textAlign: 'center', marginTop: '1rem' }}>
|
||||
<Button variant="subtle" onClick={() => setShowComparison(!showComparison)}>
|
||||
{showComparison
|
||||
? t('plan.hideComparison', 'Hide Feature Comparison')
|
||||
: t('plan.showComparison', 'Compare All Features')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Feature Comparison Table */}
|
||||
<Collapse in={showComparison}>
|
||||
<FeatureComparisonTable plans={staticPlans} />
|
||||
</Collapse>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* License Key Section */}
|
||||
<div>
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<LocalIcon icon={showLicenseKey ? "expand-less-rounded" : "expand-more-rounded"} width="1.25rem" height="1.25rem" />}
|
||||
onClick={() => setShowLicenseKey(!showLicenseKey)}
|
||||
>
|
||||
{t('admin.settings.premium.licenseKey.toggle', 'Got a license key or certificate file?')}
|
||||
</Button>
|
||||
|
||||
<Collapse in={showLicenseKey} mt="md">
|
||||
<Stack gap="md">
|
||||
<Alert
|
||||
variant="light"
|
||||
color="blue"
|
||||
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
|
||||
>
|
||||
<Text size="sm">
|
||||
{t('admin.settings.premium.licenseKey.info', 'If you have a license key or certificate file from a direct purchase, you can enter it here to activate premium or enterprise features.')}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
{premiumLoading ? (
|
||||
<Stack align="center" justify="center" h={100}>
|
||||
<Loader size="md" />
|
||||
</Stack>
|
||||
) : (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Stack gap="md">
|
||||
<div>
|
||||
<TextInput
|
||||
label={
|
||||
<Group gap="xs">
|
||||
<span>{t('admin.settings.premium.key.label', 'License Key')}</span>
|
||||
<PendingBadge show={isFieldPending('key')} />
|
||||
</Group>
|
||||
}
|
||||
description={t('admin.settings.premium.key.description', 'Enter your premium or enterprise license key. Premium features will be automatically enabled when a key is provided.')}
|
||||
value={premiumSettings.key || ''}
|
||||
onChange={(e) => setPremiumSettings({ ...premiumSettings, key: e.target.value })}
|
||||
placeholder="00000000-0000-0000-0000-000000000000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button onClick={handleSaveLicense} loading={premiumSaving} size="sm">
|
||||
{t('admin.settings.save', 'Save Changes')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</div>
|
||||
|
||||
{/* Restart Confirmation Modal */}
|
||||
<RestartConfirmationModal
|
||||
opened={restartModalOpened}
|
||||
onClose={closeRestartModal}
|
||||
onRestart={restartServer}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StaticPlanSection;
|
||||
@@ -0,0 +1,97 @@
|
||||
import { PlanFeature } from '@app/services/licenseService';
|
||||
|
||||
/**
|
||||
* Shared plan feature definitions for Stirling PDF Self-Hosted
|
||||
* Used by both dynamic (Stripe) and static (fallback) plan displays
|
||||
*/
|
||||
|
||||
export const PLAN_FEATURES = {
|
||||
FREE: [
|
||||
{ name: 'Self-hosted deployment', included: true },
|
||||
{ name: 'All PDF operations', included: true },
|
||||
{ name: 'Secure Login Support', included: true },
|
||||
{ name: 'Community support', included: true },
|
||||
{ name: 'Regular updates', included: true },
|
||||
{ name: 'up to 5 users', included: true },
|
||||
{ name: 'Unlimited users', included: false },
|
||||
{ name: 'Google drive integration', included: false },
|
||||
{ name: 'External Database', included: false },
|
||||
{ name: 'Editing text in pdfs', included: false },
|
||||
{ name: 'Users limited to seats', included: false },
|
||||
{ name: 'SSO', included: false },
|
||||
{ name: 'Auditing', included: false },
|
||||
{ name: 'Usage tracking', included: false },
|
||||
{ name: 'Prometheus Support', included: false },
|
||||
{ name: 'Custom PDF metadata', included: false },
|
||||
] as PlanFeature[],
|
||||
|
||||
SERVER: [
|
||||
{ name: 'Self-hosted deployment', included: true },
|
||||
{ name: 'All PDF operations', included: true },
|
||||
{ name: 'Secure Login Support', included: true },
|
||||
{ name: 'Community support', included: true },
|
||||
{ name: 'Regular updates', included: true },
|
||||
{ name: 'Up to 5 users', included: false },
|
||||
{ name: 'Unlimited users', included: true },
|
||||
{ name: 'Google drive integration', included: true },
|
||||
{ name: 'External Database', included: true },
|
||||
{ name: 'Editing text in pdfs', included: true },
|
||||
{ name: 'Users limited to seats', included: false },
|
||||
{ name: 'SSO', included: false },
|
||||
{ name: 'Auditing', included: false },
|
||||
{ name: 'Usage tracking', included: false },
|
||||
{ name: 'Prometheus Support', included: false },
|
||||
{ name: 'Custom PDF metadata', included: false },
|
||||
] as PlanFeature[],
|
||||
|
||||
ENTERPRISE: [
|
||||
{ name: 'Self-hosted deployment', included: true },
|
||||
{ name: 'All PDF operations', included: true },
|
||||
{ name: 'Secure Login Support', included: true },
|
||||
{ name: 'Community support', included: true },
|
||||
{ name: 'Regular updates', included: true },
|
||||
{ name: 'up to 5 users', included: false },
|
||||
{ name: 'Unlimited users', included: false },
|
||||
{ name: 'Google drive integration', included: true },
|
||||
{ name: 'External Database', included: true },
|
||||
{ name: 'Editing text in pdfs', included: true },
|
||||
{ name: 'Users limited to seats', included: true },
|
||||
{ name: 'SSO', included: true },
|
||||
{ name: 'Auditing', included: true },
|
||||
{ name: 'Usage tracking', included: true },
|
||||
{ name: 'Prometheus Support', included: true },
|
||||
{ name: 'Custom PDF metadata', included: true },
|
||||
] as PlanFeature[],
|
||||
} as const;
|
||||
|
||||
export const PLAN_HIGHLIGHTS = {
|
||||
FREE: [
|
||||
'Up to 5 users',
|
||||
'Self-hosted',
|
||||
'All basic features'
|
||||
],
|
||||
SERVER_MONTHLY: [
|
||||
'Self-hosted on your infrastructure',
|
||||
'Unlimited users',
|
||||
'Advanced integrations',
|
||||
'Cancel anytime'
|
||||
],
|
||||
SERVER_YEARLY: [
|
||||
'Self-hosted on your infrastructure',
|
||||
'Unlimited users',
|
||||
'Advanced integrations',
|
||||
'Save with annual billing'
|
||||
],
|
||||
ENTERPRISE_MONTHLY: [
|
||||
'Enterprise features (SSO, Auditing)',
|
||||
'Usage tracking & Prometheus',
|
||||
'Custom PDF metadata',
|
||||
'Per-seat licensing'
|
||||
],
|
||||
ENTERPRISE_YEARLY: [
|
||||
'Enterprise features (SSO, Auditing)',
|
||||
'Usage tracking & Prometheus',
|
||||
'Custom PDF metadata',
|
||||
'Save with annual billing'
|
||||
]
|
||||
} as const;
|
||||
@@ -0,0 +1,350 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlans } from '@app/hooks/usePlans';
|
||||
import licenseService, { PlanTierGroup, LicenseInfo, mapLicenseToTier } from '@app/services/licenseService';
|
||||
import StripeCheckout from '@app/components/shared/StripeCheckout';
|
||||
import { userManagementService } from '@app/services/userManagementService';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { pollLicenseKeyWithBackoff, activateLicenseKey, resyncExistingLicense } from '@app/utils/licenseCheckoutUtils';
|
||||
import { useLicense } from '@app/contexts/LicenseContext';
|
||||
import { isSupabaseConfigured } from '@app/services/supabaseClient';
|
||||
|
||||
export interface CheckoutOptions {
|
||||
minimumSeats?: number; // Override calculated seats for enterprise
|
||||
currency?: string; // Optional currency override (defaults to 'gbp')
|
||||
onSuccess?: (sessionId: string) => void; // Callback after successful payment
|
||||
onError?: (error: string) => void; // Callback on error
|
||||
}
|
||||
|
||||
interface CheckoutContextValue {
|
||||
openCheckout: (
|
||||
tier: 'server' | 'enterprise',
|
||||
options?: CheckoutOptions
|
||||
) => Promise<void>;
|
||||
closeCheckout: () => void;
|
||||
isOpen: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const CheckoutContext = createContext<CheckoutContextValue | undefined>(undefined);
|
||||
|
||||
interface CheckoutProviderProps {
|
||||
children: ReactNode;
|
||||
defaultCurrency?: string;
|
||||
}
|
||||
|
||||
export const CheckoutProvider: React.FC<CheckoutProviderProps> = ({
|
||||
children,
|
||||
defaultCurrency = 'gbp'
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { refetchLicense } = useLicense();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedPlanGroup, setSelectedPlanGroup] = useState<PlanTierGroup | null>(null);
|
||||
const [minimumSeats, setMinimumSeats] = useState<number>(1);
|
||||
const [currentCurrency, setCurrentCurrency] = useState(defaultCurrency);
|
||||
const [currentOptions, setCurrentOptions] = useState<CheckoutOptions>({});
|
||||
const [hostedCheckoutSuccess, setHostedCheckoutSuccess] = useState<{
|
||||
isUpgrade: boolean;
|
||||
licenseKey?: string;
|
||||
} | null>(null);
|
||||
|
||||
// Load plans with current currency
|
||||
const { plans, refetch: refetchPlans } = usePlans(currentCurrency);
|
||||
|
||||
// Handle return from hosted Stripe checkout
|
||||
useEffect(() => {
|
||||
const handleCheckoutReturn = async () => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const paymentStatus = urlParams.get('payment_status');
|
||||
const sessionId = urlParams.get('session_id');
|
||||
|
||||
if (paymentStatus === 'success' && sessionId) {
|
||||
console.log('Payment successful via hosted checkout:', sessionId);
|
||||
|
||||
// Clear URL parameters
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
|
||||
// Fetch current license info to determine upgrade vs new
|
||||
let licenseInfo: LicenseInfo | null = null;
|
||||
try {
|
||||
licenseInfo = await licenseService.getLicenseInfo();
|
||||
} catch (err) {
|
||||
console.warn('Could not fetch license info:', err);
|
||||
}
|
||||
|
||||
// Check if this is an upgrade or new subscription
|
||||
if (licenseInfo?.licenseKey) {
|
||||
// UPGRADE: Resync existing license with Keygen
|
||||
console.log('Upgrade detected - resyncing existing license');
|
||||
|
||||
const activation = await resyncExistingLicense();
|
||||
|
||||
if (activation.success) {
|
||||
console.log('License synced successfully, refreshing license context');
|
||||
|
||||
// Refresh global license context
|
||||
await refetchLicense();
|
||||
await refetchPlans();
|
||||
|
||||
// Determine tier from license type
|
||||
const tier = activation.licenseType === 'ENTERPRISE' ? 'enterprise' : 'server';
|
||||
const planGroups = licenseService.groupPlansByTier(plans);
|
||||
const planGroup = planGroups.find(pg => pg.tier === tier);
|
||||
|
||||
if (planGroup) {
|
||||
// Reopen modal to show success
|
||||
setSelectedPlanGroup(planGroup);
|
||||
setHostedCheckoutSuccess({ isUpgrade: true });
|
||||
setIsOpen(true);
|
||||
} else {
|
||||
// Fallback to toast if plan group not found
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('payment.upgradeSuccess'),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to sync license after upgrade:', activation.error);
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('payment.syncError'),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// NEW SUBSCRIPTION: Poll for license key
|
||||
console.log('New subscription - polling for license key');
|
||||
|
||||
try {
|
||||
const installationId = await licenseService.getInstallationId();
|
||||
console.log('Polling for license key with installation ID:', installationId);
|
||||
|
||||
// Use shared polling utility
|
||||
const result = await pollLicenseKeyWithBackoff(installationId);
|
||||
|
||||
if (result.success && result.licenseKey) {
|
||||
// Activate the license key
|
||||
const activation = await activateLicenseKey(result.licenseKey);
|
||||
|
||||
if (activation.success) {
|
||||
console.log(`License key activated: ${activation.licenseType}`);
|
||||
|
||||
// Refresh global license context
|
||||
await refetchLicense();
|
||||
await refetchPlans();
|
||||
|
||||
// Determine tier from license type
|
||||
const tier = activation.licenseType === 'ENTERPRISE' ? 'enterprise' : 'server';
|
||||
const planGroups = licenseService.groupPlansByTier(plans);
|
||||
const planGroup = planGroups.find(pg => pg.tier === tier);
|
||||
|
||||
if (planGroup) {
|
||||
// Reopen modal to show success with license key
|
||||
setSelectedPlanGroup(planGroup);
|
||||
setHostedCheckoutSuccess({
|
||||
isUpgrade: false,
|
||||
licenseKey: result.licenseKey
|
||||
});
|
||||
setIsOpen(true);
|
||||
} else {
|
||||
// Fallback to toast if plan group not found
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: t('payment.licenseActivated'),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to save license key:', activation.error);
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('payment.licenseSaveError'),
|
||||
});
|
||||
}
|
||||
} else if (result.timedOut) {
|
||||
console.warn('License key polling timed out');
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('payment.licenseDelayed'),
|
||||
});
|
||||
} else {
|
||||
console.error('License key polling failed:', result.error);
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('payment.licensePollingError'),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to poll for license key:', error);
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: t('payment.licenseRetrievalError'),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (paymentStatus === 'canceled') {
|
||||
console.log('Payment canceled by user');
|
||||
|
||||
// Clear URL parameters
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: t('payment.paymentCanceled'),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleCheckoutReturn();
|
||||
}, [t, refetchPlans, refetchLicense, plans]);
|
||||
|
||||
const openCheckout = useCallback(
|
||||
async (tier: 'server' | 'enterprise', options: CheckoutOptions = {}) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Check if Supabase is configured
|
||||
if (!isSupabaseConfigured) {
|
||||
throw new Error('Checkout is not available. Supabase is not configured.');
|
||||
}
|
||||
|
||||
// Update currency if provided
|
||||
const currency = options.currency || currentCurrency;
|
||||
if (currency !== currentCurrency) {
|
||||
setCurrentCurrency(currency);
|
||||
// Plans will reload automatically via usePlans
|
||||
}
|
||||
|
||||
// Fetch license info and user data for seat calculations
|
||||
let licenseInfo: LicenseInfo | null = null;
|
||||
let totalUsers = 0;
|
||||
|
||||
try {
|
||||
const [licenseData, userData] = await Promise.all([
|
||||
licenseService.getLicenseInfo(),
|
||||
userManagementService.getUsers()
|
||||
]);
|
||||
|
||||
licenseInfo = licenseData;
|
||||
totalUsers = userData.totalUsers || 0;
|
||||
} catch (err) {
|
||||
console.warn('Could not fetch license/user info, proceeding with defaults:', err);
|
||||
}
|
||||
|
||||
// Calculate minimum seats for enterprise upgrades
|
||||
let calculatedMinSeats = options.minimumSeats || 1;
|
||||
|
||||
if (tier === 'enterprise' && !options.minimumSeats) {
|
||||
const currentTier = mapLicenseToTier(licenseInfo);
|
||||
|
||||
if (currentTier === 'server' || currentTier === 'free') {
|
||||
// Upgrading from Server (unlimited) to Enterprise (per-seat)
|
||||
// Use current total user count as minimum
|
||||
calculatedMinSeats = Math.max(totalUsers, 1);
|
||||
console.log(`Setting minimum seats from server user count: ${calculatedMinSeats}`);
|
||||
} else if (currentTier === 'enterprise') {
|
||||
// Upgrading within Enterprise (e.g., monthly to yearly)
|
||||
// Use current licensed seat count as minimum
|
||||
calculatedMinSeats = Math.max(licenseInfo?.maxUsers || 1, 1);
|
||||
console.log(`Setting minimum seats from current license: ${calculatedMinSeats}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Find the plan group for the requested tier
|
||||
const planGroups = licenseService.groupPlansByTier(plans);
|
||||
const planGroup = planGroups.find(pg => pg.tier === tier);
|
||||
|
||||
if (!planGroup) {
|
||||
throw new Error(`No ${tier} plan available`);
|
||||
}
|
||||
|
||||
// Store options for callbacks
|
||||
setCurrentOptions(options);
|
||||
setMinimumSeats(calculatedMinSeats);
|
||||
setSelectedPlanGroup(planGroup);
|
||||
setIsOpen(true);
|
||||
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to open checkout';
|
||||
console.error('Error opening checkout:', errorMessage);
|
||||
options.onError?.(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[currentCurrency, plans]
|
||||
);
|
||||
|
||||
const closeCheckout = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setSelectedPlanGroup(null);
|
||||
setCurrentOptions({});
|
||||
setHostedCheckoutSuccess(null);
|
||||
|
||||
// Refetch plans and license after modal closes to update subscription display
|
||||
refetchPlans();
|
||||
refetchLicense();
|
||||
}, [refetchPlans, refetchLicense]);
|
||||
|
||||
const handlePaymentSuccess = useCallback(
|
||||
(sessionId: string) => {
|
||||
console.log('Payment successful, session:', sessionId);
|
||||
currentOptions.onSuccess?.(sessionId);
|
||||
// Don't close modal - let user view license key and close manually
|
||||
},
|
||||
[currentOptions]
|
||||
);
|
||||
|
||||
const handlePaymentError = useCallback(
|
||||
(error: string) => {
|
||||
console.error('Payment error:', error);
|
||||
currentOptions.onError?.(error);
|
||||
},
|
||||
[currentOptions]
|
||||
);
|
||||
|
||||
const handleLicenseActivated = useCallback((licenseInfo: {
|
||||
licenseType: string;
|
||||
enabled: boolean;
|
||||
maxUsers: number;
|
||||
hasKey: boolean;
|
||||
}) => {
|
||||
console.log('License activated:', licenseInfo);
|
||||
// Could expose this via context if needed
|
||||
}, []);
|
||||
|
||||
const contextValue: CheckoutContextValue = {
|
||||
openCheckout,
|
||||
closeCheckout,
|
||||
isOpen,
|
||||
isLoading,
|
||||
};
|
||||
|
||||
return (
|
||||
<CheckoutContext.Provider value={contextValue}>
|
||||
{children}
|
||||
|
||||
{/* Global Checkout Modal */}
|
||||
{selectedPlanGroup && (
|
||||
<StripeCheckout
|
||||
opened={isOpen}
|
||||
onClose={closeCheckout}
|
||||
planGroup={selectedPlanGroup}
|
||||
minimumSeats={minimumSeats}
|
||||
onSuccess={handlePaymentSuccess}
|
||||
onError={handlePaymentError}
|
||||
onLicenseActivated={handleLicenseActivated}
|
||||
hostedCheckoutSuccess={hostedCheckoutSuccess}
|
||||
/>
|
||||
)}
|
||||
</CheckoutContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useCheckout = (): CheckoutContextValue => {
|
||||
const context = useContext(CheckoutContext);
|
||||
if (!context) {
|
||||
throw new Error('useCheckout must be used within CheckoutProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
|
||||
import licenseService, { LicenseInfo } from '@app/services/licenseService';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
|
||||
interface LicenseContextValue {
|
||||
licenseInfo: LicenseInfo | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetchLicense: () => Promise<void>;
|
||||
}
|
||||
|
||||
const LicenseContext = createContext<LicenseContextValue | undefined>(undefined);
|
||||
|
||||
interface LicenseProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const LicenseProvider: React.FC<LicenseProviderProps> = ({ children }) => {
|
||||
const { config } = useAppConfig();
|
||||
const [licenseInfo, setLicenseInfo] = useState<LicenseInfo | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refetchLicense = useCallback(async () => {
|
||||
// Only fetch license info if user is an admin
|
||||
if (!config?.isAdmin) {
|
||||
console.debug('[LicenseContext] User is not an admin, skipping license fetch');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const info = await licenseService.getLicenseInfo();
|
||||
setLicenseInfo(info);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch license info';
|
||||
console.error('Error fetching license info:', errorMessage);
|
||||
setError(errorMessage);
|
||||
setLicenseInfo(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [config?.isAdmin]);
|
||||
|
||||
// Fetch license info when config changes (only if user is admin)
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
refetchLicense();
|
||||
}
|
||||
}, [config, refetchLicense]);
|
||||
|
||||
const contextValue: LicenseContextValue = {
|
||||
licenseInfo,
|
||||
loading,
|
||||
error,
|
||||
refetchLicense,
|
||||
};
|
||||
|
||||
return (
|
||||
<LicenseContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</LicenseContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useLicense = (): LicenseContextValue => {
|
||||
const context = useContext(LicenseContext);
|
||||
if (!context) {
|
||||
throw new Error('useLicense must be used within LicenseProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import licenseService, {
|
||||
PlanTier,
|
||||
PlansResponse,
|
||||
} from '@app/services/licenseService';
|
||||
|
||||
export interface UsePlansReturn {
|
||||
plans: PlanTier[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const usePlans = (currency: string = 'gbp'): UsePlansReturn => {
|
||||
const [plans, setPlans] = useState<PlanTier[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchPlans = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const data: PlansResponse = await licenseService.getPlans(currency);
|
||||
setPlans(data.plans);
|
||||
} catch (err) {
|
||||
console.error('Error fetching plans:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch plans');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlans();
|
||||
}, [currency]);
|
||||
|
||||
return {
|
||||
plans,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchPlans,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import AuthCallback from '@app/routes/AuthCallback';
|
||||
import { springAuth } from '@app/auth/springAuthClient';
|
||||
|
||||
// Mock springAuth
|
||||
vi.mock('@app/auth/springAuthClient', () => ({
|
||||
springAuth: {
|
||||
getSession: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useNavigate
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
describe('AuthCallback', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
// Reset window.location.hash
|
||||
window.location.hash = '';
|
||||
});
|
||||
|
||||
it('should extract JWT from URL hash and validate it', async () => {
|
||||
const mockToken = 'oauth-jwt-token';
|
||||
const mockUser = {
|
||||
id: '123',
|
||||
email: 'oauth@example.com',
|
||||
username: 'oauthuser',
|
||||
role: 'USER',
|
||||
};
|
||||
|
||||
// Set URL hash with access token
|
||||
window.location.hash = `#access_token=${mockToken}`;
|
||||
|
||||
// Mock successful session validation
|
||||
vi.mocked(springAuth.getSession).mockResolvedValueOnce({
|
||||
data: {
|
||||
session: {
|
||||
user: mockUser,
|
||||
access_token: mockToken,
|
||||
expires_in: 3600,
|
||||
expires_at: Date.now() + 3600000,
|
||||
},
|
||||
},
|
||||
error: null,
|
||||
});
|
||||
|
||||
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<AuthCallback />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify JWT was stored
|
||||
expect(localStorage.getItem('stirling_jwt')).toBe(mockToken);
|
||||
|
||||
// Verify jwt-available event was dispatched
|
||||
expect(dispatchEventSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'jwt-available' })
|
||||
);
|
||||
|
||||
// Verify getSession was called to validate token
|
||||
expect(springAuth.getSession).toHaveBeenCalled();
|
||||
|
||||
// Verify navigation to home
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true });
|
||||
});
|
||||
});
|
||||
|
||||
it('should redirect to login when no access token in hash', async () => {
|
||||
// No hash or empty hash
|
||||
window.location.hash = '';
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<AuthCallback />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/login', {
|
||||
replace: true,
|
||||
state: { error: 'OAuth login failed - no token received.' },
|
||||
});
|
||||
expect(localStorage.getItem('stirling_jwt')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('should redirect to login when token validation fails', async () => {
|
||||
const invalidToken = 'invalid-oauth-token';
|
||||
window.location.hash = `#access_token=${invalidToken}`;
|
||||
|
||||
// Mock failed session validation
|
||||
vi.mocked(springAuth.getSession).mockResolvedValueOnce({
|
||||
data: { session: null },
|
||||
error: { message: 'Invalid token' },
|
||||
});
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<AuthCallback />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// JWT should be stored initially
|
||||
expect(localStorage.getItem('stirling_jwt')).toBeNull(); // Cleared after validation failure
|
||||
|
||||
// Verify redirect to login
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/login', {
|
||||
replace: true,
|
||||
state: { error: 'OAuth login failed - invalid token.' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const mockToken = 'error-token';
|
||||
window.location.hash = `#access_token=${mockToken}`;
|
||||
|
||||
// Mock getSession throwing error
|
||||
vi.mocked(springAuth.getSession).mockRejectedValueOnce(
|
||||
new Error('Network error')
|
||||
);
|
||||
|
||||
render(
|
||||
<BrowserRouter>
|
||||
<AuthCallback />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/login', {
|
||||
replace: true,
|
||||
state: { error: 'OAuth login failed. Please try again.' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should display loading state while processing', () => {
|
||||
window.location.hash = '#access_token=processing-token';
|
||||
|
||||
vi.mocked(springAuth.getSession).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
data: { session: null },
|
||||
error: { message: 'Token expired' },
|
||||
}),
|
||||
100
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const { getByText } = render(
|
||||
<BrowserRouter>
|
||||
<AuthCallback />
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
expect(getByText('Completing authentication...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { springAuth } from '@app/auth/springAuthClient';
|
||||
|
||||
/**
|
||||
* OAuth Callback Handler
|
||||
@@ -11,7 +11,6 @@ import { useAuth } from '@app/auth/UseSession';
|
||||
*/
|
||||
export default function AuthCallback() {
|
||||
const navigate = useNavigate();
|
||||
const { refreshSession } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
@@ -37,12 +36,23 @@ export default function AuthCallback() {
|
||||
console.log('[AuthCallback] JWT stored in localStorage');
|
||||
|
||||
// Dispatch custom event for other components to react to JWT availability
|
||||
window.dispatchEvent(new CustomEvent('jwt-available'))
|
||||
window.dispatchEvent(new CustomEvent('jwt-available'));
|
||||
|
||||
// Refresh session to load user info into state
|
||||
await refreshSession();
|
||||
// Validate the token and load user info
|
||||
// This calls /api/v1/auth/me with the JWT to get user details
|
||||
const { data, error } = await springAuth.getSession();
|
||||
|
||||
console.log('[AuthCallback] Session refreshed, redirecting to home');
|
||||
if (error || !data.session) {
|
||||
console.error('[AuthCallback] Failed to validate token:', error);
|
||||
localStorage.removeItem('stirling_jwt');
|
||||
navigate('/login', {
|
||||
replace: true,
|
||||
state: { error: 'OAuth login failed - invalid token.' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[AuthCallback] Token validated, redirecting to home');
|
||||
|
||||
// Clear the hash from URL and redirect to home page
|
||||
navigate('/', { replace: true });
|
||||
@@ -56,7 +66,7 @@ export default function AuthCallback() {
|
||||
};
|
||||
|
||||
handleCallback();
|
||||
}, [navigate, refreshSession]);
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { BrowserRouter, MemoryRouter } from 'react-router-dom';
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import Login from '@app/routes/Login';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import { springAuth } from '@app/auth/springAuthClient';
|
||||
|
||||
// Mock i18n to return fallback text
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, fallback?: string | Record<string, unknown>) => {
|
||||
if (typeof fallback === 'string') return fallback;
|
||||
return key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useAuth hook
|
||||
vi.mock('@app/auth/UseSession', () => ({
|
||||
useAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock springAuth
|
||||
vi.mock('@app/auth/springAuthClient', () => ({
|
||||
springAuth: {
|
||||
signInWithPassword: vi.fn(),
|
||||
signInWithOAuth: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock useDocumentMeta
|
||||
vi.mock('@app/hooks/useDocumentMeta', () => ({
|
||||
useDocumentMeta: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock fetch for provider list
|
||||
global.fetch = vi.fn();
|
||||
|
||||
const mockNavigate = vi.fn();
|
||||
vi.mock('react-router-dom', async () => {
|
||||
const actual = await vi.importActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useNavigate: () => mockNavigate,
|
||||
};
|
||||
});
|
||||
|
||||
// Test wrapper with MantineProvider
|
||||
const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<MantineProvider>{children}</MantineProvider>
|
||||
);
|
||||
|
||||
describe('Login', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Default auth state - not logged in
|
||||
vi.mocked(useAuth).mockReturnValue({
|
||||
session: null,
|
||||
user: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
signOut: vi.fn(),
|
||||
refreshSession: vi.fn(),
|
||||
});
|
||||
|
||||
// Mock fetch for login UI data
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
enableLogin: true,
|
||||
providerList: {},
|
||||
}),
|
||||
} as Response);
|
||||
});
|
||||
|
||||
it('should render login form', async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<BrowserRouter>
|
||||
<Login />
|
||||
</BrowserRouter>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// Check for login form elements - use id since it's more reliable
|
||||
const emailInput = document.getElementById('email');
|
||||
expect(emailInput).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('should redirect authenticated user to home', async () => {
|
||||
const mockSession = {
|
||||
user: {
|
||||
id: '123',
|
||||
email: 'test@example.com',
|
||||
username: 'testuser',
|
||||
role: 'USER',
|
||||
},
|
||||
access_token: 'mock-token',
|
||||
expires_in: 3600,
|
||||
};
|
||||
|
||||
vi.mocked(useAuth).mockReturnValue({
|
||||
session: mockSession,
|
||||
user: mockSession.user,
|
||||
loading: false,
|
||||
error: null,
|
||||
signOut: vi.fn(),
|
||||
refreshSession: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<BrowserRouter>
|
||||
<Login />
|
||||
</BrowserRouter>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/', { replace: true });
|
||||
});
|
||||
});
|
||||
|
||||
it('should show loading state while auth is loading', () => {
|
||||
vi.mocked(useAuth).mockReturnValue({
|
||||
session: null,
|
||||
user: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
signOut: vi.fn(),
|
||||
refreshSession: vi.fn(),
|
||||
});
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<BrowserRouter>
|
||||
<Login />
|
||||
</BrowserRouter>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Component shouldn't redirect or show form while loading
|
||||
expect(mockNavigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle email/password login', async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockUser = {
|
||||
id: '123',
|
||||
email: 'test@example.com',
|
||||
username: 'test@example.com',
|
||||
role: 'USER',
|
||||
};
|
||||
|
||||
const mockSession = {
|
||||
user: mockUser,
|
||||
access_token: 'new-token',
|
||||
expires_in: 3600,
|
||||
};
|
||||
|
||||
vi.mocked(springAuth.signInWithPassword).mockResolvedValueOnce({
|
||||
user: mockUser,
|
||||
session: mockSession,
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<BrowserRouter>
|
||||
<Login />
|
||||
</BrowserRouter>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
// Wait for form to load
|
||||
await waitFor(() => {
|
||||
const emailInput = document.getElementById('email');
|
||||
expect(emailInput).toBeTruthy();
|
||||
const passwordInput = document.getElementById('password');
|
||||
expect(passwordInput).toBeTruthy();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Fill in form using getElementById
|
||||
const emailInput = document.getElementById('email') as HTMLInputElement;
|
||||
const passwordInput = document.getElementById('password') as HTMLInputElement;
|
||||
|
||||
if (!emailInput || !passwordInput) {
|
||||
throw new Error('Form inputs not found');
|
||||
}
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'password123');
|
||||
|
||||
// Submit form - use a more flexible query
|
||||
// Look for button with type="submit" in the form
|
||||
const submitButton = await waitFor(() => {
|
||||
const buttons = screen.queryAllByRole('button');
|
||||
const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit');
|
||||
if (!submitBtn) {
|
||||
throw new Error('Submit button not found');
|
||||
}
|
||||
return submitBtn;
|
||||
}, { timeout: 5000 });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(springAuth.signInWithPassword).toHaveBeenCalledWith({
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error on failed login', async () => {
|
||||
const user = userEvent.setup();
|
||||
const errorMessage = 'Invalid credentials';
|
||||
|
||||
vi.mocked(springAuth.signInWithPassword).mockResolvedValueOnce({
|
||||
user: null,
|
||||
session: null,
|
||||
error: { message: errorMessage },
|
||||
});
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<BrowserRouter>
|
||||
<Login />
|
||||
</BrowserRouter>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const emailInput = document.getElementById('email');
|
||||
const passwordInput = document.getElementById('password');
|
||||
expect(emailInput).toBeTruthy();
|
||||
expect(passwordInput).toBeTruthy();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
const emailInput = document.getElementById('email') as HTMLInputElement;
|
||||
const passwordInput = document.getElementById('password') as HTMLInputElement;
|
||||
|
||||
await user.type(emailInput, 'wrong@example.com');
|
||||
await user.type(passwordInput, 'wrongpassword');
|
||||
|
||||
const submitButton = await waitFor(() => {
|
||||
const buttons = screen.queryAllByRole('button');
|
||||
const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit');
|
||||
if (!submitBtn) {
|
||||
throw new Error('Submit button not found');
|
||||
}
|
||||
return submitBtn;
|
||||
}, { timeout: 5000 });
|
||||
await user.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(errorMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate empty email and password', async () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<BrowserRouter>
|
||||
<Login />
|
||||
</BrowserRouter>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.getElementById('email')).toBeTruthy();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
// Find the submit button
|
||||
const submitButton = await waitFor(() => {
|
||||
const buttons = screen.queryAllByRole('button');
|
||||
const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit');
|
||||
if (!submitBtn) {
|
||||
throw new Error('Submit button not found');
|
||||
}
|
||||
return submitBtn;
|
||||
}, { timeout: 5000 });
|
||||
|
||||
// Button should be disabled when email/password are empty
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// Verify sign in was not called
|
||||
expect(springAuth.signInWithPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should display session expired message from URL param', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MemoryRouter initialEntries={['/login?expired=true']}>
|
||||
<Login />
|
||||
</MemoryRouter>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/session.*expired/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display account created success message', () => {
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MemoryRouter initialEntries={['/login?messageType=accountCreated']}>
|
||||
<Login />
|
||||
</MemoryRouter>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/account created/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should prefill email from query param', () => {
|
||||
const email = 'prefilled@example.com';
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<MemoryRouter initialEntries={[`/login?email=${email}`]}>
|
||||
<Login />
|
||||
</MemoryRouter>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
waitFor(() => {
|
||||
const emailInput = document.getElementById('email') as HTMLInputElement;
|
||||
expect(emailInput.value).toBe(email);
|
||||
});
|
||||
});
|
||||
|
||||
it('should redirect to home when login disabled', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
enableLogin: false,
|
||||
providerList: {},
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<BrowserRouter>
|
||||
<Login />
|
||||
</BrowserRouter>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNavigate).toHaveBeenCalledWith('/');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle OAuth provider click', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
enableLogin: true,
|
||||
providerList: {
|
||||
'/oauth2/authorization/github': 'GitHub',
|
||||
},
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
vi.mocked(springAuth.signInWithOAuth).mockResolvedValueOnce({
|
||||
error: null,
|
||||
});
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<BrowserRouter>
|
||||
<Login />
|
||||
</BrowserRouter>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const githubButton = screen.queryByText(/github/i);
|
||||
if (githubButton) {
|
||||
expect(githubButton).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
// Since OAuth buttons might be dynamically rendered based on config,
|
||||
// we just verify the mock is set up correctly
|
||||
expect(springAuth.signInWithOAuth).toBeDefined();
|
||||
});
|
||||
|
||||
it('should show email form by default when no SSO providers', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
enableLogin: true,
|
||||
providerList: {}, // No providers
|
||||
}),
|
||||
} as Response);
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<BrowserRouter>
|
||||
<Login />
|
||||
</BrowserRouter>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.getElementById('email')).toBeInTheDocument();
|
||||
expect(document.getElementById('password')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable submit button while signing in', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
vi.mocked(springAuth.signInWithPassword).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
user: null,
|
||||
session: null,
|
||||
error: { message: 'Error' },
|
||||
}),
|
||||
100
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
render(
|
||||
<TestWrapper>
|
||||
<BrowserRouter>
|
||||
<Login />
|
||||
</BrowserRouter>
|
||||
</TestWrapper>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const emailInput = document.getElementById('email');
|
||||
const passwordInput = document.getElementById('password');
|
||||
expect(emailInput).toBeTruthy();
|
||||
expect(passwordInput).toBeTruthy();
|
||||
}, { timeout: 3000 });
|
||||
|
||||
const emailInput = document.getElementById('email') as HTMLInputElement;
|
||||
const passwordInput = document.getElementById('password') as HTMLInputElement;
|
||||
|
||||
await user.type(emailInput, 'test@example.com');
|
||||
await user.type(passwordInput, 'password123');
|
||||
|
||||
const submitButton = await waitFor(() => {
|
||||
const buttons = screen.queryAllByRole('button');
|
||||
const submitBtn = buttons.find(btn => btn.getAttribute('type') === 'submit');
|
||||
if (!submitBtn) {
|
||||
throw new Error('Submit button not found');
|
||||
}
|
||||
return submitBtn;
|
||||
}, { timeout: 5000 });
|
||||
await user.click(submitButton);
|
||||
|
||||
// Button should be disabled while signing in
|
||||
expect(submitButton).toBeDisabled();
|
||||
|
||||
// Wait for completion
|
||||
await waitFor(() => {
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,14 @@ export default function Login() {
|
||||
const [hasSSOProviders, setHasSSOProviders] = useState(false);
|
||||
const [_enableLogin, setEnableLogin] = useState<boolean | null>(null);
|
||||
|
||||
// Redirect immediately if user has valid session (JWT already validated by AuthProvider)
|
||||
useEffect(() => {
|
||||
if (!loading && session) {
|
||||
console.debug('[Login] User already authenticated, redirecting to home');
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
}, [session, loading, navigate]);
|
||||
|
||||
// Fetch enabled SSO providers and login config from backend
|
||||
useEffect(() => {
|
||||
const fetchProviders = async () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDocumentMeta } from '@app/hooks/useDocumentMeta';
|
||||
import { useAuth } from '@app/auth/UseSession';
|
||||
import AuthLayout from '@app/routes/authShared/AuthLayout';
|
||||
import '@app/routes/authShared/auth.css';
|
||||
import { BASE_PATH } from '@app/constants/app';
|
||||
@@ -17,6 +18,7 @@ import { useAuthService } from '@app/routes/signup/AuthService';
|
||||
export default function Signup() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { session, loading } = useAuth();
|
||||
const [isSigningUp, setIsSigningUp] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [email, setEmail] = useState('');
|
||||
@@ -24,6 +26,14 @@ export default function Signup() {
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [fieldErrors, setFieldErrors] = useState<SignupFieldErrors>({});
|
||||
|
||||
// Redirect immediately if user has valid session (JWT already validated by AuthProvider)
|
||||
useEffect(() => {
|
||||
if (!loading && session) {
|
||||
console.debug('[Signup] User already authenticated, redirecting to home');
|
||||
navigate('/', { replace: true });
|
||||
}
|
||||
}, [session, loading, navigate]);
|
||||
|
||||
const baseUrl = window.location.origin + BASE_PATH;
|
||||
|
||||
// Set document meta
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { supabase, isSupabaseConfigured } from '@app/services/supabaseClient';
|
||||
import { getCheckoutMode } from '@app/utils/protocolDetection';
|
||||
import { PLAN_FEATURES, PLAN_HIGHLIGHTS } from '@app/constants/planConstants';
|
||||
|
||||
export interface PlanFeature {
|
||||
name: string;
|
||||
included: boolean;
|
||||
}
|
||||
|
||||
export interface PlanTier {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
period: string;
|
||||
popular?: boolean;
|
||||
features: PlanFeature[];
|
||||
highlights: readonly string[];
|
||||
isContactOnly?: boolean;
|
||||
seatPrice?: number; // Per-seat price for enterprise plans
|
||||
requiresSeats?: boolean; // Flag indicating seat selection is needed
|
||||
lookupKey: string; // Stripe lookup key for this plan
|
||||
}
|
||||
|
||||
export interface PlanTierGroup {
|
||||
tier: 'free' | 'server' | 'enterprise';
|
||||
name: string;
|
||||
monthly: PlanTier | null;
|
||||
yearly: PlanTier | null;
|
||||
features: PlanFeature[];
|
||||
highlights: readonly string[];
|
||||
popular?: boolean;
|
||||
}
|
||||
|
||||
export interface PlansResponse {
|
||||
plans: PlanTier[];
|
||||
}
|
||||
|
||||
export interface CheckoutSessionRequest {
|
||||
lookup_key: string; // Stripe lookup key (e.g., 'selfhosted:server:monthly')
|
||||
installation_id?: string; // Installation ID from backend (MAC-based fingerprint)
|
||||
current_license_key?: string; // Current license key for upgrades
|
||||
requires_seats?: boolean; // Whether to add adjustable seat pricing
|
||||
seat_count?: number; // Initial number of seats for enterprise plans (user can adjust in Stripe UI)
|
||||
successUrl?: string;
|
||||
cancelUrl?: string;
|
||||
}
|
||||
|
||||
export interface CheckoutSessionResponse {
|
||||
clientSecret: string;
|
||||
sessionId: string;
|
||||
url?: string; // URL for hosted checkout (when not using HTTPS)
|
||||
}
|
||||
|
||||
export interface BillingPortalResponse {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface InstallationIdResponse {
|
||||
installationId: string;
|
||||
}
|
||||
|
||||
export interface LicenseKeyResponse {
|
||||
status: 'ready' | 'pending';
|
||||
license_key?: string;
|
||||
email?: string;
|
||||
plan?: string;
|
||||
}
|
||||
|
||||
export interface LicenseInfo {
|
||||
licenseType: 'NORMAL' | 'PRO' | 'ENTERPRISE';
|
||||
enabled: boolean;
|
||||
maxUsers: number;
|
||||
hasKey: boolean;
|
||||
licenseKey?: string; // The actual license key (for upgrades)
|
||||
}
|
||||
|
||||
export interface LicenseSaveResponse {
|
||||
success: boolean;
|
||||
licenseType?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Currency symbol mapping
|
||||
const getCurrencySymbol = (currency: string): string => {
|
||||
const currencySymbols: { [key: string]: string } = {
|
||||
'gbp': '£',
|
||||
'usd': '$',
|
||||
'eur': '€',
|
||||
'cny': '¥',
|
||||
'inr': '₹',
|
||||
'brl': 'R$',
|
||||
'idr': 'Rp'
|
||||
};
|
||||
return currencySymbols[currency.toLowerCase()] || currency.toUpperCase();
|
||||
};
|
||||
|
||||
// Self-hosted plan lookup keys
|
||||
const SELF_HOSTED_LOOKUP_KEYS = [
|
||||
'selfhosted:server:monthly',
|
||||
'selfhosted:server:yearly',
|
||||
'selfhosted:enterpriseseat:monthly',
|
||||
'selfhosted:enterpriseseat:yearly',
|
||||
];
|
||||
|
||||
const licenseService = {
|
||||
/**
|
||||
* Get available plans with pricing for the specified currency
|
||||
*/
|
||||
async getPlans(currency: string = 'gbp'): Promise<PlansResponse> {
|
||||
try {
|
||||
// Check if Supabase is configured
|
||||
if (!isSupabaseConfigured || !supabase) {
|
||||
throw new Error('Supabase is not configured. Please use static plans instead.');
|
||||
}
|
||||
|
||||
// Fetch all self-hosted prices from Stripe
|
||||
const { data, error } = await supabase.functions.invoke<{
|
||||
prices: Record<string, {
|
||||
unit_amount: number;
|
||||
currency: string;
|
||||
lookup_key: string;
|
||||
}>;
|
||||
missing: string[];
|
||||
}>('stripe-price-lookup', {
|
||||
body: {
|
||||
lookup_keys: SELF_HOSTED_LOOKUP_KEYS,
|
||||
currency
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to fetch plans: ${error.message}`);
|
||||
}
|
||||
|
||||
if (!data || !data.prices) {
|
||||
throw new Error('No pricing data returned');
|
||||
}
|
||||
|
||||
// Log missing prices for debugging
|
||||
if (data.missing && data.missing.length > 0) {
|
||||
console.warn('Missing Stripe prices for lookup keys:', data.missing, 'in currency:', currency);
|
||||
}
|
||||
|
||||
// Build price map for easy access
|
||||
const priceMap = new Map<string, { unit_amount: number; currency: string }>();
|
||||
for (const [lookupKey, priceData] of Object.entries(data.prices)) {
|
||||
priceMap.set(lookupKey, {
|
||||
unit_amount: priceData.unit_amount,
|
||||
currency: priceData.currency
|
||||
});
|
||||
}
|
||||
|
||||
const currencySymbol = getCurrencySymbol(currency);
|
||||
|
||||
// Helper to get price info
|
||||
const getPriceInfo = (lookupKey: string, fallback: number = 0) => {
|
||||
const priceData = priceMap.get(lookupKey);
|
||||
return priceData ? priceData.unit_amount / 100 : fallback;
|
||||
};
|
||||
|
||||
// Build plan tiers
|
||||
const plans: PlanTier[] = [
|
||||
{
|
||||
id: 'selfhosted:server:monthly',
|
||||
lookupKey: 'selfhosted:server:monthly',
|
||||
name: 'Server - Monthly',
|
||||
price: getPriceInfo('selfhosted:server:monthly'),
|
||||
currency: currencySymbol,
|
||||
period: '/month',
|
||||
popular: false,
|
||||
features: PLAN_FEATURES.SERVER,
|
||||
highlights: PLAN_HIGHLIGHTS.SERVER_MONTHLY
|
||||
},
|
||||
{
|
||||
id: 'selfhosted:server:yearly',
|
||||
lookupKey: 'selfhosted:server:yearly',
|
||||
name: 'Server - Yearly',
|
||||
price: getPriceInfo('selfhosted:server:yearly'),
|
||||
currency: currencySymbol,
|
||||
period: '/year',
|
||||
popular: true,
|
||||
features: PLAN_FEATURES.SERVER,
|
||||
highlights: PLAN_HIGHLIGHTS.SERVER_YEARLY
|
||||
},
|
||||
{
|
||||
id: 'selfhosted:enterprise:monthly',
|
||||
lookupKey: 'selfhosted:server:monthly',
|
||||
name: 'Enterprise - Monthly',
|
||||
price: getPriceInfo('selfhosted:server:monthly'),
|
||||
seatPrice: getPriceInfo('selfhosted:enterpriseseat:monthly'),
|
||||
currency: currencySymbol,
|
||||
period: '/month',
|
||||
popular: false,
|
||||
requiresSeats: true,
|
||||
features: PLAN_FEATURES.ENTERPRISE,
|
||||
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_MONTHLY
|
||||
},
|
||||
{
|
||||
id: 'selfhosted:enterprise:yearly',
|
||||
lookupKey: 'selfhosted:server:yearly',
|
||||
name: 'Enterprise - Yearly',
|
||||
price: getPriceInfo('selfhosted:server:yearly'),
|
||||
seatPrice: getPriceInfo('selfhosted:enterpriseseat:yearly'),
|
||||
currency: currencySymbol,
|
||||
period: '/year',
|
||||
popular: false,
|
||||
requiresSeats: true,
|
||||
features: PLAN_FEATURES.ENTERPRISE,
|
||||
highlights: PLAN_HIGHLIGHTS.ENTERPRISE_YEARLY
|
||||
},
|
||||
];
|
||||
|
||||
// Filter out plans with missing prices (price === 0 means Stripe price not found)
|
||||
const validPlans = plans.filter(plan => plan.price > 0);
|
||||
|
||||
if (validPlans.length < plans.length) {
|
||||
const missingPlans = plans.filter(plan => plan.price === 0).map(p => p.id);
|
||||
console.warn('Filtered out plans with missing prices:', missingPlans);
|
||||
}
|
||||
|
||||
// Add Free plan (static definition)
|
||||
const freePlan: PlanTier = {
|
||||
id: 'free',
|
||||
lookupKey: 'free',
|
||||
name: 'Free',
|
||||
price: 0,
|
||||
currency: currencySymbol,
|
||||
period: '',
|
||||
popular: false,
|
||||
features: PLAN_FEATURES.FREE,
|
||||
highlights: PLAN_HIGHLIGHTS.FREE
|
||||
};
|
||||
|
||||
const allPlans = [freePlan, ...validPlans];
|
||||
|
||||
return {
|
||||
plans: allPlans
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching plans:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Group plans by tier for display (Free, Server, Enterprise)
|
||||
*/
|
||||
groupPlansByTier(plans: PlanTier[]): PlanTierGroup[] {
|
||||
const groups: PlanTierGroup[] = [];
|
||||
|
||||
// Free tier
|
||||
const freePlan = plans.find(p => p.id === 'free');
|
||||
if (freePlan) {
|
||||
groups.push({
|
||||
tier: 'free',
|
||||
name: 'Free',
|
||||
monthly: freePlan,
|
||||
yearly: null,
|
||||
features: freePlan.features,
|
||||
highlights: freePlan.highlights,
|
||||
popular: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Server tier
|
||||
const serverMonthly = plans.find(p => p.lookupKey === 'selfhosted:server:monthly');
|
||||
const serverYearly = plans.find(p => p.lookupKey === 'selfhosted:server:yearly');
|
||||
if (serverMonthly || serverYearly) {
|
||||
groups.push({
|
||||
tier: 'server',
|
||||
name: 'Server',
|
||||
monthly: serverMonthly || null,
|
||||
yearly: serverYearly || null,
|
||||
features: (serverMonthly || serverYearly)!.features,
|
||||
highlights: (serverMonthly || serverYearly)!.highlights,
|
||||
popular: serverYearly?.popular || serverMonthly?.popular || false,
|
||||
});
|
||||
}
|
||||
|
||||
// Enterprise tier (uses server pricing + seats)
|
||||
const enterpriseMonthly = plans.find(p => p.id === 'selfhosted:enterprise:monthly');
|
||||
const enterpriseYearly = plans.find(p => p.id === 'selfhosted:enterprise:yearly');
|
||||
if (enterpriseMonthly || enterpriseYearly) {
|
||||
groups.push({
|
||||
tier: 'enterprise',
|
||||
name: 'Enterprise',
|
||||
monthly: enterpriseMonthly || null,
|
||||
yearly: enterpriseYearly || null,
|
||||
features: (enterpriseMonthly || enterpriseYearly)!.features,
|
||||
highlights: (enterpriseMonthly || enterpriseYearly)!.highlights,
|
||||
popular: false,
|
||||
});
|
||||
}
|
||||
|
||||
return groups;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a Stripe checkout session for upgrading
|
||||
*/
|
||||
async createCheckoutSession(request: CheckoutSessionRequest): Promise<CheckoutSessionResponse> {
|
||||
// Check if Supabase is configured
|
||||
if (!isSupabaseConfigured || !supabase) {
|
||||
throw new Error('Supabase is not configured. Checkout is not available.');
|
||||
}
|
||||
|
||||
// Detect if HTTPS is available to determine checkout mode
|
||||
const checkoutMode = getCheckoutMode();
|
||||
const baseUrl = window.location.origin;
|
||||
const settingsUrl = `${baseUrl}/settings/adminPlan`;
|
||||
|
||||
const { data, error } = await supabase.functions.invoke('create-checkout', {
|
||||
body: {
|
||||
self_hosted: true,
|
||||
lookup_key: request.lookup_key,
|
||||
installation_id: request.installation_id,
|
||||
current_license_key: request.current_license_key,
|
||||
requires_seats: request.requires_seats,
|
||||
seat_count: request.seat_count || 1,
|
||||
callback_base_url: baseUrl,
|
||||
ui_mode: checkoutMode,
|
||||
// For hosted checkout, provide success/cancel URLs
|
||||
success_url: checkoutMode === 'hosted'
|
||||
? `${settingsUrl}?session_id={CHECKOUT_SESSION_ID}&payment_status=success`
|
||||
: undefined,
|
||||
cancel_url: checkoutMode === 'hosted'
|
||||
? `${settingsUrl}?payment_status=canceled`
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to create checkout session: ${error.message}`);
|
||||
}
|
||||
|
||||
return data as CheckoutSessionResponse;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a Stripe billing portal session for managing subscription
|
||||
* Uses license key for self-hosted authentication
|
||||
*/
|
||||
async createBillingPortalSession(returnUrl: string, licenseKey: string): Promise<BillingPortalResponse> {
|
||||
// Check if Supabase is configured
|
||||
if (!isSupabaseConfigured || !supabase) {
|
||||
throw new Error('Supabase is not configured. Billing portal is not available.');
|
||||
}
|
||||
|
||||
const { data, error} = await supabase.functions.invoke('manage-billing', {
|
||||
body: {
|
||||
return_url: returnUrl,
|
||||
license_key: licenseKey,
|
||||
self_hosted: true // Explicitly indicate self-hosted mode
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to create billing portal session: ${error.message}`);
|
||||
}
|
||||
|
||||
return data as BillingPortalResponse;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the installation ID from the backend (MAC-based fingerprint)
|
||||
*/
|
||||
async getInstallationId(): Promise<string> {
|
||||
try {
|
||||
const response = await apiClient.get('/api/v1/admin/installation-id');
|
||||
|
||||
const data: InstallationIdResponse = await response.data;
|
||||
return data.installationId;
|
||||
} catch (error) {
|
||||
console.error('Error fetching installation ID:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if license key is ready for the given installation ID
|
||||
*/
|
||||
async checkLicenseKey(installationId: string): Promise<LicenseKeyResponse> {
|
||||
// Check if Supabase is configured
|
||||
if (!isSupabaseConfigured || !supabase) {
|
||||
throw new Error('Supabase is not configured. License key lookup is not available.');
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.functions.invoke('get-license-key', {
|
||||
body: {
|
||||
installation_id: installationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to check license key: ${error.message}`);
|
||||
}
|
||||
|
||||
return data as LicenseKeyResponse;
|
||||
},
|
||||
|
||||
/**
|
||||
* Save license key to backend
|
||||
*/
|
||||
async saveLicenseKey(licenseKey: string): Promise<LicenseSaveResponse> {
|
||||
try {
|
||||
const response = await apiClient.post('/api/v1/admin/license-key', {
|
||||
licenseKey: licenseKey,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error saving license key:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current license information from backend
|
||||
*/
|
||||
async getLicenseInfo(): Promise<LicenseInfo> {
|
||||
try {
|
||||
const response = await apiClient.get('/api/v1/admin/license-info');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching license info:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Resync the current license with Keygen
|
||||
* Re-validates the existing license key and updates local settings
|
||||
*/
|
||||
async resyncLicense(): Promise<LicenseSaveResponse> {
|
||||
try {
|
||||
const response = await apiClient.post('/api/v1/admin/license/resync');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error resyncing license:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Map license type to plan tier
|
||||
* @param licenseInfo - Current license information
|
||||
* @returns Plan tier: 'free' | 'server' | 'enterprise'
|
||||
*/
|
||||
export const mapLicenseToTier = (licenseInfo: LicenseInfo | null): 'free' | 'server' | 'enterprise' | null => {
|
||||
if (!licenseInfo) return null;
|
||||
|
||||
// No license or NORMAL type = Free tier
|
||||
if (licenseInfo.licenseType === 'NORMAL' || !licenseInfo.enabled) {
|
||||
return 'free';
|
||||
}
|
||||
|
||||
// PRO type (no seats) = Server tier
|
||||
if (licenseInfo.licenseType === 'PRO') {
|
||||
return 'server';
|
||||
}
|
||||
|
||||
// ENTERPRISE type (with seats) = Enterprise tier
|
||||
if (licenseInfo.licenseType === 'ENTERPRISE' && licenseInfo.maxUsers > 0) {
|
||||
return 'enterprise';
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return 'free';
|
||||
};
|
||||
|
||||
export default licenseService;
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Shared utilities for license checkout completion
|
||||
* Used by both embedded and hosted checkout flows
|
||||
*/
|
||||
|
||||
import licenseService, { LicenseInfo } from '@app/services/licenseService';
|
||||
|
||||
/**
|
||||
* Result of license key polling
|
||||
*/
|
||||
export interface LicenseKeyPollResult {
|
||||
success: boolean;
|
||||
licenseKey?: string;
|
||||
error?: string;
|
||||
timedOut?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for license key polling
|
||||
*/
|
||||
export interface PollConfig {
|
||||
/** Check if component is still mounted (prevents state updates after unmount) */
|
||||
isMounted?: () => boolean;
|
||||
/** Callback for status changes during polling */
|
||||
onStatusChange?: (status: 'polling' | 'ready' | 'timeout') => void;
|
||||
/** Custom backoff intervals in milliseconds (default: [1000, 2000, 4000, 8000, 16000]) */
|
||||
backoffMs?: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll for license key with exponential backoff
|
||||
* Consolidates polling logic used by both embedded and hosted checkout
|
||||
*/
|
||||
export async function pollLicenseKeyWithBackoff(
|
||||
installationId: string,
|
||||
config: PollConfig = {}
|
||||
): Promise<LicenseKeyPollResult> {
|
||||
const {
|
||||
isMounted = () => true,
|
||||
onStatusChange,
|
||||
backoffMs = [1000, 2000, 4000, 8000, 16000],
|
||||
} = config;
|
||||
|
||||
let attemptIndex = 0;
|
||||
|
||||
onStatusChange?.('polling');
|
||||
console.log(`Starting license key polling for installation: ${installationId}`);
|
||||
|
||||
const poll = async (): Promise<LicenseKeyPollResult> => {
|
||||
// Check if component is still mounted
|
||||
if (!isMounted()) {
|
||||
console.log('Polling cancelled: component unmounted');
|
||||
return { success: false, error: 'Component unmounted' };
|
||||
}
|
||||
|
||||
const attemptNumber = attemptIndex + 1;
|
||||
console.log(`Polling attempt ${attemptNumber}/${backoffMs.length}`);
|
||||
|
||||
try {
|
||||
const response = await licenseService.checkLicenseKey(installationId);
|
||||
|
||||
// Check mounted after async operation
|
||||
if (!isMounted()) {
|
||||
return { success: false, error: 'Component unmounted' };
|
||||
}
|
||||
|
||||
if (response.status === 'ready' && response.license_key) {
|
||||
console.log('✅ License key ready!');
|
||||
onStatusChange?.('ready');
|
||||
return {
|
||||
success: true,
|
||||
licenseKey: response.license_key,
|
||||
};
|
||||
}
|
||||
|
||||
// License not ready yet, continue polling
|
||||
attemptIndex++;
|
||||
|
||||
if (attemptIndex >= backoffMs.length) {
|
||||
console.warn('⏱️ License polling timeout after all attempts');
|
||||
onStatusChange?.('timeout');
|
||||
return {
|
||||
success: false,
|
||||
timedOut: true,
|
||||
error: 'Polling timeout - license key not ready',
|
||||
};
|
||||
}
|
||||
|
||||
// Wait before next attempt
|
||||
const nextDelay = backoffMs[attemptIndex];
|
||||
console.log(`Retrying in ${nextDelay}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, nextDelay));
|
||||
|
||||
return poll();
|
||||
} catch (error) {
|
||||
console.error(`Polling attempt ${attemptNumber} failed:`, error);
|
||||
|
||||
if (!isMounted()) {
|
||||
return { success: false, error: 'Component unmounted' };
|
||||
}
|
||||
|
||||
attemptIndex++;
|
||||
|
||||
if (attemptIndex >= backoffMs.length) {
|
||||
console.error('Polling failed after all attempts');
|
||||
onStatusChange?.('timeout');
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Polling failed',
|
||||
};
|
||||
}
|
||||
|
||||
// Retry with exponential backoff even on error
|
||||
const nextDelay = backoffMs[attemptIndex];
|
||||
console.log(`Retrying after error in ${nextDelay}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, nextDelay));
|
||||
|
||||
return poll();
|
||||
}
|
||||
};
|
||||
|
||||
return poll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of license key activation
|
||||
*/
|
||||
export interface LicenseActivationResult {
|
||||
success: boolean;
|
||||
licenseType?: string;
|
||||
licenseInfo?: LicenseInfo;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a license key by saving it to the backend and fetching updated info
|
||||
* Used for NEW subscriptions where we have a new license key to save
|
||||
*/
|
||||
export async function activateLicenseKey(
|
||||
licenseKey: string,
|
||||
options: {
|
||||
/** Check if component is still mounted */
|
||||
isMounted?: () => boolean;
|
||||
/** Callback when license is activated with updated info */
|
||||
onActivated?: (licenseInfo: LicenseInfo) => void;
|
||||
} = {}
|
||||
): Promise<LicenseActivationResult> {
|
||||
const { isMounted = () => true, onActivated } = options;
|
||||
|
||||
try {
|
||||
console.log('Activating license key...');
|
||||
const saveResponse = await licenseService.saveLicenseKey(licenseKey);
|
||||
|
||||
if (!isMounted()) {
|
||||
return { success: false, error: 'Component unmounted' };
|
||||
}
|
||||
|
||||
if (saveResponse.success) {
|
||||
console.log(`License key activated: ${saveResponse.licenseType}`);
|
||||
|
||||
// Fetch updated license info
|
||||
try {
|
||||
const licenseInfo = await licenseService.getLicenseInfo();
|
||||
|
||||
if (!isMounted()) {
|
||||
return { success: false, error: 'Component unmounted' };
|
||||
}
|
||||
|
||||
onActivated?.(licenseInfo);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
licenseType: saveResponse.licenseType,
|
||||
licenseInfo,
|
||||
};
|
||||
} catch (infoError) {
|
||||
console.error('Error fetching license info after activation:', infoError);
|
||||
// Still return success since save succeeded
|
||||
return {
|
||||
success: true,
|
||||
licenseType: saveResponse.licenseType,
|
||||
error: 'Failed to fetch updated license info',
|
||||
};
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to save license key:', saveResponse.error);
|
||||
return {
|
||||
success: false,
|
||||
error: saveResponse.error || 'Failed to save license key',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error activating license key:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Activation failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resync existing license with Keygen
|
||||
* Used for UPGRADES where we already have a license key configured
|
||||
* Calls the dedicated resync endpoint instead of re-saving the same key
|
||||
*/
|
||||
export async function resyncExistingLicense(
|
||||
options: {
|
||||
/** Check if component is still mounted */
|
||||
isMounted?: () => boolean;
|
||||
/** Callback when license is resynced with updated info */
|
||||
onActivated?: (licenseInfo: LicenseInfo) => void;
|
||||
} = {}
|
||||
): Promise<LicenseActivationResult> {
|
||||
const { isMounted = () => true, onActivated } = options;
|
||||
|
||||
try {
|
||||
console.log('Resyncing existing license with Keygen...');
|
||||
const resyncResponse = await licenseService.resyncLicense();
|
||||
|
||||
if (!isMounted()) {
|
||||
return { success: false, error: 'Component unmounted' };
|
||||
}
|
||||
|
||||
if (resyncResponse.success) {
|
||||
console.log(`License resynced: ${resyncResponse.licenseType}`);
|
||||
|
||||
// Fetch updated license info
|
||||
try {
|
||||
const licenseInfo = await licenseService.getLicenseInfo();
|
||||
|
||||
if (!isMounted()) {
|
||||
return { success: false, error: 'Component unmounted' };
|
||||
}
|
||||
|
||||
onActivated?.(licenseInfo);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
licenseType: resyncResponse.licenseType,
|
||||
licenseInfo,
|
||||
};
|
||||
} catch (infoError) {
|
||||
console.error('Error fetching license info after resync:', infoError);
|
||||
// Still return success since resync succeeded
|
||||
return {
|
||||
success: true,
|
||||
licenseType: resyncResponse.licenseType,
|
||||
error: 'Failed to fetch updated license info',
|
||||
};
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to resync license:', resyncResponse.error);
|
||||
return {
|
||||
success: false,
|
||||
error: resyncResponse.error || 'Failed to resync license',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error resyncing license:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Resync failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Protocol detection utility for determining secure context
|
||||
* Used to decide between Embedded Checkout (HTTPS) and Hosted Checkout (HTTP)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check if the current context is secure (HTTPS or localhost)
|
||||
* @returns true if HTTPS or localhost, false if HTTP
|
||||
*/
|
||||
export function isSecureContext(): boolean {
|
||||
// Allow localhost for development (works with both HTTP and HTTPS)
|
||||
if (typeof window !== 'undefined') {
|
||||
// const hostname = window.location.hostname;
|
||||
const protocol = window.location.protocol;
|
||||
|
||||
// Localhost is considered secure for development
|
||||
// if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]') {
|
||||
// return true;
|
||||
// }
|
||||
|
||||
// Check if HTTPS
|
||||
return protocol === 'https:';
|
||||
}
|
||||
|
||||
// Default to false if window is not available (SSR context)
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate Stripe checkout UI mode based on current context
|
||||
* @returns 'embedded' for HTTPS/localhost, 'hosted' for HTTP
|
||||
*/
|
||||
export function getCheckoutMode(): 'embedded' | 'hosted' {
|
||||
return isSecureContext() ? 'embedded' : 'hosted';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Embedded Checkout can be used in current context
|
||||
* @returns true if secure context (HTTPS/localhost)
|
||||
*/
|
||||
export function canUseEmbeddedCheckout(): boolean {
|
||||
return isSecureContext();
|
||||
}
|
||||
Vendored
+2
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user