some work on the license manager

This commit is contained in:
2026-07-10 16:55:48 +02:00
parent af80c7d582
commit 4a15f1e351
9 changed files with 736 additions and 624 deletions
+41 -30
View File
@@ -109,7 +109,7 @@ protected function getApplicationKeyPair(): array
* @param string $privateKey The private key to use for signing * @param string $privateKey The private key to use for signing
* @return string * @return string
*/ */
protected function generateSignature($licenseData, $privateKey) protected function sign($licenseData, $privateKey)
{ {
$licenseJson = json_encode($licenseData); $licenseJson = json_encode($licenseData);
if ($licenseJson === false) throw new \Exception('Fehler beim JSON-Encode der Lizenzdaten: ' . json_last_error_msg()); if ($licenseJson === false) throw new \Exception('Fehler beim JSON-Encode der Lizenzdaten: ' . json_last_error_msg());
@@ -155,7 +155,7 @@ public function store(Request $request)
'expiration_date' => $snakeCaseData['expiration_date'], 'expiration_date' => $snakeCaseData['expiration_date'],
]; ];
$signature = $this->generateSignature($licenseData, $keyPair['privateKey']); $signature = $this->sign($licenseData, $keyPair['privateKey']);
$license = License::create([ $license = License::create([
'name' => $snakeCaseData['name'], 'name' => $snakeCaseData['name'],
@@ -170,51 +170,62 @@ public function store(Request $request)
return ApiDataTransformer::snakeToCamel($license->toArray()); return ApiDataTransformer::snakeToCamel($license->toArray());
} }
/**
* Validate the license signature and expiration date.
*
* @param \Illuminate\Http\Request $request The HTTP request containing the license data
* @return array
*/
public function validate(Request $request)
{
$validatedData = $request->validate([
'name' => 'required|string',
'email' => 'required|email',
'product' => 'required|string',
'version' => 'required|string',
'isPerpetual' => 'required|boolean',
'expirationDate' => 'nullable|date',
'signature' => 'required|string',
]);
return $this->isValid(ApiDataTransformer::camelToSnake($validatedData));
}
/** /**
* Validate the license signature and expiration date. * Validate the license signature and expiration date.
* *
* The validation logic should be portable to other languages. * The validation logic should be portable to other languages.
* *
* @param \Illuminate\Http\Request $request The HTTP request containing the license ID * @param array $licenseData The license data to validate
* @return array * @return array
*/ */
public function validate(Request $request) protected function isValid(array $licenseData)
{ {
// 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()); $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 // Convert the license data to JSON
$licenseJson = json_encode($licenseData); $licenseJson = json_encode($licenseData);
// Verify the signature // Verify the signature
$signature = base64_decode($license->signature); $signature = base64_decode($licenseData['signature']);
$isSignatureValid = openssl_verify($licenseJson, $signature, $publicKey, OPENSSL_ALGO_SHA256); $isSignatureValid = openssl_verify($licenseJson, $signature, $publicKey, OPENSSL_ALGO_SHA256);
// Check the expiration date if the license is not perpetual // Check the expiration date if the license is not perpetual
$isExpired = false; $isExpired = false;
if (!$license->is_perpetual && $license->expiration_date) { if (!$licenseData['is_perpetual'] && $licenseData['expiration_date']) {
$isExpired = now()->gt($license->expiration_date); $isExpired = now()->gt($licenseData['expiration_date']);
} }
// Return the validation result // Return the validation info
return [ return [
'is_valid' => $isSignatureValid === 1 && !$isExpired, 'isValid' => $isSignatureValid === 1 && !$isExpired,
'is_signature_valid' => $isSignatureValid === 1, 'isSignatureValid' => $isSignatureValid === 1,
'is_expired' => $isExpired, 'isExpired' => $isExpired,
]; ];
} }
@@ -246,7 +257,7 @@ public function download(int $id)
// Create a response with the JSON file // Create a response with the JSON file
$response = response($licenseJson, 200, [ $response = response($licenseJson, 200, [
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',
'Content-Disposition' => 'attachment; filename="' . $licenseData['product'] . '.json"', 'Content-Disposition' => 'attachment; filename="' . $licenseData['product'] . '.license"',
]); ]);
return $response; return $response;
@@ -296,7 +307,7 @@ public function update(Request $request, int $id)
]; ];
// Generate the signature // Generate the signature
$signature = $this->generateSignature($licenseData, $privateKey); $signature = $this->sign($licenseData, $privateKey);
// Update the license // Update the license
$license->update([ $license->update([
+543 -523
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -51,7 +51,7 @@
"vaul-vue": "^0.4.1", "vaul-vue": "^0.4.1",
"vue": "^3.5.35", "vue": "^3.5.35",
"vue-sonner": "^2.0.9", "vue-sonner": "^2.0.9",
"vuedraggable": "^2.24.3" "vuedraggable": "^4.1.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4",
@@ -98,6 +98,7 @@ onMounted(async () => {
hotkey('mod+i', importLineItems, null, () => isOpen.value) hotkey('mod+i', importLineItems, null, () => isOpen.value)
hotkey('mod+e', exportPdf, null, () => isOpen.value) hotkey('mod+e', exportPdf, null, () => isOpen.value)
hotkey('mod+p', preview, null, () => isOpen.value) hotkey('mod+p', preview, null, () => isOpen.value)
hotkey('mod+s', save)
}) })
// Initial data from parent view // Initial data from parent view
@@ -560,7 +561,7 @@ const handleFileUpload = async (event: Event) => {
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<TooltipProvider> <TooltipProvider>
<!-- Save --> <!-- Save -->
<Button v-if="invoice && isDirty" class="grow md:grow-0" size="sm" @click="save" <Button class="grow md:grow-0" size="sm" @click="save"
:disabled="isSaving"> :disabled="isSaving">
<Loader2 v-if="isSaving" class="animate-spin" /> <Loader2 v-if="isSaving" class="animate-spin" />
<Check v-else /> <Check v-else />
+53 -6
View File
@@ -8,7 +8,7 @@ import { toast } from 'vue-sonner'
// Attributes, classes // Attributes, classes
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// #region Clöasses // #region Classes
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)); return twMerge(clsx(inputs));
@@ -202,6 +202,18 @@ export function randomDate(): Date {
return date return date
} }
export function addDays(date: Date | string, days: number) {
const newDate = new Date(date);
newDate.setDate(newDate.getDate() + days);
return newDate;
}
export function addYears(date: Date | string, years: number) {
const newDate = new Date(date);
newDate.setFullYear(newDate.getFullYear() + years);
return newDate;
}
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -228,11 +240,9 @@ Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming
// Keyboard shortcuts // Keyboard shortcuts
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Füge diese Typdefinitionen am Anfang der Datei hinzu
type HotkeyCallback = (params?: any) => void; type HotkeyCallback = (params?: any) => void;
type HotkeyEnabledCallback = () => boolean; type HotkeyEnabledCallback = () => boolean;
// Füge diese Variablen am Anfang der Datei hinzu
const registeredHotkeys = new Map<string, { const registeredHotkeys = new Map<string, {
callback: HotkeyCallback; callback: HotkeyCallback;
params: any; params: any;
@@ -296,6 +306,15 @@ export function hotkey(
.map(key => normalizeKeyName(key)) .map(key => normalizeKeyName(key))
.join('+') .join('+')
// Warnung, wenn der registrierte Shortcut bekanntlich nicht zuverlässig
// vom Browser/OS unterdrückt werden kann.
if (UNPREVENTABLE_SHORTCUTS.has(normalizedCombination)) {
console.warn(
`Registriere Shortcut "${keyCombination}" (normalized: "${normalizedCombination}"). ` +
'Achtung: Das Standardverhalten dieses Shortcuts kann vom Browser/OS nicht zuverlässig verhindert werden.'
)
}
// Speichern der Hotkey-Informationen // Speichern der Hotkey-Informationen
registeredHotkeys.set(normalizedCombination, { registeredHotkeys.set(normalizedCombination, {
callback, callback,
@@ -337,7 +356,11 @@ function normalizeKeyName(key: string): string {
'arrowup': 'uparrow', 'arrowup': 'uparrow',
'arrowdown': 'downarrow', 'arrowdown': 'downarrow',
'return': 'enter', 'return': 'enter',
'esc': 'escape' 'esc': 'escape',
'opt': 'alt',
'option': 'alt',
'spacebar': 'space',
'space': 'space'
}; };
// Konvertiere den Tastennamen in Kleinbuchstaben // Konvertiere den Tastennamen in Kleinbuchstaben
@@ -347,6 +370,29 @@ function normalizeKeyName(key: string): string {
return keyMap[lowerKey] || lowerKey return keyMap[lowerKey] || lowerKey
} }
// Liste bekannter Shortcuts, deren Standardverhalten unter vielen Browsern/OS
// nicht zuverlässig durch `preventDefault()` unterdrückt werden kann.
// Diese Liste ist nicht vollständig — sie dient nur zur Warnung beim Registrieren.
const UNPREVENTABLE_SHORTCUTS = new Set([
'cmd+tab',
'ctrl+tab',
'cmd+n',
'ctrl+n',
'cmd+space',
'ctrl+space',
'alt+tab',
'ctrl+alt+delete',
'f12',
'cmd+q',
'ctrl+q',
'cmd+w',
'ctrl+w',
'ctrl+shift+i',
'cmd+opt+i',
'ctrl+shift+j',
'cmd+opt+j'
]);
function handleKeyboardEvent(event: KeyboardEvent) { function handleKeyboardEvent(event: KeyboardEvent) {
// Überprüfen, ob das Event-Element ein Eingabefeld, Textarea oder ähnliches ist // Überprüfen, ob das Event-Element ein Eingabefeld, Textarea oder ähnliches ist
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
@@ -354,8 +400,9 @@ function handleKeyboardEvent(event: KeyboardEvent) {
target.tagName === 'TEXTAREA' || target.tagName === 'TEXTAREA' ||
target.tagName === 'SELECT' || target.tagName === 'SELECT' ||
target.isContentEditable) { target.isContentEditable) {
// Wenn das Event-Element ein Eingabefeld ist, den Shortcut nicht auslösen // Wenn das Event-Element ein Eingabefeld ist, den Shortcut nicht auslösen
return; return
} }
// Erstellen einer normalisierten Tastaturkombination // Erstellen einer normalisierten Tastaturkombination
@@ -390,7 +437,7 @@ function handleKeyboardEvent(event: KeyboardEvent) {
// Verhindern der Standardaktion, falls gewünscht // Verhindern der Standardaktion, falls gewünscht
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
return 0 return false
} }
} }
} }
+84 -55
View File
@@ -1,20 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { License } from '@/types' import { License } from '@/types'
import { toLocalDate } from '@/lib/utils' import { newLicense } from '@/types/index.d'
import { toLocalDate, toShortISOString, hotkey } 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'
import AppHeader from '@/components/AppHeader.vue' import AppHeader from '@/components/AppHeader.vue'
import { Head } from '@inertiajs/vue3' import { Head } from '@inertiajs/vue3'
import { Table, TableRow, TableBody, TableCell, TableFooter, TableHead, TableHeader } from '@/components/ui/crm-table' import { Table, TableRow, TableBody, TableCell, TableHead, TableHeader } from '@/components/ui/crm-table'
import { Input } from '@/components/ui/crm-input' import { Input } from '@/components/ui/crm-input'
import { Badge } from '@/components/ui/crm-badge'
import Button from '@/components/ui/crm-button/Button.vue' 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 { Check, Download, Trash2, Plus, Delete, Search } from 'lucide-vue-next' import { Download, Trash2, Plus, Delete, Search, CheckCircle, CheckCircle2, XCircle, CircleQuestionMark, BadgeQuestionMark, Eye } from 'lucide-vue-next'
import { alertStore } from "@/stores/alertStore" import { alertStore } from "@/stores/alertStore"
import { toast } from 'vue-sonner'; import { toast } from 'vue-sonner';
@@ -29,6 +29,8 @@ const alert = alertStore()
onMounted(() => { onMounted(() => {
licenses.value = props.licenseData licenses.value = props.licenseData
searchField.value = document.getElementById('search')
hotkey('n', createLicense)
}) })
const fuse = computed(() => { const fuse = computed(() => {
@@ -39,22 +41,36 @@ const fuse = computed(() => {
}) })
const filteredLicenses = computed(() => { const filteredLicenses = computed(() => {
let filteredLicenses = licenses.value
let licenses = props.licenseData
// Filter by search query // Filter by search query
if (searchQuery.value) { if (searchQuery.value) {
licenses = fuse.value.search(searchQuery.value).map(result => result.item) filteredLicenses = fuse.value.search(searchQuery.value).map(result => result.item)
} }
return filteredLicenses
return licenses
}) })
const downloadLicense = (id: number) => { const createLicense = () => {
window.open('/licenses/download/' + id) LicenseService.createLicense(newLicense()).then(license => {
if (license) licenses.value.unshift(license)
})
} }
const deleteLicense = (id: number) => { const updateLicense = (license: License) => {
LicenseService.updateLicense(license).then((response) => {
if (response) license = response
})
}
const downloadLicense = (license: License) => {
window.open('/licenses/download/' + license.id)
}
const deleteLicense = (license: License) => {
if (license.id == 0) {
licenses.value = licenses.value.filter(l => l != license)
return
}
alert.show( alert.show(
"Möchtest Du diese Lizenz wirklich löschen?", "Möchtest Du diese Lizenz wirklich löschen?",
null, null,
@@ -62,8 +78,8 @@ const deleteLicense = (id: number) => {
actionText: "Löschen", actionText: "Löschen",
actionVariant: "destructive", actionVariant: "destructive",
onAction: async () => { onAction: async () => {
LicenseService.deleteLicense(id).then(() => { LicenseService.deleteLicense(license.id).then(() => {
licenses.value = licenses.value.filter(license => license.id != id) licenses.value = licenses.value.filter(license => license.id != license.id)
}).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 })
}) })
@@ -72,6 +88,10 @@ const deleteLicense = (id: number) => {
) )
} }
const remToPx = (rem: number) => {
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
}
</script> </script>
<template> <template>
@@ -99,24 +119,25 @@ const deleteLicense = (id: number) => {
</span> </span>
</template> </template>
<template #right> <template #right>
<!-- New button --> <div class="flex gap-2 items-center">
<TooltipProvider> <TooltipProvider>
<Tooltip> <!-- New button -->
<TooltipTrigger> <Tooltip>
<Button @click=""> <TooltipTrigger>
<Plus /> <Button @click="createLicense">
Neu <Plus />
</Button> Neu
</TooltipTrigger> </Button>
<TooltipContent> </TooltipTrigger>
<span>Neue Lizenz anlegen</span> <TooltipContent>
<KbdGroup class="ml-2"> <span>Neue Lizenz anlegen</span>
<Kbd class="visible-mac"> N</Kbd> <KbdGroup class="ml-2">
<Kbd class="visible-pc">Ctrl N</Kbd> <Kbd>N</Kbd>
</KbdGroup> </KbdGroup>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
</div>
</template> </template>
</AppHeader> </AppHeader>
@@ -124,10 +145,12 @@ const deleteLicense = (id: number) => {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead class="w-1/100 text-center">Gültig bis</TableHead> <TableHead class="w-1/100 text-center">Gültig</TableHead>
<TableHead>Produkt</TableHead> <TableHead class="w-1/100">Perm.</TableHead>
<TableHead>Version</TableHead> <TableHead class="pl-7">Ablaufdatum</TableHead>
<TableHead colspan="2">Lizenznehmer</TableHead> <TableHead class="pl-7">Produkt</TableHead>
<TableHead class="pl-7">Version</TableHead>
<TableHead class="pl-7" colspan="2">Lizenznehmer</TableHead>
<TableHead class="w-1/100 print:hidden"></TableHead> <TableHead class="w-1/100 print:hidden"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -136,39 +159,45 @@ const deleteLicense = (id: 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">
<!-- <Check class="text-success inline-block" /> --> <CircleQuestionMark class="inline-block text-muted-foreground" :size="remToPx(1)"
<Badge v-if="license.isPerpetual" variant="secondary"> v-if="license.validationInfo === undefined" />
Permanent <CheckCircle2 class="inline-block text-success0" :size="remToPx(1)"
</Badge> v-else-if="license.validationInfo.isExpired === false" />
<Badge v-else-if="Math.random() > 0.5" variant="success"> <XCircle class="inline-block text-destructive0" :size="remToPx(1)" v-else />
{{ toLocalDate(license.expirationDate || '') }} </TableCell>
</Badge> <TableCell class="text-center">
<Badge v-else variant="destructive"> <input type="checkbox" v-model="license.isPerpetual"
{{ toLocalDate(license.expirationDate || '') }} v-on:change="updateLicense(license)" />
</Badge> </TableCell>
<TableCell class="whitespace-nowrap">
<div class="text-center" v-if="license.isPerpetual"></div>
<Input v-else type="date" :modelValue="toShortISOString(license.expirationDate || new Date())"
v-on:blur="updateLicense(license)" placeholder="Datum" />
</TableCell> </TableCell>
<TableCell> <TableCell>
{{ license.product }} <Input type="text" :modelValue="license.product" placeholder="Produkt Name"
v-on:blur="updateLicense(license)" />
</TableCell> </TableCell>
<TableCell> <TableCell>
{{ license.version }} <Input type="text" :modelValue="license.version" placeholder="1.0.0"
v-on:blur="updateLicense(license)" />
</TableCell> </TableCell>
<TableCell> <TableCell>
{{ license.name }}<br /> <Input type="text" v-model="license.name" placeholder="Firma"
v-on:blur="updateLicense(license)" />
</TableCell> </TableCell>
<TableCell> <TableCell>
<a :href="'mailto:' + license.email + '?subject=Ihre ' + license.product + '-Lizenz'" <Input type="email" :modelValue="license.email" placeholder="E-Mail"
class="text-muted-foreground text-sm">{{ license.email v-on:blur="updateLicense(license)" />
}}</a>
</TableCell> </TableCell>
<TableCell class="print:hidden"> <TableCell class="print:hidden">
<ButtonGroup> <ButtonGroup>
<Button size="icon" variant="ghost" @click="downloadLicense(license.id)"> <Button class="size-7" variant="ghost" @click="downloadLicense(license)">
<Download class="text-muted-foreground" /> <Download class="text-muted-foreground" />
</Button> </Button>
<Button size="icon" variant="ghost" @click="deleteLicense(license.id)"> <Button class="size-7" variant="ghost" @click="deleteLicense(license)">
<Trash2 class="text-muted-foreground" /> <Trash2 class="text-muted-foreground" />
</Button> </Button>
</ButtonGroup> </ButtonGroup>
+2 -2
View File
@@ -113,9 +113,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(licenseId: number): Promise<{ is_valid: boolean, is_signature_valid: boolean, is_expired: boolean } | null> { async validateLicense(license: License): Promise<{ isValid: boolean, isSignatureValid: boolean, isExpired: boolean } | null> {
try { try {
const response = await axios.post('/api/licenses/validate', { license_id: licenseId }); 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 });
+9 -6
View File
@@ -1,6 +1,6 @@
import { InertiaLinkProps } from '@inertiajs/vue3'; import { InertiaLinkProps } from '@inertiajs/vue3';
import type { LucideIcon } from 'lucide-vue-next'; import type { LucideIcon } from 'lucide-vue-next';
import { calcDueDate } from '@/lib/utils'; import { calcDueDate, addYears } from '@/lib/utils';
export interface Auth { export interface Auth {
user: User; user: User;
@@ -313,8 +313,13 @@ export interface License {
isPerpetual: boolean; isPerpetual: boolean;
expirationDate: string | null; expirationDate: string | null;
signature: string; signature: string;
createdAt: string; validationInfo?: LicenseValidationInfo;
updatedAt: string; }
export interface LicenseValidationInfo {
isValid: boolean;
isSignatureValid: boolean;
isExpired: boolean;
} }
export function newLicense(): License { export function newLicense(): License {
@@ -325,10 +330,8 @@ export function newLicense(): License {
product: '', product: '',
version: '', version: '',
isPerpetual: false, isPerpetual: false,
expirationDate: null, expirationDate: addYears(new Date(), 2),
signature: '', signature: '',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}; };
} }
+1
View File
@@ -61,6 +61,7 @@
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/{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::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']);