Compare commits

...
Author SHA1 Message Date
Reece Browne 22d41543eb Merge branch 'main' into bug/v2/improved-cache-busting 2025-12-11 11:14:17 +00:00
Ludy e26035c3b3 build(versioning): synchronize app version across Tauri and simulation configs (#5120)
# Description of Changes

- **What was changed**
- Added `groovy.json.JsonOutput` and `groovy.json.JsonSlurper` imports
to `build.gradle`.
- Introduced a reusable `writeIfChanged(File targetFile, String
newContent)` helper to avoid unnecessary file writes when content is
unchanged.
  - Added `updateTauriConfigVersion(String version)` to:
    - Parse `frontend/src-tauri/tauri.conf.json`.
    - Set the `version` field from `project.version`.
- Re-write the file as pretty-printed JSON (with a trailing line
separator) only if content actually changed.
- Added `updateSimulationVersion(File fileToUpdate, String version)` to:
- Locate the `appVersion: '<value>'` assignment via regex in simulation
files.
    - Replace the existing version with `project.version`.
- Fail the build with a clear `GradleException` if `appVersion` cannot
be found.
- Registered a new Gradle task `syncAppVersion` (group: `versioning`)
which:
    - Reads `project.version` as the canonical app version.
    - Updates `frontend/src-tauri/tauri.conf.json`.
- Updates `frontend/src/core/testing/serverExperienceSimulations.ts`.
- Updates
`frontend/src/proprietary/testing/serverExperienceSimulations.ts`.
- Updated the main `build` task so it now depends on `syncAppVersion` in
addition to `:stirling-pdf:bootJar` and `buildRestartHelper`.

- **Why the change was made**
- To ensure the desktop Tauri configuration and server experience
simulation configs consistently use the same application version as
defined in `project.version`.
- To remove manual version bumps in multiple files and eliminate the
risk of version mismatches between backend, desktop app, and
simulation/testing tooling.
- To minimize noise in commits and CI by only touching versioned files
when their content actually changes (using `writeIfChanged`).


---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-12-11 11:13:54 +00:00
ConnorYoh e474cc76ad Improved static upgrade flow (#5214)
<img width="996" height="621" alt="image"
src="https://github.com/user-attachments/assets/1ac87414-09ed-4307-8f7c-25984e0c89d1"
/>
<img width="608" height="351" alt="image"
src="https://github.com/user-attachments/assets/c271f75e-4844-4034-8905-007cc7ab1265"
/>
<img width="660" height="355" alt="image"
src="https://github.com/user-attachments/assets/34913b74-d4fa-418a-b098-fda48b41f0dd"
/>
<img width="1371" height="906" alt="image"
src="https://github.com/user-attachments/assets/35b61389-fd67-41b3-9969-e5409e53b362"
/>
<img width="639" height="450" alt="image"
src="https://github.com/user-attachments/assets/ae018bf3-0fcf-4221-892f-440d7325540a"
/>
<img width="963" height="599" alt="image"
src="https://github.com/user-attachments/assets/f6f67682-f43c-46f3-8632-16b209780b15"
/>
<img width="982" height="628" alt="image"
src="https://github.com/user-attachments/assets/45a7c171-3eb4-4271-a299-f3a6e78c1a52"
/>
2025-12-11 11:13:20 +00:00
Reece Browne 9bc4487b25 Merge branch 'main' into bug/v2/improved-cache-busting 2025-12-11 11:12:32 +00:00
Reece Browne 173f664bd0 Merge branch 'V2' into bug/v2/improved-cache-busting 2025-12-02 12:38:42 +00:00
Reece f5189c9bda Remove unnecessary 2025-12-01 17:29:25 +00:00
Reece af4286574c Improve cache busting 2025-12-01 16:37:53 +00:00
12 changed files with 998 additions and 405 deletions
+15 -2
View File
@@ -262,7 +262,13 @@ jobs:
strategy:
fail-fast: false
matrix:
docker-rev: ["docker/embedded/Dockerfile", "docker/embedded/Dockerfile.ultra-lite", "docker/embedded/Dockerfile.fat"]
include:
- docker-rev: docker/embedded/Dockerfile
artifact-suffix: Dockerfile
- docker-rev: docker/embedded/Dockerfile.ultra-lite
artifact-suffix: Dockerfile.ultra-lite
- docker-rev: docker/embedded/Dockerfile.fat
artifact-suffix: Dockerfile.fat
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
@@ -272,6 +278,13 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Free disk space on runner
run: |
echo "Disk space before cleanup:" && df -h
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /usr/local/share/boost
docker system prune -af || true
echo "Disk space after cleanup:" && df -h
- name: Set up JDK 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
@@ -313,7 +326,7 @@ jobs:
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: reports-docker-${{ matrix.docker-rev }}
name: reports-docker-${{ matrix.artifact-suffix }}
path: |
build/reports/tests/
build/test-results/
@@ -1,10 +1,14 @@
package stirling.software.SPDF.config;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.CacheControl;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import lombok.RequiredArgsConstructor;
@@ -25,6 +29,20 @@ public class WebMvcConfig implements WebMvcConfigurer {
registry.addInterceptor(endpointInterceptor);
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// Cache hashed assets (JS/CSS with content hashes) for 1 year
// These files have names like index-ChAS4tCC.js that change when content changes
registry.addResourceHandler("/assets/**")
.addResourceLocations("classpath:/static/assets/")
.setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic());
// Don't cache index.html - it needs to be fresh to reference latest hashed assets
registry.addResourceHandler("/index.html")
.addResourceLocations("classpath:/static/")
.setCacheControl(CacheControl.noCache().mustRevalidate());
}
@Override
public void addCorsMappings(CorsRegistry registry) {
// Check if running in Tauri mode
+48 -1
View File
@@ -12,6 +12,8 @@ plugins {
}
import com.github.jk1.license.render.*
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
ext {
springBootVersion = "3.5.6"
@@ -65,6 +67,51 @@ allprojects {
}
}
def writeIfChanged(File targetFile, String newContent) {
if (targetFile.getText('UTF-8') != newContent) {
targetFile.write(newContent, 'UTF-8')
}
}
def updateTauriConfigVersion(String version) {
File tauriConfig = file('frontend/src-tauri/tauri.conf.json')
def parsed = new JsonSlurper().parse(tauriConfig)
parsed.version = version
def formatted = JsonOutput.prettyPrint(JsonOutput.toJson(parsed)) + System.lineSeparator()
writeIfChanged(tauriConfig, formatted)
}
def updateSimulationVersion(File fileToUpdate, String version) {
def content = fileToUpdate.getText('UTF-8')
def matcher = content =~ /(appVersion:\s*')([^']*)(')/
if (!matcher.find()) {
throw new GradleException("Could not locate appVersion in ${fileToUpdate} for synchronization")
}
def updatedContent = matcher.replaceFirst("${matcher.group(1)}${version}${matcher.group(3)}")
writeIfChanged(fileToUpdate, updatedContent)
}
tasks.register('syncAppVersion') {
group = 'versioning'
description = 'Synchronizes app version across desktop and simulation configs.'
doLast {
def appVersion = project.version.toString()
println "Synchronizing application version to ${appVersion}"
updateTauriConfigVersion(appVersion)
[
'frontend/src/core/testing/serverExperienceSimulations.ts',
'frontend/src/proprietary/testing/serverExperienceSimulations.ts'
].each { path ->
updateSimulationVersion(file(path), appVersion)
}
}
}
tasks.register('writeVersion', WriteProperties) {
destinationFile = layout.projectDirectory.file('app/common/src/main/resources/version.properties')
println "Writing version.properties to ${destinationFile.get().asFile.path}"
@@ -314,7 +361,7 @@ tasks.named('bootRun') {
tasks.named('build') {
group = 'build'
description = 'Delegates to :stirling-pdf:bootJar'
dependsOn ':stirling-pdf:bootJar', 'buildRestartHelper'
dependsOn ':stirling-pdf:bootJar', 'buildRestartHelper', 'syncAppVersion'
doFirst {
println "Delegating to :stirling-pdf:bootJar"
@@ -5569,6 +5569,28 @@ contactSales = "Contact Sales"
contactToUpgrade = "Contact us to upgrade or customize your plan"
maxUsers = "Max Users"
upTo = "Up to"
getLicense = "Get Server License"
upgradeToEnterprise = "Upgrade to Enterprise"
selectPeriod = "Select Billing Period"
monthlyBilling = "Monthly Billing"
yearlyBilling = "Yearly Billing"
checkoutOpened = "Checkout Opened"
checkoutInstructions = "Complete your purchase in the Stripe tab. After payment, return here and refresh the page to activate your license. You will also receive an email with your license key."
activateLicense = "Activate Your License"
[plan.static.licenseActivation]
checkoutOpened = "Checkout Opened in New Tab"
instructions = "Complete your purchase in the Stripe tab. Once your payment is complete, you will receive an email with your license key."
enterKey = "Enter your license key below to activate your plan:"
keyDescription = "Paste the license key from your email"
activate = "Activate License"
doLater = "I'll do this later"
success = "License Activated!"
successMessage = "Your license has been successfully activated. You can now close this window."
[plan.static.billingPortal]
title = "Email Verification Required"
message = "You will need to verify your email address in the Stripe billing portal. Check your email for a login link."
[plan.period]
month = "month"
@@ -1,5 +1,5 @@
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import { Divider, Loader, Alert, Group, Text, Collapse, Button, TextInput, Stack, Paper, SegmentedControl, FileButton } from '@mantine/core';
import { Divider, Loader, Alert } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { usePlans } from '@app/hooks/usePlans';
import licenseService, { PlanTierGroup, mapLicenseToTier } from '@app/services/licenseService';
@@ -7,30 +7,25 @@ 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 LicenseKeySection from '@app/components/shared/config/configSections/plan/LicenseKeySection';
import { alert } from '@app/components/toast';
import LocalIcon from '@app/components/shared/LocalIcon';
import { InfoBanner } from '@app/components/shared/InfoBanner';
import { useLicenseAlert } from '@app/hooks/useLicenseAlert';
import { isSupabaseConfigured } from '@app/services/supabaseClient';
import { getPreferredCurrency, setCachedCurrency } from '@app/utils/currencyDetection';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@core/components/shared/config/LoginRequiredBanner';
import { isSupabaseConfigured } from '@app/services/supabaseClient';
const AdminPlanSection: React.FC = () => {
const { t, i18n } = useTranslation();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const { openCheckout } = useCheckout();
const { licenseInfo, refetchLicense } = useLicense();
const { licenseInfo } = useLicense();
const [currency, setCurrency] = useState<string>(() => {
// Initialize with auto-detected currency on first render
return getPreferredCurrency(i18n.language);
});
const [useStaticVersion, setUseStaticVersion] = useState(false);
const [showLicenseKey, setShowLicenseKey] = useState(false);
const [licenseKeyInput, setLicenseKeyInput] = useState<string>('');
const [savingLicense, setSavingLicense] = useState(false);
const [inputMethod, setInputMethod] = useState<'text' | 'file'>('text');
const [licenseFile, setLicenseFile] = useState<File | null>(null);
const { plans, loading, error, refetch } = usePlans(currency);
const licenseAlert = useLicenseAlert();
@@ -43,69 +38,6 @@ const AdminPlanSection: React.FC = () => {
}
}, [error]);
const handleSaveLicense = async () => {
// Block save if login is disabled
if (!validateLoginEnabled()) {
return;
}
try {
setSavingLicense(true);
let response;
if (inputMethod === 'file' && licenseFile) {
// Upload file
response = await licenseService.saveLicenseFile(licenseFile);
} else if (inputMethod === 'text' && licenseKeyInput.trim()) {
// Save key string (allow empty string to clear/remove license)
response = await licenseService.saveLicenseKey(licenseKeyInput.trim());
} else {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.premium.noInput', 'Please provide a license key or file'),
});
return;
}
if (response.success) {
// Refresh license context to update all components
await refetchLicense();
const successMessage = inputMethod === 'file'
? t('admin.settings.premium.file.successMessage', 'License file uploaded and activated successfully')
: t('admin.settings.premium.key.successMessage', 'License key activated successfully');
alert({
alertType: 'success',
title: t('success', 'Success'),
body: successMessage,
});
// Clear inputs
setLicenseKeyInput('');
setLicenseFile(null);
setInputMethod('text'); // Reset to default
} else {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: response.error || t('admin.settings.saveError', 'Failed to save license'),
});
}
} catch (error) {
console.error('Failed to save license:', error);
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save license'),
});
} finally {
setSavingLicense(false);
}
};
const currencyOptions = [
{ value: 'gbp', label: 'British pound (GBP, £)' },
{ value: 'usd', label: 'US dollar (USD, $)' },
@@ -280,169 +212,7 @@ const AdminPlanSection: React.FC = () => {
<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>
)}
{/* Show current license source */}
{licenseInfo?.licenseKey && (
<Alert
variant="light"
color="green"
icon={<LocalIcon icon="check-circle-rounded" width="1rem" height="1rem" />}
>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t('admin.settings.premium.currentLicense.title', 'Active License')}
</Text>
<Text size="xs">
{licenseInfo.licenseKey.startsWith('file:')
? t('admin.settings.premium.currentLicense.file', 'Source: License file ({{path}})', {
path: licenseInfo.licenseKey.substring(5)
})
: t('admin.settings.premium.currentLicense.key', 'Source: License key')}
</Text>
<Text size="xs">
{t('admin.settings.premium.currentLicense.type', 'Type: {{type}}', {
type: licenseInfo.licenseType
})}
</Text>
</Stack>
</Alert>
)}
{/* Input method selector */}
<SegmentedControl
value={inputMethod}
onChange={(value) => {
setInputMethod(value as 'text' | 'file');
// Clear opposite input when switching
if (value === 'text') setLicenseFile(null);
if (value === 'file') setLicenseKeyInput('');
}}
data={[
{
label: t('admin.settings.premium.inputMethod.text', 'License Key'),
value: 'text'
},
{
label: t('admin.settings.premium.inputMethod.file', 'Certificate File'),
value: 'file'
}
]}
disabled={!loginEnabled || savingLicense}
/>
{/* Input area */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
{inputMethod === 'text' ? (
/* Existing text input */
<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={!loginEnabled || savingLicense}
/>
) : (
/* File upload */
<div>
<Text size="sm" fw={500} mb="xs">
{t('admin.settings.premium.file.label', 'License Certificate File')}
</Text>
<Text size="xs" c="dimmed" mb="md">
{t('admin.settings.premium.file.description', 'Upload your .lic or .cert license file')}
</Text>
<FileButton
onChange={setLicenseFile}
accept=".lic,.cert"
disabled={!loginEnabled || savingLicense}
>
{(props) => (
<Button
{...props}
variant="outline"
leftSection={<LocalIcon icon="upload-file-rounded" width="1rem" height="1rem" />}
disabled={!loginEnabled || savingLicense}
>
{licenseFile
? licenseFile.name
: t('admin.settings.premium.file.choose', 'Choose License File')}
</Button>
)}
</FileButton>
{licenseFile && (
<Text size="xs" c="dimmed" mt="xs">
{t('admin.settings.premium.file.selected', 'Selected: {{filename}} ({{size}})', {
filename: licenseFile.name,
size: (licenseFile.size / 1024).toFixed(2) + ' KB'
})}
</Text>
)}
</div>
)}
<Group justify="flex-end">
<Button
onClick={handleSaveLicense}
loading={savingLicense}
size="sm"
disabled={
!loginEnabled ||
(inputMethod === 'text' && !licenseKeyInput.trim()) ||
(inputMethod === 'file' && !licenseFile)
}
>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Collapse>
</div>
<LicenseKeySection currentLicenseInfo={licenseInfo ?? undefined} />
</div>
);
};
@@ -5,6 +5,7 @@ import licenseService, { PlanTier, PlanTierGroup, LicenseInfo, mapLicenseToTier
import PlanCard from '@app/components/shared/config/configSections/plan/PlanCard';
import FeatureComparisonTable from '@app/components/shared/config/configSections/plan/FeatureComparisonTable';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { isCurrentTier as checkIsCurrentTier, isDowngrade as checkIsDowngrade } from '@app/utils/planTierUtils';
interface AvailablePlansSectionProps {
plans: PlanTier[];
@@ -43,28 +44,12 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
// 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;
return checkIsCurrentTier(currentTier, tierGroup.tier);
};
// 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 checkIsDowngrade(currentTier, tierGroup.tier);
};
return (
@@ -103,7 +88,7 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '1rem',
marginBottom: '0.5rem',
marginBottom: '0.1rem',
}}
>
{groupedPlans.map((group) => (
@@ -0,0 +1,273 @@
import React, { useState } from 'react';
import { Button, Collapse, Alert, TextInput, Paper, Stack, Group, Text, SegmentedControl, FileButton } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { alert } from '@app/components/toast';
import { LicenseInfo } from '@app/services/licenseService';
import licenseService from '@app/services/licenseService';
import { useLicense } from '@app/contexts/LicenseContext';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
interface LicenseKeySectionProps {
currentLicenseInfo?: LicenseInfo;
}
const LicenseKeySection: React.FC<LicenseKeySectionProps> = ({ currentLicenseInfo }) => {
const { t } = useTranslation();
const { refetchLicense } = useLicense();
const { loginEnabled, validateLoginEnabled } = useLoginRequired();
const [showLicenseKey, setShowLicenseKey] = useState(false);
const [licenseKeyInput, setLicenseKeyInput] = useState<string>('');
const [savingLicense, setSavingLicense] = useState(false);
const [inputMethod, setInputMethod] = useState<'text' | 'file'>('text');
const [licenseFile, setLicenseFile] = useState<File | null>(null);
const handleSaveLicense = async () => {
// Block save if login is disabled
if (!validateLoginEnabled()) {
return;
}
try {
setSavingLicense(true);
let response;
if (inputMethod === 'file' && licenseFile) {
// Upload file
response = await licenseService.saveLicenseFile(licenseFile);
} else if (inputMethod === 'text' && licenseKeyInput.trim()) {
// Save key string
response = await licenseService.saveLicenseKey(licenseKeyInput.trim());
} else {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.premium.noInput', 'Please provide a license key or file'),
});
return;
}
if (response.success) {
// Refresh license context to update all components
await refetchLicense();
const successMessage =
inputMethod === 'file'
? t('admin.settings.premium.file.successMessage', 'License file uploaded and activated successfully')
: t('admin.settings.premium.key.successMessage', 'License key activated successfully');
alert({
alertType: 'success',
title: t('success', 'Success'),
body: successMessage,
});
// Clear inputs
setLicenseKeyInput('');
setLicenseFile(null);
setInputMethod('text'); // Reset to default
} else {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: response.error || t('admin.settings.saveError', 'Failed to save license'),
});
}
} catch (error) {
console.error('Failed to save license:', error);
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save license'),
});
} finally {
setSavingLicense(false);
}
};
return (
<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 */}
{currentLicenseInfo?.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>
)}
{/* Show current license source */}
{currentLicenseInfo?.licenseKey && (
<Alert
variant="light"
color="green"
icon={<LocalIcon icon="check-circle-rounded" width="1rem" height="1rem" />}
>
<Stack gap="xs">
<Text size="sm" fw={500}>
{t('admin.settings.premium.currentLicense.title', 'Active License')}
</Text>
<Text size="xs">
{currentLicenseInfo.licenseKey.startsWith('file:')
? t('admin.settings.premium.currentLicense.file', 'Source: License file ({{path}})', {
path: currentLicenseInfo.licenseKey.substring(5),
})
: t('admin.settings.premium.currentLicense.key', 'Source: License key')}
</Text>
<Text size="xs">
{t('admin.settings.premium.currentLicense.type', 'Type: {{type}}', {
type: currentLicenseInfo.licenseType,
})}
</Text>
</Stack>
</Alert>
)}
{/* Input method selector */}
<SegmentedControl
value={inputMethod}
onChange={(value) => {
setInputMethod(value as 'text' | 'file');
// Clear opposite input when switching
if (value === 'text') setLicenseFile(null);
if (value === 'file') setLicenseKeyInput('');
}}
data={[
{
label: t('admin.settings.premium.inputMethod.text', 'License Key'),
value: 'text',
},
{
label: t('admin.settings.premium.inputMethod.file', 'Certificate File'),
value: 'file',
},
]}
disabled={!loginEnabled || savingLicense}
/>
{/* Input area */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
{inputMethod === 'text' ? (
/* Text input */
<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={currentLicenseInfo?.licenseKey || '00000000-0000-0000-0000-000000000000'}
type="password"
disabled={!loginEnabled || savingLicense}
/>
) : (
/* File upload */
<div>
<Text size="sm" fw={500} mb="xs">
{t('admin.settings.premium.file.label', 'License Certificate File')}
</Text>
<Text size="xs" c="dimmed" mb="md">
{t('admin.settings.premium.file.description', 'Upload your .lic or .cert license file')}
</Text>
<FileButton
onChange={setLicenseFile}
accept=".lic,.cert"
disabled={!loginEnabled || savingLicense}
>
{(props) => (
<Button
{...props}
variant="outline"
leftSection={<LocalIcon icon="upload-file-rounded" width="1rem" height="1rem" />}
disabled={!loginEnabled || savingLicense}
>
{licenseFile
? licenseFile.name
: t('admin.settings.premium.file.choose', 'Choose License File')}
</Button>
)}
</FileButton>
{licenseFile && (
<Text size="xs" c="dimmed" mt="xs">
{t('admin.settings.premium.file.selected', 'Selected: {{filename}} ({{size}})', {
filename: licenseFile.name,
size: (licenseFile.size / 1024).toFixed(2) + ' KB',
})}
</Text>
)}
</div>
)}
<Group justify="flex-end">
<Button
onClick={handleSaveLicense}
loading={savingLicense}
size="sm"
disabled={
!loginEnabled ||
(inputMethod === 'text' && !licenseKeyInput.trim()) ||
(inputMethod === 'file' && !licenseFile)
}
>
{t('admin.settings.save', 'Save Changes')}
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Collapse>
</div>
);
};
export default LicenseKeySection;
@@ -6,6 +6,7 @@ import { PricingBadge } from '@app/components/shared/stripeCheckout/components/P
import { PriceDisplay } from '@app/components/shared/stripeCheckout/components/PriceDisplay';
import { calculateDisplayPricing } from '@app/components/shared/stripeCheckout/utils/pricingUtils';
import { getBaseCardStyle } from '@app/components/shared/stripeCheckout/utils/cardStyles';
import { isEnterpriseBlockedForFree as checkIsEnterpriseBlockedForFree } from '@app/utils/planTierUtils';
interface PlanCardProps {
planGroup: PlanTierGroup;
@@ -83,7 +84,7 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
const isEnterprise = planGroup.tier === 'enterprise';
// Block enterprise for free tier users (must have server first)
const isEnterpriseBlockedForFree = isEnterprise && currentTier === 'free';
const isEnterpriseBlockedForFree = checkIsEnterpriseBlockedForFree(currentTier, planGroup.tier);
// Calculate "From" pricing - show yearly price divided by 12 for lowest monthly equivalent
const { displayPrice, displaySeatPrice, displayCurrency } = calculateDisplayPricing(
@@ -174,7 +175,7 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
withArrow
>
<Button
variant={isCurrentTier ? 'filled' : isDowngrade ? 'filled' : isEnterpriseBlockedForFree ? 'light' : 'filled'}
variant="filled"
fullWidth
onClick={() => isCurrentTier && onManageClick ? onManageClick() : onUpgradeClick(planGroup)}
disabled={!loginEnabled || isDowngrade || isEnterpriseBlockedForFree}
@@ -0,0 +1,338 @@
import React, { useState } from 'react';
import { Modal, Text, Group, ActionIcon, Stack, Paper, Grid, TextInput, Button, Alert } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { EmailStage } from '@app/components/shared/stripeCheckout/stages/EmailStage';
import { validateEmail } from '@app/components/shared/stripeCheckout/utils/checkoutUtils';
import { getClickablePaperStyle } from '@app/components/shared/stripeCheckout/utils/cardStyles';
import { STATIC_STRIPE_LINKS, buildStripeUrlWithEmail } from '@app/constants/staticStripeLinks';
import { alert } from '@app/components/toast';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useIsMobile } from '@app/hooks/useIsMobile';
import licenseService from '@app/services/licenseService';
import { useLicense } from '@app/contexts/LicenseContext';
interface StaticCheckoutModalProps {
opened: boolean;
onClose: () => void;
planName: 'server' | 'enterprise';
isUpgrade?: boolean;
}
type Stage = 'email' | 'period-selection' | 'license-activation';
const StaticCheckoutModal: React.FC<StaticCheckoutModalProps> = ({
opened,
onClose,
planName,
isUpgrade = false,
}) => {
const { t } = useTranslation();
const isMobile = useIsMobile();
const { refetchLicense } = useLicense();
const [stage, setStage] = useState<Stage>('email');
const [email, setEmail] = useState('');
const [emailError, setEmailError] = useState('');
const [stageHistory, setStageHistory] = useState<Stage[]>([]);
// License activation state
const [licenseKey, setLicenseKey] = useState('');
const [savingLicense, setSavingLicense] = useState(false);
const [licenseActivated, setLicenseActivated] = useState(false);
const handleEmailSubmit = () => {
const validation = validateEmail(email);
if (validation.valid) {
setEmailError('');
setStageHistory([...stageHistory, 'email']);
setStage('period-selection');
} else {
setEmailError(validation.error);
}
};
const handlePeriodSelect = (period: 'monthly' | 'yearly') => {
const baseUrl = STATIC_STRIPE_LINKS[planName][period];
const urlWithEmail = buildStripeUrlWithEmail(baseUrl, email);
// Open Stripe checkout in new tab
window.open(urlWithEmail, '_blank');
// Transition to license activation stage
setStageHistory([...stageHistory, 'period-selection']);
setStage('license-activation');
};
const handleActivateLicense = async () => {
if (!licenseKey.trim()) {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.premium.noInput', 'Please provide a license key'),
});
return;
}
try {
setSavingLicense(true);
const response = await licenseService.saveLicenseKey(licenseKey.trim());
if (response.success) {
// Refresh license context to update all components
await refetchLicense();
setLicenseActivated(true);
alert({
alertType: 'success',
title: t('success', 'Success'),
body: t(
'admin.settings.premium.key.successMessage',
'License key activated successfully'
),
});
} else {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: response.error || t('admin.settings.saveError', 'Failed to save license'),
});
}
} catch (error) {
console.error('Failed to save license:', error);
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save license'),
});
} finally {
setSavingLicense(false);
}
};
const handleGoBack = () => {
if (stageHistory.length > 0) {
const newHistory = [...stageHistory];
const previousStage = newHistory.pop();
setStageHistory(newHistory);
if (previousStage) {
setStage(previousStage);
}
}
};
const handleClose = () => {
// Reset state when closing
setStage('email');
setEmail('');
setEmailError('');
setStageHistory([]);
setLicenseKey('');
setSavingLicense(false);
setLicenseActivated(false);
onClose();
};
const getModalTitle = () => {
if (stage === 'email') {
if (isUpgrade) {
return t('plan.static.upgradeToEnterprise', 'Upgrade to Enterprise');
}
return planName === 'server'
? t('plan.static.getLicense', 'Get Server License')
: t('plan.static.upgradeToEnterprise', 'Upgrade to Enterprise');
}
if (stage === 'period-selection') {
return t('plan.static.selectPeriod', 'Select Billing Period');
}
if (stage === 'license-activation') {
return t('plan.static.activateLicense', 'Activate Your License');
}
return '';
};
const renderContent = () => {
switch (stage) {
case 'email':
return (
<EmailStage
emailInput={email}
setEmailInput={setEmail}
emailError={emailError}
onSubmit={handleEmailSubmit}
/>
);
case 'period-selection':
return (
<Stack gap="lg" style={{ padding: '1rem 2rem' }}>
<Grid gutter="xl" style={{ marginTop: '1rem' }}>
{/* Monthly Option */}
<Grid.Col span={6}>
<Paper
withBorder
p="xl"
radius="md"
style={getClickablePaperStyle()}
onClick={() => handlePeriodSelect('monthly')}
>
<Stack gap="md" style={{ height: '100%', minHeight: '120px' }} justify="space-between">
<Text size="lg" fw={600}>
{t('payment.monthly', 'Monthly')}
</Text>
<Text size="sm" c="dimmed">
{t('plan.static.monthlyBilling', 'Monthly Billing')}
</Text>
</Stack>
</Paper>
</Grid.Col>
{/* Yearly Option */}
<Grid.Col span={6}>
<Paper
withBorder
p="xl"
radius="md"
style={getClickablePaperStyle()}
onClick={() => handlePeriodSelect('yearly')}
>
<Stack gap="md" style={{ height: '100%', minHeight: '120px' }} justify="space-between">
<Text size="lg" fw={600}>
{t('payment.yearly', 'Yearly')}
</Text>
<Text size="sm" c="dimmed">
{t('plan.static.yearlyBilling', 'Yearly Billing')}
</Text>
</Stack>
</Paper>
</Grid.Col>
</Grid>
</Stack>
);
case 'license-activation':
return (
<Stack gap="lg" style={{ padding: '2rem', maxWidth: '600px', margin: '0 auto' }}>
<Alert
variant="light"
color="blue"
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Stack gap="sm">
<Text size="sm" fw={600}>
{t('plan.static.licenseActivation.checkoutOpened', 'Checkout Opened in New Tab')}
</Text>
<Text size="sm">
{t(
'plan.static.licenseActivation.instructions',
'Complete your purchase in the Stripe tab. Once your payment is complete, you will receive an email with your license key.'
)}
</Text>
</Stack>
</Alert>
{licenseActivated ? (
<Alert
variant="light"
color="green"
icon={<LocalIcon icon="check-circle-rounded" width="1rem" height="1rem" />}
title={t('plan.static.licenseActivation.success', 'License Activated!')}
>
<Text size="sm">
{t(
'plan.static.licenseActivation.successMessage',
'Your license has been successfully activated. You can now close this window.'
)}
</Text>
</Alert>
) : (
<Stack gap="md">
<Text size="sm" fw={500}>
{t(
'plan.static.licenseActivation.enterKey',
'Enter your license key below to activate your plan:'
)}
</Text>
<TextInput
label={t('admin.settings.premium.key.label', 'License Key')}
description={t(
'plan.static.licenseActivation.keyDescription',
'Paste the license key from your email'
)}
value={licenseKey}
onChange={(e) => setLicenseKey(e.target.value)}
placeholder="00000000-0000-0000-0000-000000000000"
disabled={savingLicense}
type="password"
/>
<Group justify="space-between">
<Button variant="subtle" onClick={handleClose} disabled={savingLicense}>
{t('plan.static.licenseActivation.doLater', "I'll do this later")}
</Button>
<Button
onClick={handleActivateLicense}
loading={savingLicense}
disabled={!licenseKey.trim()}
>
{t('plan.static.licenseActivation.activate', 'Activate License')}
</Button>
</Group>
</Stack>
)}
{licenseActivated && (
<Group justify="flex-end">
<Button onClick={handleClose}>
{t('common.close', 'Close')}
</Button>
</Group>
)}
</Stack>
);
default:
return null;
}
};
const canGoBack = stageHistory.length > 0 && stage !== 'license-activation';
return (
<Modal
opened={opened}
onClose={handleClose}
title={
<Group gap="sm" wrap="nowrap">
{canGoBack && (
<ActionIcon
variant="subtle"
size="lg"
onClick={handleGoBack}
aria-label={t('common.back', 'Back')}
>
<LocalIcon icon="arrow-back" width={20} height={20} />
</ActionIcon>
)}
<Text fw={600} size="lg">
{getModalTitle()}
</Text>
</Group>
}
size={isMobile ? '100%' : 600}
centered
radius="lg"
withCloseButton={true}
closeOnEscape={true}
closeOnClickOutside={false}
fullScreen={isMobile}
zIndex={Z_INDEX_OVER_CONFIG_MODAL}
>
{renderContent()}
</Modal>
);
};
export default StaticCheckoutModal;
@@ -1,20 +1,16 @@
import React, { useState, useEffect } from 'react';
import { Card, Text, Group, Stack, Badge, Button, Collapse, Alert, TextInput, Paper, Loader, Divider } from '@mantine/core';
import React, { useState } from 'react';
import { Card, Text, Stack, Button, Collapse, Divider, Tooltip } 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;
}
import StaticCheckoutModal from '@app/components/shared/config/configSections/plan/StaticCheckoutModal';
import LicenseKeySection from '@app/components/shared/config/configSections/plan/LicenseKeySection';
import { STATIC_STRIPE_LINKS } from '@app/constants/staticStripeLinks';
import { PricingBadge } from '@app/components/shared/stripeCheckout/components/PricingBadge';
import { getBaseCardStyle } from '@app/components/shared/stripeCheckout/utils/cardStyles';
import { isCurrentTier as checkIsCurrentTier, isDowngrade as checkIsDowngrade, isEnterpriseBlockedForFree } from '@app/utils/planTierUtils';
interface StaticPlanSectionProps {
currentLicenseInfo?: LicenseInfo;
@@ -22,38 +18,45 @@ interface StaticPlanSectionProps {
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',
});
// Static checkout modal state
const [checkoutModalOpened, setCheckoutModalOpened] = useState(false);
const [selectedPlan, setSelectedPlan] = useState<'server' | 'enterprise'>('server');
const [isUpgrade, setIsUpgrade] = useState(false);
useEffect(() => {
fetchPremiumSettings();
}, []);
const handleSaveLicense = async () => {
try {
await savePremiumSettings();
showRestartModal();
} catch (_error) {
const handleOpenCheckout = (plan: 'server' | 'enterprise', upgrade: boolean) => {
// Prevent Free → Enterprise (must have Server first)
const currentTier = mapLicenseToTier(currentLicenseInfo || null);
if (currentTier === 'free' && plan === 'enterprise') {
alert({
alertType: 'error',
title: t('admin.error', 'Error'),
body: t('admin.settings.saveError', 'Failed to save settings'),
alertType: 'warning',
title: t('plan.enterprise.requiresServer', 'Server Plan Required'),
body: t(
'plan.enterprise.requiresServerMessage',
'Please upgrade to the Server plan first before upgrading to Enterprise.'
),
});
return;
}
setSelectedPlan(plan);
setIsUpgrade(upgrade);
setCheckoutModalOpened(true);
};
const handleManageBilling = () => {
// Show warning about email verification
alert({
alertType: 'warning',
title: t('plan.static.billingPortal.title', 'Email Verification Required'),
body: t(
'plan.static.billingPortal.message',
'You will need to verify your email address in the Stripe billing portal. Check your email for a login link.'
),
});
window.open(STATIC_STRIPE_LINKS.billingPortal, '_blank');
};
const staticPlans = [
@@ -122,7 +125,7 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '1rem',
paddingBottom: '1rem',
paddingBottom: '0.1rem',
}}
>
{staticPlans.map((plan) => (
@@ -131,53 +134,27 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
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,
}}
style={getBaseCardStyle(plan.id === currentPlan.id)}
className="plan-card"
>
{plan.id === currentPlan.id && (
<Badge
color="green"
variant="filled"
size="sm"
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
>
{t('plan.current', 'Current Plan')}
</Badge>
<PricingBadge
type="current"
label={t('plan.current', 'Current Plan')}
/>
)}
{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>
<PricingBadge
type="popular"
label={t('plan.popular', 'Popular')}
/>
)}
<Stack gap="md" style={{ height: '100%' }}>
<div>
<Text size="lg" fw={600}>
<Text size="xl" fw={700} style={{ fontSize: '2rem' }}>
{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
@@ -195,18 +172,123 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
<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')
{/* Tier-based button logic */}
{(() => {
const currentTier = mapLicenseToTier(currentLicenseInfo || null);
const isCurrent = checkIsCurrentTier(currentTier, plan.id);
const isDowngradePlan = checkIsDowngrade(currentTier, plan.id);
// Free Plan
if (plan.id === 'free') {
return (
<Button
variant="filled"
disabled
fullWidth
className="plan-button"
>
{isCurrent
? t('plan.current', 'Current Plan')
: t('plan.free.included', 'Included')}
</Button>
);
}
>
{plan.id === currentPlan.id
? t('plan.current', 'Current Plan')
: t('plan.contact', 'Contact Us')}
</Button>
// Server Plan
if (plan.id === 'server') {
if (currentTier === 'free') {
return (
<Button
variant="filled"
fullWidth
onClick={() => handleOpenCheckout('server', false)}
className="plan-button"
>
{t('plan.upgrade', 'Upgrade')}
</Button>
);
}
if (isCurrent) {
return (
<Button
variant="filled"
fullWidth
onClick={handleManageBilling}
className="plan-button"
>
{t('plan.manage', 'Manage')}
</Button>
);
}
if (isDowngradePlan) {
return (
<Button
variant="filled"
disabled
fullWidth
className="plan-button"
>
{t('plan.free.included', 'Included')}
</Button>
);
}
}
// Enterprise Plan
if (plan.id === 'enterprise') {
if (isEnterpriseBlockedForFree(currentTier, plan.id)) {
return (
<Tooltip label={t('plan.enterprise.requiresServer', 'Requires Server plan')} position="top" withArrow>
<Button
variant="filled"
disabled
fullWidth
className="plan-button"
>
{t('plan.enterprise.requiresServer', 'Requires Server')}
</Button>
</Tooltip>
);
}
if (currentTier === 'server') {
// TODO: Re-enable checkout flow when account syncing is ready
// return (
// <Button
// variant="filled"
// fullWidth
// onClick={() => handleOpenCheckout('enterprise', true)}
// className="plan-button"
// >
// {t('plan.selectPlan', 'Select Plan')}
// </Button>
// );
return (
<Button
variant="filled"
fullWidth
disabled
className="plan-button"
>
{t('plan.contact', 'Contact Us')}
</Button>
);
}
if (isCurrent) {
return (
<Button
variant="filled"
fullWidth
onClick={handleManageBilling}
className="plan-button"
>
{t('plan.manage', 'Manage')}
</Button>
);
}
}
return null;
})()}
</Stack>
</Card>
))}
@@ -230,66 +312,14 @@ const StaticPlanSection: React.FC<StaticPlanSectionProps> = ({ currentLicenseInf
<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>
<LicenseKeySection currentLicenseInfo={currentLicenseInfo} />
<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}
{/* Static Checkout Modal */}
<StaticCheckoutModal
opened={checkoutModalOpened}
onClose={() => setCheckoutModalOpened(false)}
planName={selectedPlan}
isUpgrade={isUpgrade}
/>
</div>
);
@@ -0,0 +1,56 @@
/**
* Static Stripe payment links for offline/self-hosted environments
*
* These links are used when Supabase is not configured, allowing users to
* purchase licenses directly through Stripe hosted checkout pages.
*
* NOTE: These are test environment URLs. Replace with production URLs before release.
*/
export interface StaticStripeLinks {
server: {
monthly: string;
yearly: string;
};
enterprise: {
monthly: string;
yearly: string;
};
billingPortal: string;
}
// PRODCUTION LINKS FOR LIVE SERVER
export const STATIC_STRIPE_LINKS: StaticStripeLinks = {
server: {
monthly: 'https://buy.stripe.com/fZu4gB8Nv6ysfAj0ts8Zq03',
yearly: 'https://buy.stripe.com/9B68wR6Fn0a40Fpcca8Zq02',
},
enterprise: {
monthly: '',
yearly: '',
},
billingPortal: 'https://billing.stripe.com/p/login/test_aFa5kv1Mz2s10Fr3Cp83C00',
};
// LINKS FOR TEST SERVER:
// export const STATIC_STRIPE_LINKS: StaticStripeLinks = {
// server: {
// monthly: 'https://buy.stripe.com/test_8x27sD4YL9Ut0Fr3Cp83C02',
// yearly: 'https://buy.stripe.com/test_4gMdR11Mz4A9ag17SF83C03',
// },
// enterprise: {
// monthly: 'https://buy.stripe.com/test_8x2cMX9f18Qp9bX0qd83C04',
// yearly: 'https://buy.stripe.com/test_6oU00b2QD2s173P6OB83C05',
// },
// billingPortal: 'https://billing.stripe.com/p/login/test_aFa5kv1Mz2s10Fr3Cp83C00',
// };
/**
* Builds a Stripe URL with a prefilled email parameter
* @param baseUrl - The base Stripe checkout URL
* @param email - The email address to prefill
* @returns The complete URL with encoded email parameter
*/
export function buildStripeUrlWithEmail(baseUrl: string, email: string): string {
const encodedEmail = encodeURIComponent(email);
return `${baseUrl}?locked_prefilled_email=${encodedEmail}`;
}
@@ -0,0 +1,40 @@
/**
* Shared utilities for plan tier comparisons and button logic
*/
export type PlanTier = 'free' | 'server' | 'enterprise';
const TIER_HIERARCHY: Record<PlanTier, number> = {
'free': 1,
'server': 2,
'enterprise': 3,
};
/**
* Get numeric level for a tier
*/
export function getTierLevel(tier: PlanTier | string | null | undefined): number {
if (!tier) return 1;
return TIER_HIERARCHY[tier as PlanTier] || 1;
}
/**
* Check if target tier is the current tier
*/
export function isCurrentTier(currentTier: PlanTier | string | null | undefined, targetTier: PlanTier | string): boolean {
return getTierLevel(currentTier) === getTierLevel(targetTier);
}
/**
* Check if target tier is a downgrade from current tier
*/
export function isDowngrade(currentTier: PlanTier | string | null | undefined, targetTier: PlanTier | string): boolean {
return getTierLevel(currentTier) > getTierLevel(targetTier);
}
/**
* Check if enterprise is blocked for free tier users
*/
export function isEnterpriseBlockedForFree(currentTier: PlanTier | string | null | undefined, targetTier: PlanTier | string): boolean {
return currentTier === 'free' && targetTier === 'enterprise';
}