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:
ConnorYoh 2025-11-29 19:35:50 +00:00 committed by GitHub
parent 959d14f075
commit 1e72416d55
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 389 additions and 35 deletions

View File

@ -1,22 +1,32 @@
package stirling.software.proprietary.security.controller.api;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.HashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.GeneralUtils;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier;
@ -242,4 +252,154 @@ public class AdminLicenseController {
.body(Map.of("error", "Failed to retrieve license information"));
}
}
/**
* Upload a license certificate file for offline activation. Accepts .lic or .cert files,
* validates the certificate format, saves to configs directory, and activates the license.
*
* @param file The license certificate file to upload
* @return Response with success status, license type, and file information
*/
@PostMapping(value = "/license-file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Upload license certificate file",
description =
"Upload a license certificate file (.lic, .cert) for offline activation."
+ " Validates the file format and activates the license.")
public ResponseEntity<Map<String, Object>> uploadLicenseFile(
@RequestParam("file") MultipartFile file) {
// Validate file exists
if (file == null || file.isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "File is empty"));
}
String filename = file.getOriginalFilename();
if (filename == null || filename.trim().isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "Invalid filename"));
}
// Prevent path traversal and enforce single filename component
if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "Filename must not contain path separators or '..'"));
}
// Validate file extension
if (!isValidLicenseFile(filename)) {
return ResponseEntity.badRequest()
.body(
Map.of(
"success",
false,
"error",
"Invalid file type. Expected .lic or .cert"));
}
// Check file size (max 1MB for license files)
if (file.getSize() > 1_048_576) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "File too large. Maximum 1MB allowed"));
}
try {
// Validate certificate format by reading content
byte[] fileBytes = file.getBytes();
String content = new String(fileBytes, StandardCharsets.UTF_8);
if (!content.trim().startsWith("-----BEGIN LICENSE FILE-----")) {
return ResponseEntity.badRequest()
.body(
Map.of(
"success",
false,
"error",
"Invalid license certificate format"));
}
// Get config directory and target path
Path configPath = Paths.get(InstallationPathConfig.getConfigPath());
Path targetPath = configPath.resolve(filename).normalize();
// Prevent directory traversal: ensure targetPath is inside configPath
if (!targetPath.startsWith(configPath.normalize().toAbsolutePath())) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "Invalid file path"));
}
// Backup existing file if present
if (Files.exists(targetPath)) {
Path backupDir = configPath.resolve("backup");
Files.createDirectories(backupDir);
String backupFilename = filename + ".bak." + System.currentTimeMillis();
Path backupPath = backupDir.resolve(backupFilename);
Files.copy(targetPath, backupPath, StandardCopyOption.REPLACE_EXISTING);
log.info("Backed up existing license file to: {}", backupPath);
}
// Write new license file
Files.write(targetPath, fileBytes);
log.info("License file saved to: {}", targetPath);
// assume premium enabled when setting license key
applicationProperties.getPremium().setEnabled(true);
// Update settings with file reference (relative path)
String fileReference = "file:configs/" + filename;
licenseKeyChecker.updateLicenseKey(fileReference);
// Get license status after activation
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("licenseType", license.name());
response.put("filename", filename);
response.put("filePath", "configs/" + filename);
response.put("enabled", applicationProperties.getPremium().isEnabled());
response.put("maxUsers", applicationProperties.getPremium().getMaxUsers());
response.put("message", "License file uploaded and activated");
log.info(
"License file uploaded and activated: filename={}, type={}",
filename,
license.name());
return ResponseEntity.ok(response);
} catch (IOException e) {
log.error("Failed to save license file", e);
return ResponseEntity.internalServerError()
.body(
Map.of(
"success",
false,
"error",
"Failed to save license file: " + e.getMessage()));
} catch (Exception e) {
log.error("Failed to activate license from file", e);
return ResponseEntity.badRequest()
.body(
Map.of(
"success",
false,
"error",
"Failed to activate license: " + e.getMessage()));
}
}
/**
* Validates if the filename has a valid license file extension (.lic or .cert)
*
* @param filename The filename to validate
* @return true if the filename ends with .lic or .cert (case-insensitive)
*/
private boolean isValidLicenseFile(String filename) {
if (filename == null) {
return false;
}
String lower = filename.toLowerCase();
return lower.endsWith(".lic") || lower.endsWith(".cert");
}
}

View File

