[License module] Fix casing in licenses, allow multiple uploads properly handle dirty states in frontend
This commit is contained in:
@@ -21,7 +21,13 @@ class LicenseController extends Controller
|
|||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$licenses = License::orderBy('expirationDate', 'asc')->get();
|
$licenses = License::orderBy('expirationDate', 'asc')->get();
|
||||||
return ApiDataTransformer::snakeToCamel($licenses->toArray());
|
|
||||||
|
return array_map(function (License $license) {
|
||||||
|
$licenseData = ApiDataTransformer::snakeToCamel($license->toArray());
|
||||||
|
$licenseData['validationInfo'] = $this->buildValidationInfo($licenseData);
|
||||||
|
|
||||||
|
return $licenseData;
|
||||||
|
}, $licenses->all());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,7 +41,10 @@ public function index()
|
|||||||
public function single($id)
|
public function single($id)
|
||||||
{
|
{
|
||||||
$license = License::findOrFail($id);
|
$license = License::findOrFail($id);
|
||||||
return ApiDataTransformer::snakeToCamel($license->toArray());
|
$licenseData = ApiDataTransformer::snakeToCamel($license->toArray());
|
||||||
|
$licenseData['validationInfo'] = $this->buildValidationInfo($licenseData);
|
||||||
|
|
||||||
|
return $licenseData;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,7 +112,8 @@ protected function getApplicationKeyPair(): array
|
|||||||
/**
|
/**
|
||||||
* Generate a signature for the license data.
|
* Generate a signature for the license data.
|
||||||
*
|
*
|
||||||
* Uses the private key to sign the data.
|
* The payload is signed in camelCase because the signature is consumed by PHP-side
|
||||||
|
* validation logic and is not persisted as a database column shape.
|
||||||
*
|
*
|
||||||
* @param array $licenseData The license data to sign
|
* @param array $licenseData The license data to sign
|
||||||
* @param string $privateKey The private key to use for signing
|
* @param string $privateKey The private key to use for signing
|
||||||
@@ -130,7 +140,7 @@ protected function sign($licenseData, $privateKey)
|
|||||||
*/
|
*/
|
||||||
public function store(Request $request)
|
public function store(Request $request)
|
||||||
{
|
{
|
||||||
// Validate the request data
|
// Validate the request data coming from the frontend in camelCase.
|
||||||
$validatedData = $request->validate([
|
$validatedData = $request->validate([
|
||||||
'name' => 'required|string',
|
'name' => 'required|string',
|
||||||
'email' => 'required|email',
|
'email' => 'required|email',
|
||||||
@@ -140,22 +150,24 @@ public function store(Request $request)
|
|||||||
'expirationDate' => 'nullable|date',
|
'expirationDate' => 'nullable|date',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Convert camelCase to snake_case
|
|
||||||
$snakeCaseData = ApiDataTransformer::camelToSnake($validatedData);
|
|
||||||
|
|
||||||
|
|
||||||
$keyPair = $this->getApplicationKeyPair();
|
$keyPair = $this->getApplicationKeyPair();
|
||||||
|
|
||||||
$licenseData = [
|
// Sign the camelCase business payload. The signature is validated by PHP code,
|
||||||
'name' => $snakeCaseData['name'],
|
// not persisted as part of the database column schema.
|
||||||
'email' => $snakeCaseData['email'],
|
$signatureData = [
|
||||||
'product' => $snakeCaseData['product'],
|
'name' => $validatedData['name'],
|
||||||
'version' => $snakeCaseData['version'],
|
'email' => $validatedData['email'],
|
||||||
'is_perpetual' => $snakeCaseData['is_perpetual'],
|
'product' => $validatedData['product'],
|
||||||
'expiration_date' => $snakeCaseData['expiration_date'],
|
'version' => $validatedData['version'],
|
||||||
|
'isPerpetual' => $validatedData['isPerpetual'],
|
||||||
|
'expirationDate' => $validatedData['isPerpetual'] ? null : $validatedData['expirationDate'],
|
||||||
];
|
];
|
||||||
|
|
||||||
$signature = $this->sign($licenseData, $keyPair['privateKey']);
|
$signature = $this->sign($signatureData, $keyPair['privateKey']);
|
||||||
|
|
||||||
|
// Convert incoming camelCase fields to snake_case only for database storage.
|
||||||
|
$snakeCaseData = ApiDataTransformer::camelToSnake($validatedData);
|
||||||
|
|
||||||
$license = License::create([
|
$license = License::create([
|
||||||
'name' => $snakeCaseData['name'],
|
'name' => $snakeCaseData['name'],
|
||||||
@@ -179,6 +191,8 @@ public function store(Request $request)
|
|||||||
*/
|
*/
|
||||||
public function validate(Request $request)
|
public function validate(Request $request)
|
||||||
{
|
{
|
||||||
|
// The frontend sends camelCase, and the signature is verified against the same
|
||||||
|
// camelCase representation. We keep snake_case only for database persistence.
|
||||||
$validatedData = $request->validate([
|
$validatedData = $request->validate([
|
||||||
'name' => 'required|string',
|
'name' => 'required|string',
|
||||||
'email' => 'required|email',
|
'email' => 'required|email',
|
||||||
@@ -189,42 +203,45 @@ public function validate(Request $request)
|
|||||||
'signature' => 'required|string',
|
'signature' => 'required|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $this->isValid(ApiDataTransformer::camelToSnake($validatedData));
|
return $this->buildValidationInfo($validatedData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate the license signature and expiration date.
|
* Build the validation info for a license payload.
|
||||||
*
|
*
|
||||||
* The validation logic should be portable to other languages.
|
* A license is considered valid when:
|
||||||
|
* - the signature is valid, and
|
||||||
|
* - the license is perpetual, or the expiration date is still in the future.
|
||||||
*
|
*
|
||||||
* @param array $licenseData The license data to validate
|
* @param array $licenseData The license data to validate
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
protected function isValid(array $licenseData)
|
protected function buildValidationInfo(array $licenseData): array
|
||||||
{
|
{
|
||||||
|
|
||||||
$publicKey = Storage::disk('local')->get($this->publicKeyPath());
|
$publicKey = Storage::disk('local')->get($this->publicKeyPath());
|
||||||
|
|
||||||
|
$licenseData = ApiDataTransformer::snakeToCamel($licenseData);
|
||||||
|
$isPerpetual = (bool) ($licenseData['isPerpetual'] ?? false);
|
||||||
|
$expirationDate = $licenseData['expirationDate'] ?? null;
|
||||||
|
|
||||||
// Convert the license data to JSON
|
// Validate against the same camelCase payload shape used to generate the signature.
|
||||||
$licenseJson = json_encode($licenseData);
|
$licenseJson = json_encode([
|
||||||
|
'name' => $licenseData['name'] ?? '',
|
||||||
|
'email' => $licenseData['email'] ?? '',
|
||||||
|
'product' => $licenseData['product'] ?? '',
|
||||||
|
'version' => $licenseData['version'] ?? '',
|
||||||
|
'isPerpetual' => $isPerpetual,
|
||||||
|
'expirationDate' => $expirationDate,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$signature = base64_decode($licenseData['signature'] ?? '', true);
|
||||||
|
$isSignatureValid = $signature !== false && openssl_verify($licenseJson, $signature, $publicKey, OPENSSL_ALGO_SHA256) === 1;
|
||||||
|
$isExpired = !$isPerpetual && !empty($expirationDate) && now()->gt($expirationDate);
|
||||||
|
|
||||||
// Verify the signature
|
|
||||||
$signature = base64_decode($licenseData['signature']);
|
|
||||||
$isSignatureValid = openssl_verify($licenseJson, $signature, $publicKey, OPENSSL_ALGO_SHA256);
|
|
||||||
|
|
||||||
// Check the expiration date if the license is not perpetual
|
|
||||||
$isExpired = false;
|
|
||||||
if (!$licenseData['is_perpetual'] && $licenseData['expiration_date']) {
|
|
||||||
$isExpired = now()->gt($licenseData['expiration_date']);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the validation info
|
|
||||||
return [
|
return [
|
||||||
'isValid' => $isSignatureValid === 1 && !$isExpired,
|
'isValid' => $isSignatureValid && !$isExpired,
|
||||||
'isSignatureValid' => $isSignatureValid === 1,
|
'isSignatureValid' => $isSignatureValid,
|
||||||
'isExpired' => $isExpired,
|
'isExpired' => $isExpired,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -247,12 +264,12 @@ public function download(int $id)
|
|||||||
'product' => $license->product,
|
'product' => $license->product,
|
||||||
'version' => $license->version,
|
'version' => $license->version,
|
||||||
'is_perpetual' => $license->is_perpetual,
|
'is_perpetual' => $license->is_perpetual,
|
||||||
'expiration_date' => $license->expiration_date,
|
'expiration_date' => $license->expiration_date?->format('Y-m-d'),
|
||||||
'signature' => $license->signature,
|
'signature' => $license->signature,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Convert the license data to JSON
|
// Normalize the outgoing payload to camelCase for the frontend consumer.
|
||||||
$licenseJson = json_encode($licenseData, JSON_PRETTY_PRINT);
|
$licenseJson = json_encode(ApiDataTransformer::snakeToCamel($licenseData), JSON_PRETTY_PRINT);
|
||||||
|
|
||||||
// Create a response with the JSON file
|
// Create a response with the JSON file
|
||||||
$response = response($licenseJson, 200, [
|
$response = response($licenseJson, 200, [
|
||||||
@@ -263,6 +280,91 @@ public function download(int $id)
|
|||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a single license update using the shared business logic.
|
||||||
|
*
|
||||||
|
* @param array $data The validated license payload in camelCase
|
||||||
|
* @param int $id The license id
|
||||||
|
* @param string $privateKey The application private key used to sign the payload
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
protected function persistLicenseUpdate(array $data, int $id, string $privateKey): array
|
||||||
|
{
|
||||||
|
$data['expirationDate'] = $data['isPerpetual'] ? null : $data['expirationDate'];
|
||||||
|
|
||||||
|
$signatureData = [
|
||||||
|
'name' => $data['name'],
|
||||||
|
'email' => $data['email'],
|
||||||
|
'product' => $data['product'],
|
||||||
|
'version' => $data['version'],
|
||||||
|
'isPerpetual' => $data['isPerpetual'],
|
||||||
|
'expirationDate' => $data['expirationDate'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$signature = $this->sign($signatureData, $privateKey);
|
||||||
|
$snakeCaseData = ApiDataTransformer::camelToSnake($data);
|
||||||
|
|
||||||
|
$license = License::findOrFail($id);
|
||||||
|
$license->update([
|
||||||
|
'name' => $snakeCaseData['name'],
|
||||||
|
'email' => $snakeCaseData['email'],
|
||||||
|
'product' => $snakeCaseData['product'],
|
||||||
|
'version' => $snakeCaseData['version'],
|
||||||
|
'is_perpetual' => $snakeCaseData['is_perpetual'],
|
||||||
|
'expiration_date' => $snakeCaseData['expiration_date'],
|
||||||
|
'signature' => $signature,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$licenseData = ApiDataTransformer::snakeToCamel($license->fresh()->toArray());
|
||||||
|
$licenseData['validationInfo'] = $this->buildValidationInfo($licenseData);
|
||||||
|
|
||||||
|
return $licenseData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update multiple licenses in a single request.
|
||||||
|
*
|
||||||
|
* This keeps the database writes in one HTTP request and avoids SQLite lock contention
|
||||||
|
* that can happen when a page sends one update request per dirty license in parallel.
|
||||||
|
*
|
||||||
|
* @param \Illuminate\Http\Request $request The HTTP request containing multiple updated license records
|
||||||
|
* @return array|\Illuminate\Http\JsonResponse
|
||||||
|
*/
|
||||||
|
public function updateMany(Request $request)
|
||||||
|
{
|
||||||
|
$validatedData = $request->validate([
|
||||||
|
'*.id' => 'required|integer',
|
||||||
|
'*.name' => 'required|string',
|
||||||
|
'*.email' => 'required|email',
|
||||||
|
'*.product' => 'required|string',
|
||||||
|
'*.version' => 'required|string',
|
||||||
|
'*.isPerpetual' => 'required|boolean',
|
||||||
|
'*.expirationDate' => 'nullable|date',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$privateKey = $this->getApplicationKeyPair()['privateKey'];
|
||||||
|
|
||||||
|
DB::beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$updatedLicenses = [];
|
||||||
|
|
||||||
|
foreach ($validatedData as $licensePayload) {
|
||||||
|
$updatedLicenses[] = $this->persistLicenseUpdate($licensePayload, $licensePayload['id'], $privateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::commit();
|
||||||
|
|
||||||
|
return $updatedLicenses;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
DB::rollBack();
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Lizenzen konnten nicht aktualisiert werden',
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the specified resource in storage.
|
* Update the specified resource in storage.
|
||||||
*
|
*
|
||||||
@@ -274,7 +376,7 @@ public function download(int $id)
|
|||||||
*/
|
*/
|
||||||
public function update(Request $request, int $id)
|
public function update(Request $request, int $id)
|
||||||
{
|
{
|
||||||
// Validate the request data
|
// Validate the request data coming from the frontend in camelCase.
|
||||||
$validatedData = $request->validate([
|
$validatedData = $request->validate([
|
||||||
'name' => 'required|string',
|
'name' => 'required|string',
|
||||||
'email' => 'required|email',
|
'email' => 'required|email',
|
||||||
@@ -284,46 +386,16 @@ public function update(Request $request, int $id)
|
|||||||
'expirationDate' => 'nullable|date',
|
'expirationDate' => 'nullable|date',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Convert camelCase to snake_case
|
|
||||||
$snakeCaseData = ApiDataTransformer::camelToSnake($validatedData);
|
|
||||||
|
|
||||||
DB::beginTransaction();
|
DB::beginTransaction();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Find license
|
|
||||||
$license = License::findOrFail($id);
|
|
||||||
|
|
||||||
// Retrieve the private key for the license
|
|
||||||
$privateKey = $this->getApplicationKeyPair()['privateKey'];
|
$privateKey = $this->getApplicationKeyPair()['privateKey'];
|
||||||
|
$licenseData = $this->persistLicenseUpdate($validatedData, $id, $privateKey);
|
||||||
// Generate the license data
|
|
||||||
$licenseData = [
|
|
||||||
'name' => $snakeCaseData['name'],
|
|
||||||
'email' => $snakeCaseData['email'],
|
|
||||||
'product' => $snakeCaseData['product'],
|
|
||||||
'version' => $snakeCaseData['version'],
|
|
||||||
'is_perpetual' => $snakeCaseData['is_perpetual'],
|
|
||||||
'expiration_date' => $snakeCaseData['expiration_date'],
|
|
||||||
];
|
|
||||||
|
|
||||||
// Generate the signature
|
|
||||||
$signature = $this->sign($licenseData, $privateKey);
|
|
||||||
|
|
||||||
// Update the license
|
|
||||||
$license->update([
|
|
||||||
'name' => $snakeCaseData['name'],
|
|
||||||
'email' => $snakeCaseData['email'],
|
|
||||||
'product' => $snakeCaseData['product'],
|
|
||||||
'version' => $snakeCaseData['version'],
|
|
||||||
'is_perpetual' => $snakeCaseData['is_perpetual'],
|
|
||||||
'expiration_date' => $snakeCaseData['expiration_date'],
|
|
||||||
'signature' => $signature,
|
|
||||||
]);
|
|
||||||
|
|
||||||
DB::commit();
|
DB::commit();
|
||||||
|
|
||||||
// Return the updated license
|
// Return the updated license with its validation info attached.
|
||||||
return ApiDataTransformer::snakeToCamel($license->toArray());
|
return $licenseData;
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
DB::rollBack();
|
DB::rollBack();
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public function up(): void
|
|||||||
$table->string('product');
|
$table->string('product');
|
||||||
$table->string('version');
|
$table->string('version');
|
||||||
$table->boolean('is_perpetual');
|
$table->boolean('is_perpetual');
|
||||||
$table->string('expiration_date')->nullable();;
|
$table->string('expiration_date')->nullable();
|
||||||
$table->string('signature');
|
$table->string('signature');
|
||||||
$table->timestamps();
|
$table->timestamps();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { License } from '@/types'
|
import { License } from '@/types'
|
||||||
import { newLicense } from '@/types/index.d'
|
import { newLicense } from '@/types/index.d'
|
||||||
import { toLocalDate, toShortISOString, hotkey } from '@/lib/utils'
|
import { hotkey, toShortISOString } from '@/lib/utils'
|
||||||
import Fuse from 'fuse.js'
|
import Fuse from 'fuse.js'
|
||||||
import { onMounted, ref, computed } from 'vue'
|
import { onMounted, ref, computed } from 'vue'
|
||||||
import AppLayout from '@/layouts/AppLayout.vue'
|
import AppLayout from '@/layouts/AppLayout.vue'
|
||||||
@@ -13,85 +13,126 @@ import Button from '@/components/ui/crm-button/Button.vue'
|
|||||||
import { ButtonGroup } from '@/components/ui/button-group'
|
import { ButtonGroup } from '@/components/ui/button-group'
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
||||||
import LicenseService from '@/services/LicenseService';
|
import LicenseService from '@/services/LicenseService'
|
||||||
import { Download, Trash2, Plus, Delete, Search, CheckCircle, CheckCircle2, XCircle, CircleQuestionMark, BadgeQuestionMark, Eye } from 'lucide-vue-next'
|
import { Download, Trash2, Plus, Delete, Search, CheckCircle2, XCircle, CircleQuestionMark, Save } from 'lucide-vue-next'
|
||||||
import { alertStore } from "@/stores/alertStore"
|
import { alertStore } from '@/stores/alertStore'
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
licenseData: License[]
|
licenseData: License[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
// Working copy of the licenses shown in the table. All form inputs bind against this array.
|
||||||
const licenses = ref([] as License[])
|
const licenses = ref([] as License[])
|
||||||
|
// Snapshot of the initial server data used to detect whether a license has been changed.
|
||||||
|
const originalLicenses = ref([] as License[])
|
||||||
|
// Deep-clone the incoming license list so edits to the UI do not mutate the original props object.
|
||||||
|
const cloneLicenses = (licenseList: License[]) => JSON.parse(JSON.stringify(licenseList)) as License[]
|
||||||
|
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const searchField = ref()
|
const searchField = ref()
|
||||||
const alert = alertStore()
|
const alert = alertStore()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
licenses.value = props.licenseData
|
licenses.value = cloneLicenses(props.licenseData)
|
||||||
|
originalLicenses.value = cloneLicenses(licenses.value)
|
||||||
searchField.value = document.getElementById('search')
|
searchField.value = document.getElementById('search')
|
||||||
hotkey('n', createLicense)
|
hotkey('n', createLicense)
|
||||||
})
|
})
|
||||||
|
|
||||||
const fuse = computed(() => {
|
const fuse = computed(() => {
|
||||||
return new Fuse(props.licenseData, {
|
return new Fuse(licenses.value, {
|
||||||
keys: ['name', 'email', 'product', 'version'],
|
keys: ['name', 'email', 'product', 'version'],
|
||||||
threshold: 0.3
|
threshold: 0.3,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const filteredLicenses = computed(() => {
|
const filteredLicenses = computed(() => {
|
||||||
let filteredLicenses = licenses.value
|
let filtered = licenses.value
|
||||||
// Filter by search query
|
|
||||||
if (searchQuery.value) {
|
if (searchQuery.value) {
|
||||||
filteredLicenses = fuse.value.search(searchQuery.value).map(result => result.item)
|
filtered = fuse.value.search(searchQuery.value).map((result) => result.item)
|
||||||
}
|
}
|
||||||
return filteredLicenses
|
|
||||||
|
return filtered
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
const isDirty = computed(() => {
|
||||||
|
return licenses.value.some(isLicenseDirty)
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLicenseDirty = (license: License) => {
|
||||||
|
const originalLicense = originalLicenses.value.find((item) => item.id === license.id)
|
||||||
|
|
||||||
|
if (!originalLicense) {
|
||||||
|
return license.id === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(originalLicense) !== JSON.stringify(license)
|
||||||
|
}
|
||||||
|
|
||||||
const createLicense = () => {
|
const createLicense = () => {
|
||||||
LicenseService.createLicense(newLicense()).then(license => {
|
LicenseService.createLicense(newLicense()).then((license) => {
|
||||||
if (license) licenses.value.unshift(license)
|
if (license) {
|
||||||
|
licenses.value.unshift(license)
|
||||||
|
originalLicenses.value = cloneLicenses(licenses.value)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateLicense = (license: License) => {
|
const updateLicenses = async () => {
|
||||||
LicenseService.updateLicense(license).then((response) => {
|
const dirtyLicenses = licenses.value.filter(isLicenseDirty)
|
||||||
if (response) license = response
|
|
||||||
|
if (dirtyLicenses.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await LicenseService.updateLicenses(dirtyLicenses)
|
||||||
|
|
||||||
|
if (response) {
|
||||||
|
response.forEach((updatedLicense) => {
|
||||||
|
var i = licenses.value.findIndex(license => license.id == updatedLicense.id)
|
||||||
|
licenses.value[i] = updatedLicense
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
originalLicenses.value = cloneLicenses(licenses.value)
|
||||||
|
}
|
||||||
|
|
||||||
const downloadLicense = (license: License) => {
|
const downloadLicense = (license: License) => {
|
||||||
window.open('/licenses/download/' + license.id)
|
window.open('/licenses/download/' + license.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteLicense = (license: License) => {
|
const deleteLicense = (license: License) => {
|
||||||
if (license.id == 0) {
|
if (license.id === 0) {
|
||||||
licenses.value = licenses.value.filter(l => l != license)
|
licenses.value = licenses.value.filter((item) => item !== license)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
alert.show(
|
alert.show(
|
||||||
"Möchtest Du diese Lizenz wirklich löschen?",
|
'Möchtest Du diese Lizenz wirklich löschen?',
|
||||||
null,
|
null,
|
||||||
{
|
{
|
||||||
actionText: "Löschen",
|
actionText: 'Löschen',
|
||||||
actionVariant: "destructive",
|
actionVariant: 'destructive',
|
||||||
onAction: async () => {
|
onAction: async () => {
|
||||||
LicenseService.deleteLicense(license.id).then(() => {
|
LicenseService.deleteLicense(license.id).then(() => {
|
||||||
licenses.value = licenses.value.filter(license => license.id != license.id)
|
licenses.value = licenses.value.filter((item) => item.id !== license.id)
|
||||||
|
originalLicenses.value = cloneLicenses(licenses.value)
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
toast.error('Fehler beim Löschen der Lizenz', { duration: 5000, description: error.message })
|
toast.error('Fehler beim Löschen der Lizenz', { duration: 5000, description: error.message })
|
||||||
})
|
})
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const remToPx = (rem: number) => {
|
const remToPx = (rem: number) => {
|
||||||
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -121,19 +162,21 @@ const remToPx = (rem: number) => {
|
|||||||
<template #right>
|
<template #right>
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
|
<!-- Save button -->
|
||||||
|
<Button variant="action" @click="updateLicenses" v-if="isDirty">
|
||||||
|
<Save /> Speichern
|
||||||
|
</Button>
|
||||||
|
|
||||||
<!-- New button -->
|
<!-- New button -->
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger>
|
<TooltipTrigger>
|
||||||
<Button @click="createLicense">
|
<Button @click="createLicense">
|
||||||
<Plus />
|
<Plus /> Neu
|
||||||
Neu
|
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<span>Neue Lizenz anlegen</span>
|
<span>Neue Lizenz anlegen</span>
|
||||||
<KbdGroup class="ml-2">
|
<KbdGroup class="ml-2"><Kbd>N</Kbd></KbdGroup>
|
||||||
<Kbd>N</Kbd>
|
|
||||||
</KbdGroup>
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -159,37 +202,33 @@ const remToPx = (rem: number) => {
|
|||||||
|
|
||||||
<TableRow v-for="license in filteredLicenses" :key="license.id">
|
<TableRow v-for="license in filteredLicenses" :key="license.id">
|
||||||
<TableCell class="text-center">
|
<TableCell class="text-center">
|
||||||
<CircleQuestionMark class="inline-block text-muted-foreground" :size="remToPx(1)"
|
<CircleQuestionMark class="size-5 inline-block text-muted-foreground" :size="remToPx(1)"
|
||||||
v-if="license.validationInfo === undefined" />
|
v-if="license.validationInfo === undefined" />
|
||||||
<CheckCircle2 class="inline-block text-success0" :size="remToPx(1)"
|
<CheckCircle2 class="size-5 inline-block text-success" :size="remToPx(1)"
|
||||||
v-else-if="license.validationInfo.isExpired === false" />
|
v-else-if="license.validationInfo.isExpired === false" />
|
||||||
<XCircle class="inline-block text-destructive0" :size="remToPx(1)" v-else />
|
<XCircle class="size-5 inline-block text-destructive" :size="remToPx(1)" v-else />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-center">
|
<TableCell class="text-center">
|
||||||
<input type="checkbox" v-model="license.isPerpetual"
|
<input type="checkbox" v-model="license.isPerpetual" />
|
||||||
v-on:change="updateLicense(license)" />
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="whitespace-nowrap">
|
<TableCell class="whitespace-nowrap">
|
||||||
<div class="text-center" v-if="license.isPerpetual">–</div>
|
<div class="text-center" v-if="license.isPerpetual">–</div>
|
||||||
<Input v-else type="date" :modelValue="toShortISOString(license.expirationDate || new Date())"
|
<Input v-else type="date" :modelValue="toShortISOString(license.expirationDate || new Date())"
|
||||||
v-on:blur="updateLicense(license)" placeholder="Datum" />
|
@update:model-value="(value) => license.expirationDate = value as string"
|
||||||
|
placeholder="Datum" />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Input type="text" :modelValue="license.product" placeholder="Produkt Name"
|
<Input type="text" v-model="license.product" placeholder="Produkt Name" />
|
||||||
v-on:blur="updateLicense(license)" />
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Input type="text" :modelValue="license.version" placeholder="1.0.0"
|
<Input type="text" v-model="license.version" placeholder="1.0.0" />
|
||||||
v-on:blur="updateLicense(license)" />
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Input type="text" v-model="license.name" placeholder="Firma"
|
<Input type="text" v-model="license.name" placeholder="Firma" />
|
||||||
v-on:blur="updateLicense(license)" />
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Input type="email" :modelValue="license.email" placeholder="E-Mail"
|
<Input type="email" v-model="license.email" placeholder="E-Mail" />
|
||||||
v-on:blur="updateLicense(license)" />
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
<TableCell class="print:hidden">
|
<TableCell class="print:hidden">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import axios, { AxiosError } from 'axios';
|
import axios, { AxiosError } from 'axios';
|
||||||
import { License } from '@/types';
|
import { License, LicenseValidationInfo } from '@/types';
|
||||||
import { toast } from 'vue-sonner';
|
import { toast } from 'vue-sonner';
|
||||||
|
|
||||||
const API_URL = '/api/licenses';
|
const API_URL = '/api/licenses';
|
||||||
@@ -53,7 +53,7 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates an existing license
|
* Updates one existing license
|
||||||
* @param license - The license data to update
|
* @param license - The license data to update
|
||||||
* @returns Promise<License | null>
|
* @returns Promise<License | null>
|
||||||
*/
|
*/
|
||||||
@@ -68,6 +68,22 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates multiple licenses in one request.
|
||||||
|
* @param licenses - The dirty license data to update
|
||||||
|
* @returns Promise<License[] | null>
|
||||||
|
*/
|
||||||
|
async updateLicenses(licenses: License[]): Promise<License[] | null> {
|
||||||
|
try {
|
||||||
|
const response = await axios.put(API_URL, licenses);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Fehler beim Aktualisieren der Lizenzen', { description: (error as AxiosError).message });
|
||||||
|
console.error(error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes a license by ID
|
* Deletes a license by ID
|
||||||
* @param id - The ID of the license to delete
|
* @param id - The ID of the license to delete
|
||||||
@@ -113,9 +129,9 @@ export default {
|
|||||||
* @param licenseId - The ID of the license to validate
|
* @param licenseId - The ID of the license to validate
|
||||||
* @returns Promise<{ is_valid: boolean, is_signature_valid: boolean, is_expired: boolean } | null>
|
* @returns Promise<{ is_valid: boolean, is_signature_valid: boolean, is_expired: boolean } | null>
|
||||||
*/
|
*/
|
||||||
async validateLicense(license: License): Promise<{ isValid: boolean, isSignatureValid: boolean, isExpired: boolean } | null> {
|
async validateLicense(license: License): Promise<LicenseValidationInfo | null> {
|
||||||
try {
|
try {
|
||||||
const response = await axios.post('/api/licenses/validate', { license });
|
const response = await axios.post('/api/licenses/validate', license);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Fehler beim Validieren der Lizenz', { description: (error as AxiosError).message });
|
toast.error('Fehler beim Validieren der Lizenz', { description: (error as AxiosError).message });
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
Route::get('/licenses/{id}', [\App\Http\Controllers\LicenseController::class, 'single']);
|
Route::get('/licenses/{id}', [\App\Http\Controllers\LicenseController::class, 'single']);
|
||||||
Route::delete('/licenses/{id}', [\App\Http\Controllers\LicenseController::class, 'destroy']);
|
Route::delete('/licenses/{id}', [\App\Http\Controllers\LicenseController::class, 'destroy']);
|
||||||
Route::post('/licenses', [\App\Http\Controllers\LicenseController::class, 'store']);
|
Route::post('/licenses', [\App\Http\Controllers\LicenseController::class, 'store']);
|
||||||
|
Route::put('/licenses', [\App\Http\Controllers\LicenseController::class, 'updateMany']);
|
||||||
Route::put('/licenses/{id}', [\App\Http\Controllers\LicenseController::class, 'update']);
|
Route::put('/licenses/{id}', [\App\Http\Controllers\LicenseController::class, 'update']);
|
||||||
Route::post('/licenses/validate', [\App\Http\Controllers\LicenseController::class, 'validate']);
|
Route::post('/licenses/validate', [\App\Http\Controllers\LicenseController::class, 'validate']);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user