mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-02-17 13:52:14 +01:00
Added file endpoint for license files and easy upload in admin UI (#5055)
<img width="698" height="240" alt="image" src="https://github.com/user-attachments/assets/f0161e5f-e2ed-44c1-bdd1-93fab46f756b" /> --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { Divider, Loader, Alert, Group, Text, Collapse, Button, TextInput, Stack, Paper } from '@mantine/core';
|
||||
import { Divider, Loader, Alert, Group, Text, Collapse, Button, TextInput, Stack, Paper, SegmentedControl, FileButton } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { usePlans } from '@app/hooks/usePlans';
|
||||
import licenseService, { PlanTierGroup, mapLicenseToTier } from '@app/services/licenseService';
|
||||
@@ -29,6 +29,8 @@ const AdminPlanSection: React.FC = () => {
|
||||
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();
|
||||
|
||||
@@ -49,34 +51,55 @@ const AdminPlanSection: React.FC = () => {
|
||||
|
||||
try {
|
||||
setSavingLicense(true);
|
||||
// Allow empty string to clear/remove license
|
||||
const response = await licenseService.saveLicenseKey(licenseKeyInput.trim());
|
||||
|
||||
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('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.'),
|
||||
title: t('success', 'Success'),
|
||||
body: successMessage,
|
||||
});
|
||||
|
||||
// Clear input
|
||||
// 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 key'),
|
||||
body: response.error || t('admin.settings.saveError', 'Failed to save license'),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save license key:', 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 key'),
|
||||
body: t('admin.settings.saveError', 'Failed to save license'),
|
||||
});
|
||||
} finally {
|
||||
setSavingLicense(false);
|
||||
@@ -300,20 +323,118 @@ const AdminPlanSection: React.FC = () => {
|
||||
</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">
|
||||
<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}
|
||||
/>
|
||||
{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}>
|
||||
<Button
|
||||
onClick={handleSaveLicense}
|
||||
loading={savingLicense}
|
||||
size="sm"
|
||||
disabled={
|
||||
!loginEnabled ||
|
||||
(inputMethod === 'text' && !licenseKeyInput.trim()) ||
|
||||
(inputMethod === 'file' && !licenseFile)
|
||||
}
|
||||
>
|
||||
{t('admin.settings.save', 'Save Changes')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -80,6 +80,10 @@ export interface LicenseInfo {
|
||||
export interface LicenseSaveResponse {
|
||||
success: boolean;
|
||||
licenseType?: string;
|
||||
filename?: string;
|
||||
filePath?: string;
|
||||
enabled?: boolean;
|
||||
maxUsers?: number;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
@@ -419,6 +423,29 @@ const licenseService = {
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload license certificate file for offline activation
|
||||
* @param file - The .lic or .cert file to upload
|
||||
* @returns Promise with upload result
|
||||
*/
|
||||
async saveLicenseFile(file: File): Promise<LicenseSaveResponse> {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await apiClient.post('/api/v1/admin/license-file', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error uploading license file:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current license information from backend
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user