@ -455,6 +455,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -498,6 +499,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -578,6 +580,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz",
"integrity": "sha512-TGpxn2CvAKRnOJWJ3bsK+dKBiCp75ehxftRUmv7wAmPomhnG5XrDfoWJungvO+zbbqAwso6PocdeXINVt3hlAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/engines": "1.4.1",
"@embedpdf/models": "1.4.1"
@ -677,6 +680,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.4.1.tgz",
"integrity": "sha512-5WLDiNMH6tACkLGGv/lJtNsDeozOhSbrh0mjD1btHun8u7Yscu/Vf8tdJRUOsd+nULivo2nQ2NFNKu0OTbVo8w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -693,6 +697,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.4.1.tgz",
"integrity": "sha512-Ng02S9SFIAi9JZS5rI+NXSnZZ1Yk9YYRw4MlN2pig49qOyivZdz0oScZaYxQPewo8ccJkLeghjdeWswOBW/6cA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -710,6 +715,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.4.1.tgz",
"integrity": "sha512-m3ZOk8JygsLxoa4cZ+0BVB5pfRWuBCg2/gPqjhoFZNKTqAFw4J6HGUrhYKg94GRYe+w1cTJl/NbTBYuU5DOrsA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -746,6 +752,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.4.1.tgz",
"integrity": "sha512-gKCdNKw6WBHBEpTc2DLBWIWOxzsNnaNbpfeY6C4f2Bum0EO+XW3Hl2oIx1uaRHjIhhnXso1J3QweqelsPwDGwg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -780,6 +787,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.4.1.tgz",
"integrity": "sha512-Y9O+matB4j4fLim5s/jn7qIi+lMC9vmDJRpJhiWe8bvD9oYLP2xfD/DdhFgAjRKcNhPoxC+j8q8QN5BMeGAv2Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -816,6 +824,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.4.1.tgz",
"integrity": "sha512-lo5Ytk1PH0PrRKv6zKVupm4t02VGsqIrnSIeP6NO8Ujx0wfqEhj//sqIuO/EwfFVJD8lcQIP9UUo9y8baCrEog==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -891,6 +900,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.4.1.tgz",
"integrity": "sha512-+TgFHKPCLTBiDYe2DdsmTS37hwQgcZ3dYIc7bE0l5cp+GVwouu1h0MTmjL+90loizeWwCiu10E/zXR6hz+CUaQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@ -1046,6 +1056,7 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@ -1089,6 +1100,7 @@
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@ -2119,6 +2131,7 @@
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.6.tgz",
"integrity": "sha512-paTl+0x+O/QtgMtqVJaG8maD8sfiOdgPmLOyG485FmeGZ1L3KMdEkhxZtmdGlDFsLXhmMGQ57ducT90bvhXX5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@floating-ui/react": "^0.27.16",
"clsx": "^2.1.1",
@ -2169,6 +2182,7 @@
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.6.tgz",
"integrity": "sha512-liHfaWXHAkLjJy+Bkr29UsCwAoDQ/a64WrM67lksx8F0qqyjR5RQH8zVlhuOjdpQnwtlUkE/YiTvbJiPcoI0bw==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^18.x || ^19.x"
}
@ -2236,6 +2250,7 @@
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz",
"integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.5",
@ -3168,6 +3183,7 @@
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.16"
}
@ -3286,7 +3302,6 @@
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz",
"integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"acorn": "^8.9.0"
}
@ -4063,6 +4078,7 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@ -4391,6 +4407,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@ -4401,6 +4418,7 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@ -4470,6 +4488,7 @@
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.3",
"@typescript-eslint/types": "8.46.3",
@ -5183,7 +5202,6 @@
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz",
"integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.24"
}
@ -5193,7 +5211,6 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz",
"integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.24",
"@vue/shared": "3.5.24"
@ -5204,7 +5221,6 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz",
"integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.24",
"@vue/runtime-core": "3.5.24",
@ -5217,7 +5233,6 @@
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz",
"integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.24",
"@vue/shared": "3.5.24"
@ -5244,6 +5259,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -5651,7 +5667,6 @@
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">= 0.4"
}
@ -5928,6 +5943,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@ -6975,7 +6991,8 @@
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz",
"integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==",
"dev": true,
"license": "BSD-3-Clause"
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/dezalgo": {
"version": "1.0.4",
@ -7370,6 +7387,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@ -7540,6 +7558,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@ -7706,8 +7725,7 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/espree": {
"version": "10.4.0",
@ -7772,7 +7790,6 @@
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.2.tgz",
"integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
}
@ -8863,6 +8880,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@ -9339,7 +9357,6 @@
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "^1.0.6"
}
@ -9660,6 +9677,7 @@
"integrity": "sha512-Pcfm3eZ+eO4JdZCXthW9tCDT3nF4K+9dmeZ+5X39n+Kqz0DDIABRP5CAEOHRFZk8RGuC2efksTJxrjp8EXCunQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.19",
"@asamuzakjp/dom-selector": "^6.7.3",
@ -10246,8 +10264,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/locate-path": {
"version": "6.0.0",
@ -11393,6 +11410,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -11672,6 +11690,7 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@ -12054,6 +12073,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@ -12063,6 +12083,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@ -13574,7 +13595,6 @@
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">= 0.4"
}
@ -13783,6 +13803,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -14084,6 +14105,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -14165,6 +14187,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@ -14369,6 +14392,7 @@
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@ -14520,6 +14544,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -14533,6 +14558,7 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@ -15144,8 +15170,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/zod": {
"version": "3.25.76",

View File

@ -4516,6 +4516,7 @@ description = "URL or filename to impressum (required in some jurisdictions)"
title = "Premium & Enterprise"
description = "Configure your premium or enterprise license key."
license = "License Configuration"
noInput = "Please provide a license key or file"
[admin.settings.premium.licenseKey]
toggle = "Got a license key or certificate file?"
@ -4533,6 +4534,26 @@ 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."
[admin.settings.premium.inputMethod]
text = "License Key"
file = "Certificate File"
[admin.settings.premium.file]
label = "License Certificate File"
description = "Upload your .lic or .cert license file from offline purchases"
choose = "Choose License File"
selected = "Selected: {{filename}} ({{size}})"
successMessage = "License file uploaded and activated successfully. No restart required."
[admin.settings.premium.currentLicense]
title = "Active License"
file = "Source: License file ({{path}})"
key = "Source: License key"
type = "Type: {{type}}"
noInput = "Please provide a license key or upload a certificate file"
success = "Success"
[admin.settings.premium.enabled]
label = "Enable Premium Features"
description = "Enable license key checks for pro/enterprise features"

View File

@ -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>

View File

@ -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
*/