diff --git a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java index 7bd5836c8..c75b4d23f 100644 --- a/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java +++ b/app/proprietary/src/main/java/stirling/software/proprietary/security/controller/api/AdminLicenseController.java @@ -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> 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 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"); + } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0b78669f4..bbbe0dc9e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index da021cdde..487c8d4cf 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -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" diff --git a/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx b/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx index 4cfc90936..2c86c9ced 100644 --- a/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx +++ b/frontend/src/proprietary/components/shared/config/configSections/AdminPlanSection.tsx @@ -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(''); const [savingLicense, setSavingLicense] = useState(false); + const [inputMethod, setInputMethod] = useState<'text' | 'file'>('text'); + const [licenseFile, setLicenseFile] = useState(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 = () => { )} + {/* Show current license source */} + {licenseInfo?.licenseKey && ( + } + > + + + {t('admin.settings.premium.currentLicense.title', 'Active License')} + + + {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')} + + + {t('admin.settings.premium.currentLicense.type', 'Type: {{type}}', { + type: licenseInfo.licenseType + })} + + + + )} + + {/* Input method selector */} + { + 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 */} - setLicenseKeyInput(e.target.value)} - placeholder={licenseInfo?.licenseKey || '00000000-0000-0000-0000-000000000000'} - type="password" - disabled={!loginEnabled || savingLicense} - /> + {inputMethod === 'text' ? ( + /* Existing text input */ + setLicenseKeyInput(e.target.value)} + placeholder={licenseInfo?.licenseKey || '00000000-0000-0000-0000-000000000000'} + type="password" + disabled={!loginEnabled || savingLicense} + /> + ) : ( + /* File upload */ +
+ + {t('admin.settings.premium.file.label', 'License Certificate File')} + + + {t('admin.settings.premium.file.description', 'Upload your .lic or .cert license file')} + + + {(props) => ( + + )} + + {licenseFile && ( + + {t('admin.settings.premium.file.selected', 'Selected: {{filename}} ({{size}})', { + filename: licenseFile.name, + size: (licenseFile.size / 1024).toFixed(2) + ' KB' + })} + + )} +
+ )} - diff --git a/frontend/src/proprietary/services/licenseService.ts b/frontend/src/proprietary/services/licenseService.ts index 867567977..d53129c17 100644 --- a/frontend/src/proprietary/services/licenseService.ts +++ b/frontend/src/proprietary/services/licenseService.ts @@ -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 { + 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 */