Added license management module
This commit is contained in:
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class GenerateLicenseKeysCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'license:generate-keys {--force : Overwrite existing key pair}';
|
||||||
|
|
||||||
|
protected $description = 'Generate a new application-wide license signing key pair.';
|
||||||
|
|
||||||
|
public function handle(): int
|
||||||
|
{
|
||||||
|
$directory = 'license-keys';
|
||||||
|
$privateKeyPath = $directory . '/private.pem';
|
||||||
|
$publicKeyPath = $directory . '/public.pem';
|
||||||
|
$disk = Storage::disk('local');
|
||||||
|
|
||||||
|
$privateExists = $disk->exists($privateKeyPath);
|
||||||
|
$publicExists = $disk->exists($publicKeyPath);
|
||||||
|
|
||||||
|
if (($privateExists || $publicExists) && !$this->option('force')) {
|
||||||
|
$this->error('Key pair already exists. Use --force to overwrite.');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$keyPair = $this->generateKeyPair();
|
||||||
|
$disk->put($privateKeyPath, $keyPair['privateKey']);
|
||||||
|
$disk->put($publicKeyPath, $keyPair['publicKey']);
|
||||||
|
|
||||||
|
$this->info('Generated application license key pair.');
|
||||||
|
$this->line('Private key: ' . $privateKeyPath);
|
||||||
|
$this->line('Public key: ' . $publicKeyPath);
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function generateKeyPair(): array
|
||||||
|
{
|
||||||
|
$privateKey = openssl_pkey_new([
|
||||||
|
'private_key_bits' => 2048,
|
||||||
|
'private_key_type' => OPENSSL_KEYTYPE_RSA,
|
||||||
|
]);
|
||||||
|
|
||||||
|
openssl_pkey_export($privateKey, $privateKeyPem);
|
||||||
|
$publicKeyDetails = openssl_pkey_get_details($privateKey);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'privateKey' => $privateKeyPem,
|
||||||
|
'publicKey' => $publicKeyDetails['key'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ class Kernel extends ConsoleKernel
|
|||||||
protected $commands = [
|
protected $commands = [
|
||||||
\App\Console\Commands\CaldavSyncCommand::class,
|
\App\Console\Commands\CaldavSyncCommand::class,
|
||||||
\App\Console\Commands\CheckInvoiceDueDatesCommand::class,
|
\App\Console\Commands\CheckInvoiceDueDatesCommand::class,
|
||||||
|
\App\Console\Commands\GenerateLicenseKeysCommand::class,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -541,7 +541,7 @@ public function store(Request $request)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function update(Request $request, $id)
|
public function update(Request $request, int $id)
|
||||||
{
|
{
|
||||||
// Validate input data (expecting camelCase)
|
// Validate input data (expecting camelCase)
|
||||||
$validatedData = $request->validate([
|
$validatedData = $request->validate([
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\License;
|
||||||
|
use App\Support\ApiDataTransformer;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
|
||||||
|
class LicenseController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display a listing of the resource.
|
||||||
|
*
|
||||||
|
* Retrieves all licenses from the database, ordered by expiration date.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$licenses = License::orderBy('expirationDate', 'asc')->get();
|
||||||
|
return ApiDataTransformer::snakeToCamel($licenses->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the specified resource.
|
||||||
|
*
|
||||||
|
* Retrieves a single license by its ID.
|
||||||
|
*
|
||||||
|
* @param int $id The ID of the license to retrieve
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function single($id)
|
||||||
|
{
|
||||||
|
$license = License::findOrFail($id);
|
||||||
|
return ApiDataTransformer::snakeToCamel($license->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display the specified resource.
|
||||||
|
*
|
||||||
|
* Renders the Inertia view for licenses
|
||||||
|
*
|
||||||
|
* @return \Inertia\Response
|
||||||
|
*/
|
||||||
|
public function show()
|
||||||
|
{
|
||||||
|
return Inertia::render(
|
||||||
|
'Licenses',
|
||||||
|
['licenseData' => $this->index()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the directory path for license keys.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function licenseKeyDirectory(): string
|
||||||
|
{
|
||||||
|
return 'license-keys';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the path for the private key file.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function privateKeyPath(): string
|
||||||
|
{
|
||||||
|
return $this->licenseKeyDirectory() . '/private.pem';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the path for the public key file.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function publicKeyPath(): string
|
||||||
|
{
|
||||||
|
return $this->licenseKeyDirectory() . '/public.pem';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the application's key pair.
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
protected function getApplicationKeyPair(): array
|
||||||
|
{
|
||||||
|
if (!Storage::disk('local')->exists($this->privateKeyPath()) || !Storage::disk('local')->exists($this->publicKeyPath())) {
|
||||||
|
abort(500, 'Application license key pair is missing. Generate it with `php artisan license:generate-keys`.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'privateKey' => Storage::disk('local')->get($this->privateKeyPath()),
|
||||||
|
'publicKey' => Storage::disk('local')->get($this->publicKeyPath()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a signature for the license data.
|
||||||
|
*
|
||||||
|
* Uses the private key to sign the data.
|
||||||
|
*
|
||||||
|
* @param array $licenseData The license data to sign
|
||||||
|
* @param string $privateKey The private key to use for signing
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function generateSignature($licenseData, $privateKey)
|
||||||
|
{
|
||||||
|
$licenseJson = json_encode($licenseData);
|
||||||
|
if ($licenseJson === false) throw new \Exception('Fehler beim JSON-Encode der Lizenzdaten: ' . json_last_error_msg());
|
||||||
|
|
||||||
|
$success = openssl_sign($licenseJson, $signature, $privateKey, OPENSSL_ALGO_SHA256);
|
||||||
|
if ($success === false) throw new \Exception('Fehler beim Signieren der Lizenzdaten: ' . openssl_error_string());
|
||||||
|
|
||||||
|
return base64_encode($signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a newly created resource in storage.
|
||||||
|
*
|
||||||
|
* Validates the request data, generates a signature, and creates a new license.
|
||||||
|
*
|
||||||
|
* @param \Illuminate\Http\Request $request The HTTP request containing license data
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
// Validate the request data
|
||||||
|
$validatedData = $request->validate([
|
||||||
|
'name' => 'required|string',
|
||||||
|
'email' => 'required|email',
|
||||||
|
'product' => 'required|string',
|
||||||
|
'version' => 'required|string',
|
||||||
|
'isPerpetual' => 'required|boolean',
|
||||||
|
'expirationDate' => 'nullable|date',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Convert camelCase to snake_case
|
||||||
|
$snakeCaseData = ApiDataTransformer::camelToSnake($validatedData);
|
||||||
|
|
||||||
|
|
||||||
|
$keyPair = $this->getApplicationKeyPair();
|
||||||
|
|
||||||
|
$licenseData = [
|
||||||
|
'name' => $snakeCaseData['name'],
|
||||||
|
'email' => $snakeCaseData['email'],
|
||||||
|
'product' => $snakeCaseData['product'],
|
||||||
|
'version' => $snakeCaseData['version'],
|
||||||
|
'is_perpetual' => $snakeCaseData['is_perpetual'],
|
||||||
|
'expiration_date' => $snakeCaseData['expiration_date'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$signature = $this->generateSignature($licenseData, $keyPair['privateKey']);
|
||||||
|
|
||||||
|
$license = License::create([
|
||||||
|
'name' => $snakeCaseData['name'],
|
||||||
|
'email' => $snakeCaseData['email'],
|
||||||
|
'product' => $snakeCaseData['product'],
|
||||||
|
'version' => $snakeCaseData['version'],
|
||||||
|
'is_perpetual' => $snakeCaseData['is_perpetual'],
|
||||||
|
'expiration_date' => $snakeCaseData['expiration_date'],
|
||||||
|
'signature' => $signature,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ApiDataTransformer::snakeToCamel($license->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the license signature and expiration date.
|
||||||
|
*
|
||||||
|
* The validation logic should be portable to other languages.
|
||||||
|
*
|
||||||
|
* @param \Illuminate\Http\Request $request The HTTP request containing the license ID
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function validate(Request $request)
|
||||||
|
{
|
||||||
|
// Get the license ID from the request
|
||||||
|
$licenseId = $request->input('license_id');
|
||||||
|
|
||||||
|
// Find the license
|
||||||
|
$license = License::findOrFail($licenseId);
|
||||||
|
|
||||||
|
$publicKey = Storage::disk('local')->get($this->publicKeyPath());
|
||||||
|
|
||||||
|
$licenseData = [
|
||||||
|
'name' => $license->name,
|
||||||
|
'email' => $license->email,
|
||||||
|
'product' => $license->product,
|
||||||
|
'version' => $license->version,
|
||||||
|
'is_perpetual' => $license->is_perpetual,
|
||||||
|
'expiration_date' => $license->expiration_date,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Convert the license data to JSON
|
||||||
|
$licenseJson = json_encode($licenseData);
|
||||||
|
|
||||||
|
// Verify the signature
|
||||||
|
$signature = base64_decode($license->signature);
|
||||||
|
$isSignatureValid = openssl_verify($licenseJson, $signature, $publicKey, OPENSSL_ALGO_SHA256);
|
||||||
|
|
||||||
|
// Check the expiration date if the license is not perpetual
|
||||||
|
$isExpired = false;
|
||||||
|
if (!$license->is_perpetual && $license->expiration_date) {
|
||||||
|
$isExpired = now()->gt($license->expiration_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the validation result
|
||||||
|
return [
|
||||||
|
'is_valid' => $isSignatureValid === 1 && !$isExpired,
|
||||||
|
'is_signature_valid' => $isSignatureValid === 1,
|
||||||
|
'is_expired' => $isExpired,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download the license as a JSON file.
|
||||||
|
*
|
||||||
|
* @param int $id The ID of the license to download
|
||||||
|
* @return \Symfony\Component\HttpFoundation\StreamedResponse
|
||||||
|
*/
|
||||||
|
public function download(int $id)
|
||||||
|
{
|
||||||
|
// Find the license
|
||||||
|
$license = License::findOrFail($id);
|
||||||
|
|
||||||
|
// Prepare the license data for download
|
||||||
|
$licenseData = [
|
||||||
|
'name' => $license->name,
|
||||||
|
'email' => $license->email,
|
||||||
|
'product' => $license->product,
|
||||||
|
'version' => $license->version,
|
||||||
|
'is_perpetual' => $license->is_perpetual,
|
||||||
|
'expiration_date' => $license->expiration_date,
|
||||||
|
'signature' => $license->signature,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Convert the license data to JSON
|
||||||
|
$licenseJson = json_encode($licenseData, JSON_PRETTY_PRINT);
|
||||||
|
|
||||||
|
// Create a response with the JSON file
|
||||||
|
$response = response($licenseJson, 200, [
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'Content-Disposition' => 'attachment; filename="' . $licenseData['product'] . '.json"',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the specified resource in storage.
|
||||||
|
*
|
||||||
|
* Validates the request data, generates a new signature, and updates the license.
|
||||||
|
*
|
||||||
|
* @param \Illuminate\Http\Request $request The HTTP request containing updated license data
|
||||||
|
* @param int $id The ID of the license to update
|
||||||
|
* @return array|\Illuminate\Http\JsonResponse
|
||||||
|
*/
|
||||||
|
public function update(Request $request, int $id)
|
||||||
|
{
|
||||||
|
// Validate the request data
|
||||||
|
$validatedData = $request->validate([
|
||||||
|
'name' => 'required|string',
|
||||||
|
'email' => 'required|email',
|
||||||
|
'product' => 'required|string',
|
||||||
|
'version' => 'required|string',
|
||||||
|
'isPerpetual' => 'required|boolean',
|
||||||
|
'expirationDate' => 'nullable|date',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Convert camelCase to snake_case
|
||||||
|
$snakeCaseData = ApiDataTransformer::camelToSnake($validatedData);
|
||||||
|
|
||||||
|
DB::beginTransaction();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find license
|
||||||
|
$license = License::findOrFail($id);
|
||||||
|
|
||||||
|
// Retrieve the private key for the license
|
||||||
|
$privateKey = $this->getApplicationKeyPair()['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->generateSignature($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();
|
||||||
|
|
||||||
|
// Return the updated license
|
||||||
|
return ApiDataTransformer::snakeToCamel($license->toArray());
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
DB::rollBack();
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Lizenz konnte nicht aktualisiert werden',
|
||||||
|
'error' => $e->getMessage()
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove the specified resource from storage.
|
||||||
|
*
|
||||||
|
* Deletes the specified license from the database.
|
||||||
|
*
|
||||||
|
* @param int $id The ID of the license to delete
|
||||||
|
* @return \Illuminate\Http\JsonResponse
|
||||||
|
*/
|
||||||
|
public function destroy(int $id)
|
||||||
|
{
|
||||||
|
$license = License::findOrFail($id);
|
||||||
|
$license->delete();
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Lizenz erfolgreich gelöscht'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
|
||||||
|
class License extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\LicenseFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'name',
|
||||||
|
'email',
|
||||||
|
'product',
|
||||||
|
'version',
|
||||||
|
'is_perpetual',
|
||||||
|
'expiration_date',
|
||||||
|
'signature'
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_perpetual' => 'boolean',
|
||||||
|
'expiration_date' => 'datetime'
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use App\Models\License;
|
||||||
|
|
||||||
|
class LicenseFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = License::class;
|
||||||
|
|
||||||
|
public function definition()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => $this->faker->name(),
|
||||||
|
'email' => $this->faker->safeEmail(),
|
||||||
|
'product' => "Tooloop Guide",
|
||||||
|
'version' => $this->faker->semver(),
|
||||||
|
'is_perpetual' => rand(0, 10) > 7,
|
||||||
|
'expiration_date' => $this->faker->dateTimeThisDecade(),
|
||||||
|
'signature' => $this->faker->sha256()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('licenses', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('email');
|
||||||
|
$table->string('product');
|
||||||
|
$table->string('version');
|
||||||
|
$table->boolean('is_perpetual');
|
||||||
|
$table->string('expiration_date')->nullable();;
|
||||||
|
$table->string('signature');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('licenses');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -22,6 +22,7 @@ public function run(): void
|
|||||||
SettingsTableSeeder::class,
|
SettingsTableSeeder::class,
|
||||||
TodoTypeSeeder::class,
|
TodoTypeSeeder::class,
|
||||||
UnitSeeder::class,
|
UnitSeeder::class,
|
||||||
|
LicenseSeeder::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = User::factory()->create([
|
$user = User::factory()->create([
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Seeders;
|
||||||
|
|
||||||
|
use Illuminate\Database\Seeder;
|
||||||
|
use App\Models\License;
|
||||||
|
|
||||||
|
class LicenseSeeder extends Seeder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the database seeds.
|
||||||
|
*/
|
||||||
|
public function run(): void
|
||||||
|
{
|
||||||
|
License::factory()->count(10)->create();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,12 @@ import NavFooter from '@/components/NavFooter.vue';
|
|||||||
import NavMain from '@/components/NavMain.vue';
|
import NavMain from '@/components/NavMain.vue';
|
||||||
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarTrigger } from '@/components/ui/crm-sidebar';
|
import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarTrigger } from '@/components/ui/crm-sidebar';
|
||||||
import { useSidebar } from '@/components/ui/crm-sidebar/utils'
|
import { useSidebar } from '@/components/ui/crm-sidebar/utils'
|
||||||
import { dashboard, pipeline, offers, invoices, newInvoice, products, timesheets, customers, leads, achievements } from '@/routes';
|
import { dashboard, pipeline, licenses, offers, invoices, newInvoice, products, timesheets, customers, leads, achievements } from '@/routes';
|
||||||
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 { type NavGroup } from '@/types';
|
import { type NavGroup } from '@/types';
|
||||||
import { Link, usePage } from '@inertiajs/vue3';
|
import { Link, usePage } from '@inertiajs/vue3';
|
||||||
import { Kanban, Euro, Trophy, Calculator, Timer, ContactRound, Headset, Plus, Package } from 'lucide-vue-next';
|
import { Kanban, Euro, Trophy, Calculator, Timer, ContactRound, Headset, Plus, Package, FileBadge } from 'lucide-vue-next';
|
||||||
import AppLogo from './AppLogo.vue';
|
import AppLogo from './AppLogo.vue';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
@@ -66,6 +66,11 @@ const mainNavGroups: NavGroup[] = [
|
|||||||
href: products(),
|
href: products(),
|
||||||
icon: Package,
|
icon: Package,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Lizenzen',
|
||||||
|
href: licenses(),
|
||||||
|
icon: FileBadge,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Zeiterfassung',
|
title: 'Zeiterfassung',
|
||||||
href: timesheets(),
|
href: timesheets(),
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export const badgeVariants = cva(
|
|||||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||||
destructive:
|
destructive:
|
||||||
"border-transparent bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"border-transparent bg-destructive text-destructive-foreground [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
success:
|
||||||
|
"border-transparent bg-success text-success-foreground [a&]:hover:bg-success/90 focus-visible:ring-success/20 dark:focus-visible:ring-success/40 dark:bg-success/60",
|
||||||
warning:
|
warning:
|
||||||
"border-transparent bg-warning text-warning-foreground [a&]:hover:bg-warning/90 focus-visible:ring-warning/20 dark:focus-visible:ring-warning/40 dark:bg-warning/60",
|
"border-transparent bg-warning text-warning-foreground [a&]:hover:bg-warning/90 focus-visible:ring-warning/20 dark:focus-visible:ring-warning/40 dark:bg-warning/60",
|
||||||
outline:
|
outline:
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { License } from '@/types'
|
||||||
|
import { toLocalDate } from '@/lib/utils'
|
||||||
|
import Fuse from 'fuse.js'
|
||||||
|
import { onMounted, ref, computed } from 'vue'
|
||||||
|
import AppLayout from '@/layouts/AppLayout.vue'
|
||||||
|
import AppHeader from '@/components/AppHeader.vue'
|
||||||
|
import { Head } from '@inertiajs/vue3'
|
||||||
|
import { Table, TableRow, TableBody, TableCell, TableFooter, TableHead, TableHeader } from '@/components/ui/crm-table'
|
||||||
|
import { Input } from '@/components/ui/crm-input'
|
||||||
|
import { Badge } from '@/components/ui/crm-badge'
|
||||||
|
import Button from '@/components/ui/crm-button/Button.vue'
|
||||||
|
import { ButtonGroup } from '@/components/ui/button-group'
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
|
import { Kbd, KbdGroup } from '@/components/ui/kbd'
|
||||||
|
import LicenseService from '@/services/LicenseService';
|
||||||
|
import { Check, Download, Trash2, Plus, Delete, Search } from 'lucide-vue-next'
|
||||||
|
import { alertStore } from "@/stores/alertStore"
|
||||||
|
import { toast } from 'vue-sonner';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
licenseData: License[]
|
||||||
|
}
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const licenses = ref([] as License[])
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const searchField = ref()
|
||||||
|
const alert = alertStore()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
licenses.value = props.licenseData
|
||||||
|
})
|
||||||
|
|
||||||
|
const fuse = computed(() => {
|
||||||
|
return new Fuse(props.licenseData, {
|
||||||
|
keys: ['name', 'email', 'product', 'version'],
|
||||||
|
threshold: 0.3
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredLicenses = computed(() => {
|
||||||
|
|
||||||
|
let licenses = props.licenseData
|
||||||
|
|
||||||
|
// Filter by search query
|
||||||
|
if (searchQuery.value) {
|
||||||
|
licenses = fuse.value.search(searchQuery.value).map(result => result.item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return licenses
|
||||||
|
})
|
||||||
|
|
||||||
|
const downloadLicense = (id: number) => {
|
||||||
|
window.open('/licenses/download/' + id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteLicense = (id: number) => {
|
||||||
|
alert.show(
|
||||||
|
"Möchtest Du diese Lizenz wirklich löschen?",
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
actionText: "Löschen",
|
||||||
|
actionVariant: "destructive",
|
||||||
|
onAction: async () => {
|
||||||
|
LicenseService.deleteLicense(id).then(() => {
|
||||||
|
licenses.value = licenses.value.filter(license => license.id != id)
|
||||||
|
}).catch((error) => {
|
||||||
|
toast.error('Fehler beim Löschen der Lizenz', { duration: 5000, description: error.message })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
|
||||||
|
<Head title="Lizenzen" />
|
||||||
|
|
||||||
|
<AppLayout title="Lizenzen">
|
||||||
|
|
||||||
|
<!-- Header functions -->
|
||||||
|
<AppHeader>
|
||||||
|
<template #left>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #middle>
|
||||||
|
<!-- Search field -->
|
||||||
|
<Input ref="search-field" id="search" type="text" placeholder="Filtern" class="px-8 bg-background"
|
||||||
|
v-model="searchQuery" />
|
||||||
|
<span class="absolute start-0 inset-y-0 flex items-center justify-center px-2">
|
||||||
|
<Search class="size-4 text-muted-foreground" />
|
||||||
|
</span>
|
||||||
|
<span class="absolute end-0 inset-y-0 flex items-center justify-center px-0 mr-1">
|
||||||
|
<Button :size="'sm'" :variant="'ghost'" @click="searchQuery = ''; searchField.focus();">
|
||||||
|
<Delete class="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #right>
|
||||||
|
<!-- New button -->
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger>
|
||||||
|
<Button @click="">
|
||||||
|
<Plus />
|
||||||
|
Neu
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<span>Neue Lizenz anlegen</span>
|
||||||
|
<KbdGroup class="ml-2">
|
||||||
|
<Kbd class="visible-mac">⌘ N</Kbd>
|
||||||
|
<Kbd class="visible-pc">Ctrl N</Kbd>
|
||||||
|
</KbdGroup>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</template>
|
||||||
|
</AppHeader>
|
||||||
|
|
||||||
|
<!-- License table -->
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead class="w-1/100 text-center">Gültig bis</TableHead>
|
||||||
|
<TableHead>Produkt</TableHead>
|
||||||
|
<TableHead>Version</TableHead>
|
||||||
|
<TableHead colspan="2">Lizenznehmer</TableHead>
|
||||||
|
<TableHead class="w-1/100 print:hidden"></TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
|
||||||
|
<TableRow v-for="license in filteredLicenses" :key="license.id">
|
||||||
|
<TableCell class="text-center">
|
||||||
|
<!-- <Check class="text-success inline-block" /> -->
|
||||||
|
<Badge v-if="license.isPerpetual" variant="secondary">
|
||||||
|
Permanent
|
||||||
|
</Badge>
|
||||||
|
<Badge v-else-if="Math.random() > 0.5" variant="success">
|
||||||
|
{{ toLocalDate(license.expirationDate || '') }}
|
||||||
|
</Badge>
|
||||||
|
<Badge v-else variant="destructive">
|
||||||
|
{{ toLocalDate(license.expirationDate || '') }}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{{ license.product }}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{{ license.version }}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell>
|
||||||
|
{{ license.name }}<br />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<a :href="'mailto:' + license.email + '?subject=Ihre ' + license.product + '-Lizenz'"
|
||||||
|
class="text-muted-foreground text-sm">{{ license.email
|
||||||
|
}}</a>
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell class="print:hidden">
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button size="icon" variant="ghost" @click="downloadLicense(license.id)">
|
||||||
|
<Download class="text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
<Button size="icon" variant="ghost" @click="deleteLicense(license.id)">
|
||||||
|
<Trash2 class="text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</AppLayout>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import axios, { AxiosError } from 'axios';
|
||||||
|
import { License } from '@/types';
|
||||||
|
import { toast } from 'vue-sonner';
|
||||||
|
|
||||||
|
const API_URL = '/api/licenses';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
/**
|
||||||
|
* Fetches all licenses
|
||||||
|
* @returns Promise<License[] | null>
|
||||||
|
*/
|
||||||
|
async getLicenses(): Promise<License[] | null> {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(API_URL);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Fehler beim Abrufen der Lizenzen', { description: (error as AxiosError).message });
|
||||||
|
console.error(error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a single license by ID
|
||||||
|
* @param id - The ID of the license to fetch
|
||||||
|
* @returns Promise<License | null>
|
||||||
|
*/
|
||||||
|
async getLicense(id: number): Promise<License | null> {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${API_URL}/${id}`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Fehler beim Abrufen der Lizenz', { description: (error as AxiosError).message });
|
||||||
|
console.error(error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new license
|
||||||
|
* @param license - The license data to create
|
||||||
|
* @returns Promise<License | null>
|
||||||
|
*/
|
||||||
|
async createLicense(license: License): Promise<License | null> {
|
||||||
|
try {
|
||||||
|
const response = await axios.post(API_URL, license);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Fehler beim Erstellen der Lizenz', { description: (error as AxiosError).message });
|
||||||
|
console.error(error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing license
|
||||||
|
* @param license - The license data to update
|
||||||
|
* @returns Promise<License | null>
|
||||||
|
*/
|
||||||
|
async updateLicense(license: License): Promise<License | null> {
|
||||||
|
try {
|
||||||
|
const response = await axios.put(`${API_URL}/${license.id}`, license);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Fehler beim Aktualisieren der Lizenz', { description: (error as AxiosError).message });
|
||||||
|
console.error(error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a license by ID
|
||||||
|
* @param id - The ID of the license to delete
|
||||||
|
* @returns Promise<boolean>
|
||||||
|
*/
|
||||||
|
async deleteLicense(id: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await axios.delete(`${API_URL}/${id}`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Fehler beim Löschen der Lizenz', { description: (error as AxiosError).message });
|
||||||
|
console.error(error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads a license as a JSON file
|
||||||
|
* @param id - The ID of the license to download
|
||||||
|
* @returns Promise<void>
|
||||||
|
*/
|
||||||
|
async downloadLicense(id: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/licenses/download/${id}`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.setAttribute('download', `license_${id}.json`);
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Fehler beim Herunterladen der Lizenz', { description: (error as AxiosError).message });
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a license
|
||||||
|
* @param licenseId - The ID of the license to validate
|
||||||
|
* @returns Promise<{ is_valid: boolean, is_signature_valid: boolean, is_expired: boolean } | null>
|
||||||
|
*/
|
||||||
|
async validateLicense(licenseId: number): Promise<{ is_valid: boolean, is_signature_valid: boolean, is_expired: boolean } | null> {
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/licenses/validate', { license_id: licenseId });
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Fehler beim Validieren der Lizenz', { description: (error as AxiosError).message });
|
||||||
|
console.error(error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
Vendored
+28
-1
@@ -289,6 +289,34 @@ export function newBillingData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface License {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
product: string;
|
||||||
|
version: string;
|
||||||
|
isPerpetual: boolean;
|
||||||
|
expirationDate: string | null;
|
||||||
|
signature: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newLicense(): License {
|
||||||
|
return {
|
||||||
|
id: 0,
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
product: '',
|
||||||
|
version: '',
|
||||||
|
isPerpetual: false,
|
||||||
|
expirationDate: null,
|
||||||
|
signature: '',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface LineItem {
|
export interface LineItem {
|
||||||
id: number,
|
id: number,
|
||||||
invoiceId: number,
|
invoiceId: number,
|
||||||
@@ -411,7 +439,6 @@ export function newProduct(): Product {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Pipeline item used by CRM pipeline
|
// Pipeline item used by CRM pipeline
|
||||||
export interface PipelineItem {
|
export interface PipelineItem {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
+12
-5
@@ -2,19 +2,20 @@
|
|||||||
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use App\Http\Controllers\CustomerController;
|
use App\Http\Controllers\CustomerController;
|
||||||
use App\Http\Controllers\NoteController;
|
|
||||||
use App\Http\Controllers\InvoiceController;
|
use App\Http\Controllers\InvoiceController;
|
||||||
|
use App\Http\Controllers\LicenseController;
|
||||||
use App\Http\Controllers\LineItemController;
|
use App\Http\Controllers\LineItemController;
|
||||||
|
use App\Http\Controllers\NoteController;
|
||||||
use App\Http\Controllers\PaymentTermsController;
|
use App\Http\Controllers\PaymentTermsController;
|
||||||
|
use App\Http\Controllers\PipelineController;
|
||||||
|
use App\Http\Controllers\PipelineItemController;
|
||||||
use App\Http\Controllers\ProductController;
|
use App\Http\Controllers\ProductController;
|
||||||
use App\Http\Controllers\SettingController;
|
use App\Http\Controllers\SettingController;
|
||||||
|
use App\Http\Controllers\TimesheetController;
|
||||||
|
use App\Http\Controllers\TimesheetEntryController;
|
||||||
use App\Http\Controllers\TodoController;
|
use App\Http\Controllers\TodoController;
|
||||||
use App\Http\Controllers\UnitController;
|
use App\Http\Controllers\UnitController;
|
||||||
use App\Mail\OrderConfirmation;
|
use App\Mail\OrderConfirmation;
|
||||||
use App\Http\Controllers\TimesheetController;
|
|
||||||
use App\Http\Controllers\TimesheetEntryController;
|
|
||||||
use App\Http\Controllers\PipelineController;
|
|
||||||
use App\Http\Controllers\PipelineItemController;
|
|
||||||
|
|
||||||
Route::get('/pipeline', [PipelineController::class, 'index']);
|
Route::get('/pipeline', [PipelineController::class, 'index']);
|
||||||
Route::post('/pipeline/positions', [PipelineController::class, 'updatePositions']);
|
Route::post('/pipeline/positions', [PipelineController::class, 'updatePositions']);
|
||||||
@@ -55,6 +56,12 @@
|
|||||||
Route::delete('/invoices/{id}', [InvoiceController::class, 'delete']);
|
Route::delete('/invoices/{id}', [InvoiceController::class, 'delete']);
|
||||||
Route::get('/invoices/{id}/remind', [InvoiceController::class, 'remind']);
|
Route::get('/invoices/{id}/remind', [InvoiceController::class, 'remind']);
|
||||||
|
|
||||||
|
Route::get('/licenses', [\App\Http\Controllers\LicenseController::class, 'index']);
|
||||||
|
Route::get('/licenses/{id}', [\App\Http\Controllers\LicenseController::class, 'single']);
|
||||||
|
Route::delete('/licenses/{id}', [\App\Http\Controllers\LicenseController::class, 'destroy']);
|
||||||
|
Route::post('/licenses', [\App\Http\Controllers\LicenseController::class, 'store']);
|
||||||
|
Route::put('/licenses/{id}', [\App\Http\Controllers\LicenseController::class, 'update']);
|
||||||
|
|
||||||
Route::get('/lineitems/{invoiceId}', [LineItemController::class, 'index']);
|
Route::get('/lineitems/{invoiceId}', [LineItemController::class, 'index']);
|
||||||
Route::post('/lineitems/import', [LineItemController::class, 'importFromCsv']);
|
Route::post('/lineitems/import', [LineItemController::class, 'importFromCsv']);
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
use App\Http\Controllers\ProductController;
|
use App\Http\Controllers\ProductController;
|
||||||
use App\Http\Controllers\TimesheetController;
|
use App\Http\Controllers\TimesheetController;
|
||||||
use App\Http\Controllers\PipelineController;
|
use App\Http\Controllers\PipelineController;
|
||||||
|
use App\Http\Controllers\LicenseController;
|
||||||
|
|
||||||
Route::middleware('auth')->group(function () {
|
Route::middleware('auth')->group(function () {
|
||||||
|
|
||||||
@@ -49,6 +50,10 @@
|
|||||||
Route::get('invoice/{id}/pdf', [InvoiceController::class, 'exportPdf'])->name('invoiceExportPdf');
|
Route::get('invoice/{id}/pdf', [InvoiceController::class, 'exportPdf'])->name('invoiceExportPdf');
|
||||||
Route::get('invoice/{id}/xml', [InvoiceController::class, 'exportXml'])->name('invoiceExportXml');
|
Route::get('invoice/{id}/xml', [InvoiceController::class, 'exportXml'])->name('invoiceExportXml');
|
||||||
|
|
||||||
|
// Licenses
|
||||||
|
Route::get('licenses', [LicenseController::class, 'show'])->name('licenses');
|
||||||
|
Route::get('licenses/download/{id}', [LicenseController::class, 'download'])->name('downloadLicense');
|
||||||
|
|
||||||
// Products
|
// Products
|
||||||
Route::get('products', [ProductController::class, 'show'])->name('products');
|
Route::get('products', [ProductController::class, 'show'])->name('products');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user