This commit is contained in:
Connor Yoh 2025-11-16 16:05:08 +00:00
parent 41a974eb0b
commit 97b51cab02
4 changed files with 215 additions and 4 deletions

View File

@ -1,10 +1,14 @@
package stirling.software.proprietary.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;
@ -13,10 +17,14 @@ 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.License;
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
/**
* Admin controller for license management. Provides installation ID for Stripe checkout metadata.
* Admin controller for license management. Provides installation ID for Stripe checkout metadata
* and endpoints for managing license keys.
*/
@RestController
@Slf4j
@ -25,6 +33,11 @@ import stirling.software.common.util.GeneralUtils;
@Tag(name = "Admin License Management", description = "Admin-only License Management APIs")
public class AdminLicenseController {
@Autowired(required = false)
private LicenseKeyChecker licenseKeyChecker;
@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.
@ -37,7 +50,6 @@ public class AdminLicenseController {
description =
"Returns the unique installation ID (MAC-based fingerprint) for this"
+ " self-hosted instance")
@PreAuthorize("hasRole('ROLE_ADMIN')")
public ResponseEntity<Map<String, String>> getInstallationId() {
try {
String installationId = GeneralUtils.generateMachineFingerprint();
@ -49,4 +61,95 @@ public class AdminLicenseController {
.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");
if (licenseKey == null || licenseKey.trim().isEmpty()) {
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"));
}
// Use existing LicenseKeyChecker to update and validate license
licenseKeyChecker.updateLicenseKey(licenseKey.trim());
// Get current license status
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("licenseType", license.name());
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()));
}
}
/**
* 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());
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"));
}
}
}

View File

@ -16,6 +16,7 @@ interface StripeCheckoutProps {
email: string;
onSuccess?: (sessionId: string) => void;
onError?: (error: string) => void;
onLicenseActivated?: (licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => void;
}
type CheckoutState = {
@ -32,6 +33,7 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
email,
onSuccess,
onError,
onLicenseActivated,
}) => {
const { t } = useTranslation();
const [state, setState] = useState<CheckoutState>({ status: 'idle' });
@ -103,6 +105,27 @@ const StripeCheckout: React.FC<StripeCheckoutProps> = ({
if (response.status === 'ready' && response.license_key) {
setLicenseKey(response.license_key);
setPollingStatus('ready');
// Save license key to backend
try {
const saveResponse = await licenseService.saveLicenseKey(response.license_key);
if (saveResponse.success) {
console.log(`License key activated on backend: ${saveResponse.licenseType}`);
// Fetch and pass license info to parent
try {
const licenseInfo = await licenseService.getLicenseInfo();
onLicenseActivated?.(licenseInfo);
} catch (infoError) {
console.error('Error fetching license info:', infoError);
}
} else {
console.error('Failed to save license key to backend:', saveResponse.error);
}
} catch (error) {
console.error('Error saving license key to backend:', error);
}
return;
}

View File

@ -2,7 +2,7 @@ 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 { PlanTierGroup } from '@app/services/licenseService';
import licenseService, { PlanTierGroup } from '@app/services/licenseService';
import StripeCheckout from '@app/components/shared/StripeCheckout';
import AvailablePlansSection from './plan/AvailablePlansSection';
import ActivePlanSection from './plan/ActivePlanSection';
@ -15,6 +15,7 @@ import RestartConfirmationModal from '@app/components/shared/config/RestartConfi
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import { convertOperationConfig } from '@app/hooks/tools/convert/useConvertOperation';
interface PremiumSettingsData {
key?: string;
@ -29,6 +30,8 @@ const AdminPlanSection: React.FC = () => {
const [currency, setCurrency] = useState<string>('gbp');
const [useStaticVersion, setUseStaticVersion] = useState(false);
const [currentLicenseInfo, setCurrentLicenseInfo] = useState<any>(null);
const [licenseInfoLoading, setLicenseInfoLoading] = useState(false);
const [licenseInfoError, setLicenseInfoError] = useState<string | null>(null);
const [showLicenseKey, setShowLicenseKey] = useState(false);
const [email, setEmail] = useState<string>('');
const { plans, currentSubscription, loading, error, refetch } = usePlans(currency);
@ -51,6 +54,7 @@ const AdminPlanSection: React.FC = () => {
useEffect(() => {
const fetchLicenseInfo = async () => {
try {
console.log('Fetching user and license info for plan section');
const adminData = await userManagementService.getUsers();
// Determine plan name based on config flags
@ -66,17 +70,32 @@ const AdminPlanSection: React.FC = () => {
maxUsers: adminData.maxAllowedUsers,
grandfathered: adminData.grandfatheredUserCount > 0,
});
// Also fetch license info from new backend endpoint
try {
setLicenseInfoLoading(true);
setLicenseInfoError(null);
const backendLicenseInfo = await licenseService.getLicenseInfo();
setCurrentLicenseInfo(backendLicenseInfo);
setLicenseInfoLoading(false);
} catch (licenseErr: any) {
console.error('Failed to fetch backend license info:', licenseErr);
setLicenseInfoLoading(false);
setLicenseInfoError(licenseErr?.response?.data?.error || licenseErr?.message || 'Unknown error');
// Don't overwrite existing info if backend call fails
}
} catch (err) {
console.error('Failed to fetch license info:', err);
}
};
// Check if Stripe is configured
const stripeKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
if (!stripeKey || error) {
setUseStaticVersion(true);
fetchLicenseInfo();
}
fetchLicenseInfo();
// Fetch premium settings
fetchPremiumSettings();
@ -150,6 +169,11 @@ const AdminPlanSection: React.FC = () => {
// Error is already displayed in the StripeCheckout component
}, []);
const handleLicenseActivated = useCallback((licenseInfo: {licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}) => {
console.log('License activated:', licenseInfo);
setCurrentLicenseInfo(licenseInfo);
}, []);
const handleCheckoutClose = useCallback(() => {
setCheckoutOpen(false);
setSelectedPlanGroup(null);
@ -187,6 +211,37 @@ const AdminPlanSection: React.FC = () => {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
{/* License Information Display - Always visible */}
<Alert
color={licenseInfoLoading ? "gray" : licenseInfoError ? "red" : currentLicenseInfo?.enabled ? "green" : "blue"}
title={t('admin.plan.licenseInfo', 'License Information')}
>
{licenseInfoLoading ? (
<Group gap="xs">
<Loader size="sm" />
<Text size="sm">{t('admin.plan.loadingLicense', 'Loading license information...')}</Text>
</Group>
) : licenseInfoError ? (
<Text size="sm" c="red">{t('admin.plan.licenseError', 'Failed to load license info')}: {licenseInfoError}</Text>
) : currentLicenseInfo ? (
<Stack gap="xs">
<Text size="sm">
<strong>{t('admin.plan.licenseType', 'License Type')}:</strong> {currentLicenseInfo.licenseType}
</Text>
<Text size="sm">
<strong>{t('admin.plan.status', 'Status')}:</strong> {currentLicenseInfo.enabled ? t('admin.plan.active', 'Active') : t('admin.plan.inactive', 'Inactive')}
</Text>
{currentLicenseInfo.licenseType === 'ENTERPRISE' && currentLicenseInfo.maxUsers > 0 && (
<Text size="sm">
<strong>{t('admin.plan.maxUsers', 'Max Users')}:</strong> {currentLicenseInfo.maxUsers}
</Text>
)}
</Stack>
) : (
<Text size="sm">{t('admin.plan.noLicenseInfo', 'No license information available')}</Text>
)}
</Alert>
{/* Customer Information Section */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
@ -240,6 +295,7 @@ const AdminPlanSection: React.FC = () => {
email={email}
onSuccess={handlePaymentSuccess}
onError={handlePaymentError}
onLicenseActivated={handleLicenseActivated}
/>
)}

View File

@ -429,6 +429,35 @@ const licenseService = {
return data as LicenseKeyResponse;
},
/**
* Save license key to backend
*/
async saveLicenseKey(licenseKey: string): Promise<{success: boolean; licenseType?: string; message?: string; error?: string}> {
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<{licenseType: string; enabled: boolean; maxUsers: number; hasKey: boolean}> {
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;
}
},
};
export default licenseService;