fast fertig
This commit is contained in:
+5
-1
@@ -30,8 +30,12 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# runtime data
|
||||||
|
/data/
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
|
|||||||
@@ -1,36 +1,65 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
# Familien-Motorrad-Tracker
|
||||||
|
|
||||||
## Getting Started
|
Gemeinsame Verwaltung von Motorrädern, Wartungen, TÜV-/Inspektionsterminen und Fahrten für eine Familie. Die App nutzt Next.js, TypeScript und SQLite und ist für den Betrieb per Docker hinter einem Reverse Proxy vorbereitet.
|
||||||
|
|
||||||
First, run the development server:
|
## Lokale Entwicklung
|
||||||
|
|
||||||
```bash
|
Voraussetzungen: Node.js 24 und npm.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Copy-Item .env.example .env.local
|
||||||
|
npm ci
|
||||||
npm run dev
|
npm run dev
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
Vor dem Start in `.env.local` mindestens `ADMIN_PASSWORD`, `ADMIN_NAME` und `AUTH_SECRET` setzen. Die App ist anschließend unter `http://localhost:3000` erreichbar. Datenbank und Uploads liegen standardmäßig in `data\`.
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
Beim ersten Start wird genau dann ein Admin aus den `ADMIN_*`-Werten angelegt, wenn noch kein aktives Konto existiert. Danach meldet man sich mit `ADMIN_NAME` und `ADMIN_PASSWORD` an. Das initiale Passwort sollte direkt unter **Konto** geändert werden.
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
## Standard-Admin-Zugang (aus `.env.example`)
|
||||||
|
|
||||||
## Learn More
|
- Passwort: `change-this-password`
|
||||||
|
- Name: `Familien-Admin`
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
## Docker
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
```powershell
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
Copy-Item .env.example .env
|
||||||
|
# Sichere Werte in .env eintragen
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
Die Anwendung ist standardmäßig über Port 3000 erreichbar. `PORT` in `.env` ändert nur den veröffentlichten Host-Port. Für eine Domain sollte nginx, Traefik oder ein anderer Reverse Proxy TLS terminieren und auf diesen Port weiterleiten.
|
||||||
|
|
||||||
## Deploy on Vercel
|
Sichere Schlüssel lassen sich beispielsweise so erzeugen:
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
```powershell
|
||||||
|
node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
|
||||||
|
node -e "console.log(require('node:crypto').randomBytes(32).toString('base64'))"
|
||||||
|
```
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
Den Hex-Wert für `AUTH_SECRET` und den Base64-Wert für `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` verwenden.
|
||||||
|
|
||||||
|
## Benutzer und Rollen
|
||||||
|
|
||||||
|
Es gibt Administratoren und Mitglieder. Jedes Motorrad gehört genau einem Benutzer (Besitzer). Administratoren sehen alle Motorräder, Mitglieder sehen ihre eigenen plus explizit freigegebene Motorräder. Besitzer und Administratoren können Motorräder mit anderen aktiven Benutzern per Benutzername teilen. Konten werden durch Administratoren verwaltet. Motorräder dürfen vom Besitzer oder von Administratoren gelöscht werden. Gelöschte Benutzer können sich nicht mehr anmelden und sind nicht mehr als Fahrer auswählbar; vorhandene Wartungen und Fahrten bleiben mit dem ursprünglichen Namen zur Nachvollziehbarkeit erhalten. Der letzte aktive Administrator kann weder gelöscht noch herabgestuft werden.
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
Alle persistenten Daten liegen im Docker-Volume `moped-data` unter `/app/data`:
|
||||||
|
|
||||||
|
- `moped-tracker.sqlite` sowie mögliche `-wal`-/`-shm`-Dateien
|
||||||
|
- `uploads\` mit Rechnungen und Fotos
|
||||||
|
|
||||||
|
Für ein konsistentes Backup den Container kurz stoppen und das komplette Volume sichern. Bei lokaler Entwicklung entsprechend den gesamten Ordner `data\` sichern.
|
||||||
|
|
||||||
|
## Qualität
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sinnvolle nächste Ausbaustufen
|
||||||
|
|
||||||
|
E-Mail-Erinnerungen vor Fälligkeiten, Mehrsprachigkeit und PDF-Exporte der Wartungshistorie.
|
||||||
|
|||||||
@@ -1,282 +0,0 @@
|
|||||||
#
|
|
||||||
# There is insufficient memory for the Java Runtime Environment to continue.
|
|
||||||
# Native memory allocation (mmap) failed to map 266338304 bytes. Error detail: G1 virtual space
|
|
||||||
# Possible reasons:
|
|
||||||
# The system is out of physical RAM or swap space
|
|
||||||
# This process is running with CompressedOops enabled, and the Java Heap may be blocking the growth of the native heap
|
|
||||||
# Possible solutions:
|
|
||||||
# Reduce memory load on the system
|
|
||||||
# Increase physical memory or swap space
|
|
||||||
# Check if swap backing store is full
|
|
||||||
# Decrease Java heap size (-Xmx/-Xms)
|
|
||||||
# Decrease number of Java threads
|
|
||||||
# Decrease Java thread stack sizes (-Xss)
|
|
||||||
# Set larger code cache with -XX:ReservedCodeCacheSize=
|
|
||||||
# JVM is running with Zero Based Compressed Oops mode in which the Java heap is
|
|
||||||
# placed in the first 32GB address space. The Java Heap base address is the
|
|
||||||
# maximum limit for the native heap growth. Please use -XX:HeapBaseMinAddress
|
|
||||||
# to set the Java Heap base and to place the Java Heap above 32GB virtual address.
|
|
||||||
# This output file may be truncated or incomplete.
|
|
||||||
#
|
|
||||||
# Out of Memory Error (os_windows.cpp:3766), pid=444, tid=39064
|
|
||||||
#
|
|
||||||
# JRE version: (25.0.3+9) (build )
|
|
||||||
# Java VM: OpenJDK 64-Bit Server VM (25.0.3+9-b508.16, mixed mode, sharing, tiered, compressed oops, compressed class ptrs, g1 gc, windows-amd64)
|
|
||||||
# No core dump will be written. Minidumps are not enabled by default on client versions of Windows
|
|
||||||
#
|
|
||||||
|
|
||||||
--------------- S U M M A R Y ------------
|
|
||||||
|
|
||||||
Command Line: git4idea.http.GitAskPassApp Username for 'https://git.konfi-castle.com':
|
|
||||||
|
|
||||||
Host: Intel(R) Core(TM) i7-10700F CPU @ 2.90GHz, 16 cores, 15G, Windows 11 , 64 bit Build 26100 (10.0.26100.8875)
|
|
||||||
Time: Sun Jul 26 12:04:05 2026 Mitteleuropäische Sommerzeit elapsed time: 0.015396 seconds (0d 0h 0m 0s)
|
|
||||||
|
|
||||||
--------------- T H R E A D ---------------
|
|
||||||
|
|
||||||
Current thread (0x000001d94e30a300): JavaThread "Unknown thread" [_thread_in_vm, id=39064, stack(0x000000d2e4200000,0x000000d2e4300000) (1024K)]
|
|
||||||
|
|
||||||
Stack: [0x000000d2e4200000,0x000000d2e4300000]
|
|
||||||
Native frames: (J=compiled Java code, j=interpreted, Vv=VM code, C=native code)
|
|
||||||
V [jvm.dll+0x7c2949] (no source info available)
|
|
||||||
V [jvm.dll+0x78c01d] (no source info available)
|
|
||||||
V [jvm.dll+0xa105b3] (no source info available)
|
|
||||||
V [jvm.dll+0xa12dac] (no source info available)
|
|
||||||
V [jvm.dll+0xa134a3] (no source info available)
|
|
||||||
V [jvm.dll+0x308106] (no source info available)
|
|
||||||
V [jvm.dll+0x7beb55] (no source info available)
|
|
||||||
V [jvm.dll+0x7b1ae7] (no source info available)
|
|
||||||
V [jvm.dll+0x40224e] (no source info available)
|
|
||||||
V [jvm.dll+0x40a06b] (no source info available)
|
|
||||||
V [jvm.dll+0x3f68a9] (no source info available)
|
|
||||||
V [jvm.dll+0x3f6b5b] (no source info available)
|
|
||||||
V [jvm.dll+0x3ccb73] (no source info available)
|
|
||||||
V [jvm.dll+0x3cd7de] (no source info available)
|
|
||||||
V [jvm.dll+0x9d6907] (no source info available)
|
|
||||||
V [jvm.dll+0x467992] (no source info available)
|
|
||||||
V [jvm.dll+0x9ba039] (no source info available)
|
|
||||||
V [jvm.dll+0x509dd1] (no source info available)
|
|
||||||
V [jvm.dll+0x50b741] (no source info available)
|
|
||||||
C [jli.dll+0x5422] (no source info available)
|
|
||||||
C [ucrtbase.dll+0x2cd30] (no source info available)
|
|
||||||
C [KERNEL32.DLL+0x2e957] (no source info available)
|
|
||||||
C [ntdll.dll+0xaad6c] (no source info available)
|
|
||||||
|
|
||||||
Lock stack of current Java thread (top to bottom):
|
|
||||||
|
|
||||||
|
|
||||||
--------------- P R O C E S S ---------------
|
|
||||||
|
|
||||||
Threads class SMR info:
|
|
||||||
_java_thread_list=0x00007fffca8cc108, length=0, elements={
|
|
||||||
}
|
|
||||||
|
|
||||||
Java Threads: ( => current thread )
|
|
||||||
Total: 0
|
|
||||||
|
|
||||||
Other Threads:
|
|
||||||
0x000001d95085df00 WorkerThread "GC Thread#0" [id=17804, stack(0x000000d2e4300000,0x000000d2e4400000) (1024K)]
|
|
||||||
0x000001d968bd8dc0 ConcurrentGCThread "G1 Main Marker" [id=10620, stack(0x000000d2e4400000,0x000000d2e4500000) (1024K)]
|
|
||||||
0x000001d968bdd770 WorkerThread "G1 Conc#0" [id=46496, stack(0x000000d2e4500000,0x000000d2e4600000) (1024K)]
|
|
||||||
|
|
||||||
[error occurred during error reporting (printing all threads), id 0xc0000005, EXCEPTION_ACCESS_VIOLATION (0xc0000005) at pc=0x00007fffc9ee194a]
|
|
||||||
VM state: not at safepoint (not fully initialized)
|
|
||||||
|
|
||||||
VM Mutex/Monitor currently owned by a thread: ([mutex/lock_event])
|
|
||||||
[0x00007fffca954fb0] Heap_lock - owner thread: 0x000001d94e30a300
|
|
||||||
|
|
||||||
Heap address: 0x0000000702000000, size: 4064 MB, Compressed Oops mode: Zero based, Oop shift amount: 3
|
|
||||||
|
|
||||||
CDS archive(s) mapped at: [0x0000000000000000-0x0000000000000000-0x0000000000000000), size 0, SharedBaseAddress: 0x0000000800000000, ArchiveRelocationMode: 1.
|
|
||||||
UseCompressedClassPointers 1, UseCompactObjectHeaders 0
|
|
||||||
Narrow klass pointer bits 32, Max shift 3
|
|
||||||
Narrow klass base: 0xffffffffffffffff, Narrow klass shift: -1
|
|
||||||
Encoding Range: [0xffffffffffffffff - 0x000000007fffffff), (2147483648 bytes)
|
|
||||||
Klass Range: [0x0000000000000000 - 0x0000000000000000), (0 bytes)
|
|
||||||
Klass ID Range: [4294967295 - 0) (1)
|
|
||||||
No protection zone.
|
|
||||||
|
|
||||||
GC Precious Log:
|
|
||||||
CardTable entry size: 512
|
|
||||||
Card Set container configuration: InlinePtr #cards 4 size 8 Array Of Cards #cards 16 size 48 Howl #buckets 8 coarsen threshold 3686 Howl Bitmap #cards 512 size 80 coarsen threshold 460 Card regions per heap region 1 cards per card region 4096
|
|
||||||
|
|
||||||
Heap:
|
|
||||||
garbage-first heap total reserved 4161536K, committed 0K, used 0K [0x0000000702000000, 0x0000000800000000)
|
|
||||||
region size 2048K, 0 young (0K), 0 survivors (0K)
|
|
||||||
|
|
||||||
Heap Regions: E=young(eden), S=young(survivor), O=old, HS=humongous(starts), HC=humongous(continues), CS=collection set, F=free, TAMS=top-at-mark-start, PB=parsable bottom
|
|
||||||
|
|
||||||
Card table byte_map: [0x000001d964380000,0x000001d964b70000] _byte_map_base: 0x000001d960b70000
|
|
||||||
|
|
||||||
Marking Bits: (CMBitMap*) 0x000001d95085e540
|
|
||||||
Bits: [0x000001d964b70000, 0x000001d968af0000)
|
|
||||||
|
|
||||||
GC Heap Usage History (0 events):
|
|
||||||
No events
|
|
||||||
|
|
||||||
Metaspace Usage History (0 events):
|
|
||||||
No events
|
|
||||||
|
|
||||||
Dll operation events (1 events):
|
|
||||||
Event: 0.009 Loaded shared library C:\Users\Linus\AppData\Local\Programs\IntelliJ IDEA\jbr\bin\java.dll
|
|
||||||
|
|
||||||
Deoptimization events (0 events):
|
|
||||||
No events
|
|
||||||
|
|
||||||
Classes loaded (0 events):
|
|
||||||
No events
|
|
||||||
|
|
||||||
Classes unloaded (0 events):
|
|
||||||
No events
|
|
||||||
|
|
||||||
Classes redefined (0 events):
|
|
||||||
No events
|
|
||||||
|
|
||||||
Internal exceptions (0 events):
|
|
||||||
No events
|
|
||||||
|
|
||||||
VM Operations (0 events):
|
|
||||||
No events
|
|
||||||
|
|
||||||
Memory protections (0 events):
|
|
||||||
No events
|
|
||||||
|
|
||||||
Nmethod flushes (0 events):
|
|
||||||
No events
|
|
||||||
|
|
||||||
Events (0 events):
|
|
||||||
No events
|
|
||||||
|
|
||||||
|
|
||||||
Dynamic libraries:
|
|
||||||
0x00007ff729850000 - 0x00007ff72985a000 C:\Users\Linus\AppData\Local\Programs\IntelliJ IDEA\jbr\bin\java.exe
|
|
||||||
0x00007ff92cd20000 - 0x00007ff92cf86000 C:\WINDOWS\SYSTEM32\ntdll.dll
|
|
||||||
0x00007ff8b1760000 - 0x00007ff8b1781000 C:\Program Files\Norton\Suite\aswhook.dll
|
|
||||||
0x00007ff92b780000 - 0x00007ff92b849000 C:\WINDOWS\System32\KERNEL32.DLL
|
|
||||||
0x00007ff92a640000 - 0x00007ff92aa3e000 C:\WINDOWS\System32\KERNELBASE.dll
|
|
||||||
0x00007ff92a070000 - 0x00007ff92a1bc000 C:\WINDOWS\System32\ucrtbase.dll
|
|
||||||
0x00007fffdceb0000 - 0x00007fffdcec7000 C:\Users\Linus\AppData\Local\Programs\IntelliJ IDEA\jbr\bin\jli.dll
|
|
||||||
0x00007ff92c270000 - 0x00007ff92c437000 C:\WINDOWS\System32\USER32.dll
|
|
||||||
0x00007ff92a610000 - 0x00007ff92a637000 C:\WINDOWS\System32\win32u.dll
|
|
||||||
0x00007ff92c820000 - 0x00007ff92c84b000 C:\WINDOWS\System32\GDI32.dll
|
|
||||||
0x00007ff929f40000 - 0x00007ff92a06a000 C:\WINDOWS\System32\gdi32full.dll
|
|
||||||
0x00007ff92a1d0000 - 0x00007ff92a273000 C:\WINDOWS\System32\msvcp_win.dll
|
|
||||||
0x00007ff923ae0000 - 0x00007ff923afe000 C:\Users\Linus\AppData\Local\Programs\IntelliJ IDEA\jbr\bin\VCRUNTIME140.dll
|
|
||||||
0x00007ff907170000 - 0x00007ff907402000 C:\WINDOWS\WinSxS\amd64_microsoft.windows.common-controls_6595b64144ccf1df_6.0.26100.8875_none_3e0d5d42e32fe9dd\COMCTL32.dll
|
|
||||||
0x00007ff92abc0000 - 0x00007ff92ac69000 C:\WINDOWS\System32\msvcrt.dll
|
|
||||||
0x00007ff92bc10000 - 0x00007ff92bc42000 C:\WINDOWS\System32\IMM32.DLL
|
|
||||||
0x00007ff8d77b0000 - 0x00007ff8d790c000 C:\Program Files (x86)\Citrix\ICA Client\ctxapclient64.dll
|
|
||||||
0x00007ff92c950000 - 0x00007ff92ccd3000 C:\WINDOWS\System32\combase.dll
|
|
||||||
0x00007ff92ac70000 - 0x00007ff92ad88000 C:\WINDOWS\System32\RPCRT4.dll
|
|
||||||
0x00007ff923e20000 - 0x00007ff923e2b000 C:\WINDOWS\SYSTEM32\VERSION.dll
|
|
||||||
0x00007ff929e30000 - 0x00007ff929e5a000 C:\WINDOWS\SYSTEM32\bcrypt.dll
|
|
||||||
0x00007ff92bc70000 - 0x00007ff92bd1a000 C:\WINDOWS\System32\sechost.dll
|
|
||||||
0x00007ff92c760000 - 0x00007ff92c817000 C:\WINDOWS\System32\ADVAPI32.dll
|
|
||||||
0x00007ff928d10000 - 0x00007ff928d46000 C:\WINDOWS\SYSTEM32\ntmarta.dll
|
|
||||||
0x00007ff92a490000 - 0x00007ff92a53c000 C:\WINDOWS\System32\bcryptprimitives.dll
|
|
||||||
0x00007ff8ebad0000 - 0x00007ff8ebbbb000 C:\Program Files\Windhawk\Engine\1.5.1\64\windhawk.dll
|
|
||||||
0x00007ff921750000 - 0x00007ff921877000 C:\WINDOWS\SYSTEM32\WINHTTP.dll
|
|
||||||
0x00007ff923ad0000 - 0x00007ff923adc000 C:\Users\Linus\AppData\Local\Programs\IntelliJ IDEA\jbr\bin\vcruntime140_1.dll
|
|
||||||
0x00007ff910210000 - 0x00007ff910299000 C:\Users\Linus\AppData\Local\Programs\IntelliJ IDEA\jbr\bin\msvcp140.dll
|
|
||||||
0x00007fffc9b00000 - 0x00007fffcaa55000 C:\Users\Linus\AppData\Local\Programs\IntelliJ IDEA\jbr\bin\server\jvm.dll
|
|
||||||
0x00007ff92adc0000 - 0x00007ff92ae40000 C:\WINDOWS\System32\WS2_32.dll
|
|
||||||
0x00007ff929da0000 - 0x00007ff929dfe000 C:\WINDOWS\SYSTEM32\POWRPROF.dll
|
|
||||||
0x00007ff91b2f0000 - 0x00007ff91b326000 C:\WINDOWS\SYSTEM32\WINMM.dll
|
|
||||||
0x00007ff929d80000 - 0x00007ff929d94000 C:\WINDOWS\SYSTEM32\UMPDC.dll
|
|
||||||
0x00007ff928be0000 - 0x00007ff928bfb000 C:\WINDOWS\SYSTEM32\kernel.appcore.dll
|
|
||||||
0x00007ff923ac0000 - 0x00007ff923aca000 C:\Users\Linus\AppData\Local\Programs\IntelliJ IDEA\jbr\bin\jimage.dll
|
|
||||||
0x00007ff9271b0000 - 0x00007ff9273f2000 C:\WINDOWS\SYSTEM32\DBGHELP.DLL
|
|
||||||
0x000001d94fe80000 - 0x000001d94ff59000 C:\WINDOWS\System32\OLEAUT32.dll
|
|
||||||
0x00007ff923f70000 - 0x00007ff923fab000 C:\WINDOWS\SYSTEM32\dbgcore.DLL
|
|
||||||
0x00007ff923aa0000 - 0x00007ff923abf000 C:\Users\Linus\AppData\Local\Programs\IntelliJ IDEA\jbr\bin\java.dll
|
|
||||||
0x00007ff92b900000 - 0x00007ff92ba99000 C:\WINDOWS\System32\ole32.dll
|
|
||||||
0x00007ff92ae50000 - 0x00007ff92b5e2000 C:\WINDOWS\System32\SHELL32.dll
|
|
||||||
|
|
||||||
JVMTI agents: none
|
|
||||||
|
|
||||||
dbghelp: loaded successfully - version: 4.0.5 - missing functions: none
|
|
||||||
symbol engine: initialized successfully - sym options: 0x614 - pdb path: .;C:\Users\Linus\AppData\Local\Programs\IntelliJ IDEA\jbr\bin;C:\WINDOWS\SYSTEM32;C:\Program Files\Norton\Suite;C:\WINDOWS\WinSxS\amd64_microsoft.windows.common-controls_6595b64144ccf1df_6.0.26100.8875_none_3e0d5d42e32fe9dd;C:\Program Files (x86)\Citrix\ICA Client;C:\Program Files\Windhawk\Engine\1.5.1\64;C:\Users\Linus\AppData\Local\Programs\IntelliJ IDEA\jbr\bin\server
|
|
||||||
|
|
||||||
VM Arguments:
|
|
||||||
java_command: git4idea.http.GitAskPassApp Username for 'https://git.konfi-castle.com':
|
|
||||||
java_class_path (initial): C:/Users/Linus/AppData/Local/Programs/IntelliJ IDEA/plugins/vcs-git/lib/git4idea-rt.jar;C:/Users/Linus/AppData/Local/Programs/IntelliJ IDEA/lib/externalProcess-rt.jar
|
|
||||||
Launcher Type: SUN_STANDARD
|
|
||||||
|
|
||||||
[Global flags]
|
|
||||||
intx CICompilerCount = 12 {product} {ergonomic}
|
|
||||||
uint ConcGCThreads = 3 {product} {ergonomic}
|
|
||||||
uint G1ConcRefinementThreads = 13 {product} {ergonomic}
|
|
||||||
size_t G1HeapRegionSize = 2097152 {product} {ergonomic}
|
|
||||||
size_t InitialHeapSize = 266338304 {product} {ergonomic}
|
|
||||||
size_t MarkStackSize = 4194304 {product} {ergonomic}
|
|
||||||
size_t MarkStackSizeMax = 536870912 {product} {ergonomic}
|
|
||||||
size_t MaxHeapSize = 4261412864 {product} {ergonomic}
|
|
||||||
size_t MinHeapDeltaBytes = 2097152 {product} {ergonomic}
|
|
||||||
size_t MinHeapSize = 8388608 {product} {ergonomic}
|
|
||||||
uintx NonNMethodCodeHeapSize = 7667712 {pd product} {ergonomic}
|
|
||||||
uintx NonProfiledCodeHeapSize = 122028032 {pd product} {ergonomic}
|
|
||||||
uintx ProfiledCodeHeapSize = 122028032 {pd product} {ergonomic}
|
|
||||||
uintx ReservedCodeCacheSize = 251723776 {pd product} {ergonomic}
|
|
||||||
bool SegmentedCodeCache = true {product} {ergonomic}
|
|
||||||
size_t SoftMaxHeapSize = 4261412864 {manageable} {ergonomic}
|
|
||||||
bool UseCompressedOops = true {product lp64_product} {ergonomic}
|
|
||||||
bool UseG1GC = true {product} {ergonomic}
|
|
||||||
bool UseLargePagesIndividualAllocation = false {pd product} {ergonomic}
|
|
||||||
|
|
||||||
Logging:
|
|
||||||
Log output configuration:
|
|
||||||
#0: stdout all=warning uptime,level,tags foldmultilines=false
|
|
||||||
#1: stderr all=off uptime,level,tags foldmultilines=false
|
|
||||||
|
|
||||||
Release file:
|
|
||||||
<release file has not been read>
|
|
||||||
Environment Variables:
|
|
||||||
JAVA_HOME=C:\Program Files\Eclipse Adoptium\jdk-21.0.10.7-hotspot\
|
|
||||||
PATH=C:/Program Files/Git/mingw64/libexec/git-core;C:/Program Files/Git/mingw64/libexec/git-core;C:\Program Files\Git\mingw64\bin;C:\Program Files\Git\usr\bin;C:\Users\Linus\bin;C:\Program Files\Meta Horizon\Support\oculus-runtime;C:\Program Files\Eclipse Adoptium\jdk-21.0.10.7-hotspot\bin;C:\Program Files\Eclipse Adoptium\jdk-17.0.17.10-hotspot\bin;C:\Program Files\SqlCmd\;C:\Program Files\Common Files\Oracle\Java\javapath;C:\Program Files (x86)\Common Files\Oracle\Java\java8path;C:\Program Files (x86)\Common Files\Oracle\Java\javapath;C:\WINDOWS\system32;C:\WINDOWS;C:\WINDOWS\System32\Wbem;C:\WINDOWS\System32\WindowsPowerShell\v1.0\;C:\WINDOWS\System32\OpenSSH\;C:\Program Files\Git\cmd;C:\Program Files\PuTTY\;C:\Program Files\nodejs\;C:\ProgramData\chocolatey\bin;C:\Program Files\NVIDIA Corporation\NVIDIA app\NvDLISR;C:\Users\Linus\.config\herd\bin\nvm;C:\Program Files\nodejs;C:\Program Files\WireGuard\;C:\Program Files\dotnet\;C:\Program Files (x86)\NVIDIA Corporation\PhysX\Common;C:\Program Files\CMake\bin;C:\WINDOWS\system32;C:\WINDOWS;C:\WINDOWS\System32\Wbem;C:\WINDOWS\System32\WindowsPowerShell\v1.0\;C:\WINDOWS\System32\OpenSSH\;C:\Users\Linus\AppData\Local\Muse Hub\lib;C:\Program Files (x86)\Windows Kits\10\Windows Performance Toolkit\;C:\ProgramData\chocolatey\lib\maven\apache-maven-3.9.15\bin;C:\Program Files\Docker\Docker\resources\bin;C:\Program Files\PatchMon;C:\Users\Linus\AppData\Local\Muse Hub\lib;C:\Program Files\Yubico\YubiKey Manager CLI\;C:\Program Files\Netbird;C:\Program Files\Zabbix Agent\;C:\Program Files\Netbird\;C:\Program Files\PowerShell\7\;C:\Users\Linus\.local\bin;C:\Users\Linus\AppData\Local\Programs\Python\Python311\Scripts\;C:\Users\Linus\AppData\Local\Programs\Python\Python311\;C:\Users\Linus\AppData\Local\Programs\Python\Python314\Scripts;C:\Users\Linus\AppData\Local\Programs\Python\Python314\;C:\Users\Linus\.cargo\bin;C:\Users\Linus\.config\herd-lite\bin;C:\Users\Linus\AppData\Local\Microsoft\WindowsApps;C:\Users\Linus\AppData\Local\JetBrains\Toolbox\scripts;C:\Users\Linus\AppData\Local\GitHubDesktop\bin;C:\ProgramData\Linus\GitHubDesktop\bin;C:\Users\Linus\AppData\Roaming\npm;C:\Flutter\flutter\bin;C:\Users\Linus\AppData\Local\Programs\Azure Data Studio\bin;C:\Users\Linus\.dotnet\tools;C:\Program Files (x86)\Nmap;C:\gradle\gradle-9.2.0\bin;C:\Users\Linus\AppData\Local\Programs\Antigravity\bin;C:\Users\Linus\AppData\Local\Programs\Microsoft VS Code\bin;C:\Users\Linus\AppData\Local\Microsoft\WindowsApps;C:\Users\Linus\AppData\Local\Microsoft\WinGet\Links;C:\Users\Linus\AppData\Local\Microsoft\WinGet\Packages\Google.PlatformTools_Microsoft.Winget.Source_8wekyb3d8bbwe\platform-tools;C:\Users\Linus\AppData\Local\Programs\Ollama;C:\Program Files\JetBrains\DataGrip 2026.1\bin;C:\Users\Linus\AppData\Local\Programs\Warp\bin
|
|
||||||
USERNAME=Linus
|
|
||||||
DISPLAY=:0.0
|
|
||||||
LC_ALL=en_US.UTF-8
|
|
||||||
TERM=xterm-256color
|
|
||||||
TMPDIR=C:\Users\Linus\AppData\Local\Temp
|
|
||||||
OS=Windows_NT
|
|
||||||
PROCESSOR_IDENTIFIER=Intel64 Family 6 Model 165 Stepping 5, GenuineIntel
|
|
||||||
TMP=C:\Users\Linus\AppData\Local\Temp
|
|
||||||
TEMP=C:\Users\Linus\AppData\Local\Temp
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Compilation memory statistics disabled.
|
|
||||||
|
|
||||||
Periodic native trim disabled
|
|
||||||
|
|
||||||
JNI global refs:
|
|
||||||
JNI global refs: 0, weak refs: 0
|
|
||||||
|
|
||||||
JNI global refs memory usage: 0, weak refs: 0
|
|
||||||
|
|
||||||
Process memory usage:
|
|
||||||
Resident Set Size: 17500K (0% of 16643176K total physical memory with 512524K free physical memory)
|
|
||||||
|
|
||||||
OOME stack traces (most recent first):
|
|
||||||
Classloader memory used:
|
|
||||||
|
|
||||||
--------------- S Y S T E M ---------------
|
|
||||||
|
|
||||||
OS:
|
|
||||||
Windows 11 , 64 bit Build 26100 (10.0.26100.8875)
|
|
||||||
OS uptime: 0 days 1:29 hours
|
|
||||||
Hyper-V role detected
|
|
||||||
|
|
||||||
CPU: total 16 (initial active 16) (8 cores per cpu, 2 threads per core) family 6 model 165 stepping 5 microcode 0xec, cx8, cmov, fxsr, ht, mmx, 3dnowpref, sse, sse2, sse3, ssse3, sse4.1, sse4.2, popcnt, lzcnt, tsc, tscinvbit, avx, avx2, aes, erms, clmul, bmi1, bmi2, adx, fma, vzeroupper, clflush, clflushopt, hv, rdtscp, f16c
|
|
||||||
Processor Information for the first 16 processors :
|
|
||||||
Max Mhz: 2904, Current Mhz: 2904, Mhz Limit: 2904
|
|
||||||
|
|
||||||
Memory: 4k page, system-wide physical 16253M (500M free)
|
|
||||||
TotalPageFile size 34846M (AvailPageFile size 112M)
|
|
||||||
current process WorkingSet (physical memory assigned to process): 17M, peak: 17M
|
|
||||||
current process commit charge ("private bytes"): 73M, peak: 326M
|
|
||||||
|
|
||||||
vm_info: OpenJDK 64-Bit Server VM (25.0.3+9-b508.16) for windows-amd64 JRE (25.0.3+9-b508.16), built on 2026-06-22 with MS VC++ 17.14 (VS2022)
|
|
||||||
|
|
||||||
END.
|
|
||||||
@@ -34,7 +34,6 @@ export default async function AccountPage({
|
|||||||
<h3 className="panel-title">Profil</h3>
|
<h3 className="panel-title">Profil</h3>
|
||||||
<dl className="data-list">
|
<dl className="data-list">
|
||||||
<div><dt>Name</dt><dd>{user.name}</dd></div>
|
<div><dt>Name</dt><dd>{user.name}</dd></div>
|
||||||
<div><dt>E-Mail</dt><dd className="mono">{user.email}</dd></div>
|
|
||||||
<div><dt>Rolle</dt><dd>{user.role === "admin" ? "Administrator" : "Mitglied"}</dd></div>
|
<div><dt>Rolle</dt><dd>{user.role === "admin" ? "Administrator" : "Mitglied"}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export const runtime = "nodejs";
|
|||||||
type UserRow = {
|
type UserRow = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
@@ -30,7 +29,9 @@ export default async function AdminUsersPage({
|
|||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
|
|
||||||
const users = db
|
const users = db
|
||||||
.prepare("SELECT id, name, email, role, created_at FROM users ORDER BY name COLLATE NOCASE")
|
.prepare(
|
||||||
|
"SELECT id, name, role, created_at FROM users WHERE active = 1 ORDER BY name COLLATE NOCASE",
|
||||||
|
)
|
||||||
.all() as UserRow[];
|
.all() as UserRow[];
|
||||||
const adminCount = users.filter((u) => u.role === "admin").length;
|
const adminCount = users.filter((u) => u.role === "admin").length;
|
||||||
|
|
||||||
@@ -62,7 +63,6 @@ export default async function AdminUsersPage({
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>E-Mail</th>
|
|
||||||
<th>Rolle</th>
|
<th>Rolle</th>
|
||||||
<th>Angelegt</th>
|
<th>Angelegt</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
@@ -75,7 +75,6 @@ export default async function AdminUsersPage({
|
|||||||
return (
|
return (
|
||||||
<tr key={row.id}>
|
<tr key={row.id}>
|
||||||
<td>{row.name}{isSelf && <span className="badge">Du</span>}</td>
|
<td>{row.name}{isSelf && <span className="badge">Du</span>}</td>
|
||||||
<td className="mono">{row.email}</td>
|
|
||||||
<td>
|
<td>
|
||||||
<form action={changeUserRoleAction} className="inline-form">
|
<form action={changeUserRoleAction} className="inline-form">
|
||||||
<input type="hidden" name="id" value={row.id} />
|
<input type="hidden" name="id" value={row.id} />
|
||||||
@@ -121,10 +120,6 @@ export default async function AdminUsersPage({
|
|||||||
Name *
|
Name *
|
||||||
<input name="name" required maxLength={100} />
|
<input name="name" required maxLength={100} />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
|
||||||
E-Mail *
|
|
||||||
<input name="email" type="email" required maxLength={254} />
|
|
||||||
</label>
|
|
||||||
<label>
|
<label>
|
||||||
Passwort * (min. 10 Zeichen)
|
Passwort * (min. 10 Zeichen)
|
||||||
<input name="password" type="password" required minLength={10} maxLength={200} />
|
<input name="password" type="password" required minLength={10} maxLength={200} />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { MotorcycleForm } from "@/components/motorcycle-form";
|
|||||||
import { requireSessionUser } from "@/lib/auth";
|
import { requireSessionUser } from "@/lib/auth";
|
||||||
import { db, initDatabase } from "@/lib/db";
|
import { db, initDatabase } from "@/lib/db";
|
||||||
import { queryMessage } from "@/lib/format";
|
import { queryMessage } from "@/lib/format";
|
||||||
|
import { motorcycleAccessFilter } from "@/lib/motorcycle-access";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
@@ -17,6 +18,17 @@ type MotorcycleValue = {
|
|||||||
vin: string | null;
|
vin: string | null;
|
||||||
plate: string | null;
|
plate: string | null;
|
||||||
current_km: number;
|
current_km: number;
|
||||||
|
tire_pressure_front_bar: number | null;
|
||||||
|
tire_pressure_rear_bar: number | null;
|
||||||
|
chain_tension_min_mm: number | null;
|
||||||
|
chain_tension_max_mm: number | null;
|
||||||
|
oil_type: string | null;
|
||||||
|
oil_capacity_liters: number | null;
|
||||||
|
oil_check_temp: "warm" | "cold" | null;
|
||||||
|
oil_check_run_minutes: number | null;
|
||||||
|
oil_check_wait_min_minutes: number | null;
|
||||||
|
oil_check_wait_max_minutes: number | null;
|
||||||
|
oil_check_method: "dipstick" | "sight_glass" | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,16 +39,39 @@ export default async function EditMotorcyclePage({
|
|||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
}) {
|
}) {
|
||||||
await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
initDatabase();
|
initDatabase();
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const motorcycleId = Number(id);
|
const motorcycleId = Number(id);
|
||||||
if (!Number.isSafeInteger(motorcycleId) || motorcycleId < 1) notFound();
|
if (!Number.isSafeInteger(motorcycleId) || motorcycleId < 1) notFound();
|
||||||
|
|
||||||
|
const visible = motorcycleAccessFilter(user);
|
||||||
const bike = db
|
const bike = db
|
||||||
.prepare("SELECT * FROM motorcycles WHERE id = ?")
|
.prepare(`SELECT m.* FROM motorcycles m WHERE m.id = ? AND ${visible.clause}`)
|
||||||
.get(motorcycleId) as MotorcycleValue | undefined;
|
.get(motorcycleId, ...visible.params) as MotorcycleValue | undefined;
|
||||||
if (!bike) notFound();
|
if (!bike) notFound();
|
||||||
|
const motorcycleForForm: MotorcycleValue = {
|
||||||
|
id: bike.id,
|
||||||
|
nickname: bike.nickname,
|
||||||
|
brand: bike.brand,
|
||||||
|
model: bike.model,
|
||||||
|
year: bike.year,
|
||||||
|
vin: bike.vin,
|
||||||
|
plate: bike.plate,
|
||||||
|
current_km: bike.current_km,
|
||||||
|
tire_pressure_front_bar: bike.tire_pressure_front_bar,
|
||||||
|
tire_pressure_rear_bar: bike.tire_pressure_rear_bar,
|
||||||
|
chain_tension_min_mm: bike.chain_tension_min_mm,
|
||||||
|
chain_tension_max_mm: bike.chain_tension_max_mm,
|
||||||
|
oil_type: bike.oil_type,
|
||||||
|
oil_capacity_liters: bike.oil_capacity_liters,
|
||||||
|
oil_check_temp: bike.oil_check_temp,
|
||||||
|
oil_check_run_minutes: bike.oil_check_run_minutes,
|
||||||
|
oil_check_wait_min_minutes: bike.oil_check_wait_min_minutes,
|
||||||
|
oil_check_wait_max_minutes: bike.oil_check_wait_max_minutes,
|
||||||
|
oil_check_method: bike.oil_check_method,
|
||||||
|
notes: bike.notes,
|
||||||
|
};
|
||||||
|
|
||||||
const params_ = await searchParams;
|
const params_ = await searchParams;
|
||||||
|
|
||||||
@@ -56,7 +91,7 @@ export default async function EditMotorcyclePage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Flash error={queryMessage(params_, "error")} />
|
<Flash error={queryMessage(params_, "error")} />
|
||||||
<MotorcycleForm motorcycle={bike} />
|
<MotorcycleForm motorcycle={motorcycleForForm} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { deleteMaintenanceAction, deleteMotorcycleAction, deleteTripAction } from "@/app/actions/motorcycles";
|
import {
|
||||||
|
deleteMaintenanceAction,
|
||||||
|
deleteMotorcycleAction,
|
||||||
|
deleteTripAction,
|
||||||
|
revokeMotorcycleShareAction,
|
||||||
|
shareMotorcycleAction,
|
||||||
|
} from "@/app/actions/motorcycles";
|
||||||
import { Flash } from "@/components/flash";
|
import { Flash } from "@/components/flash";
|
||||||
import { InspectionSticker } from "@/components/inspection-sticker";
|
import { InspectionSticker } from "@/components/inspection-sticker";
|
||||||
import { MaintenanceForm } from "@/components/maintenance-form";
|
import { MaintenanceForm } from "@/components/maintenance-form";
|
||||||
@@ -9,6 +15,7 @@ import { TripForm } from "@/components/trip-form";
|
|||||||
import { requireSessionUser } from "@/lib/auth";
|
import { requireSessionUser } from "@/lib/auth";
|
||||||
import { db, initDatabase } from "@/lib/db";
|
import { db, initDatabase } from "@/lib/db";
|
||||||
import { formatDate, formatKm, formatMoney, queryMessage } from "@/lib/format";
|
import { formatDate, formatKm, formatMoney, queryMessage } from "@/lib/format";
|
||||||
|
import { canManageShares, motorcycleAccessFilter } from "@/lib/motorcycle-access";
|
||||||
import { getDueStatus } from "@/lib/status";
|
import { getDueStatus } from "@/lib/status";
|
||||||
import { maintenanceLabels, type MaintenanceType } from "@/lib/types";
|
import { maintenanceLabels, type MaintenanceType } from "@/lib/types";
|
||||||
|
|
||||||
@@ -23,7 +30,20 @@ type MotorcycleRow = {
|
|||||||
vin: string | null;
|
vin: string | null;
|
||||||
plate: string | null;
|
plate: string | null;
|
||||||
current_km: number;
|
current_km: number;
|
||||||
|
created_by: number;
|
||||||
|
tire_pressure_front_bar: number | null;
|
||||||
|
tire_pressure_rear_bar: number | null;
|
||||||
|
chain_tension_min_mm: number | null;
|
||||||
|
chain_tension_max_mm: number | null;
|
||||||
|
oil_type: string | null;
|
||||||
|
oil_capacity_liters: number | null;
|
||||||
|
oil_check_temp: "warm" | "cold" | null;
|
||||||
|
oil_check_run_minutes: number | null;
|
||||||
|
oil_check_wait_min_minutes: number | null;
|
||||||
|
oil_check_wait_max_minutes: number | null;
|
||||||
|
oil_check_method: "dipstick" | "sight_glass" | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
owner_name: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type EventRow = {
|
type EventRow = {
|
||||||
@@ -36,7 +56,7 @@ type EventRow = {
|
|||||||
cost_cents: number | null;
|
cost_cents: number | null;
|
||||||
due_date: string | null;
|
due_date: string | null;
|
||||||
due_km: number | null;
|
due_km: number | null;
|
||||||
creator: string | null;
|
creator: string;
|
||||||
attachment_id: number | null;
|
attachment_id: number | null;
|
||||||
attachment_name: string | null;
|
attachment_name: string | null;
|
||||||
};
|
};
|
||||||
@@ -44,13 +64,18 @@ type EventRow = {
|
|||||||
type TripRow = {
|
type TripRow = {
|
||||||
id: number;
|
id: number;
|
||||||
trip_date: string;
|
trip_date: string;
|
||||||
driver: string | null;
|
driver: string;
|
||||||
start_km: number;
|
start_km: number;
|
||||||
end_km: number;
|
end_km: number;
|
||||||
purpose: string | null;
|
purpose: string | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ShareRow = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
function latestDue(events: EventRow[], type: MaintenanceType) {
|
function latestDue(events: EventRow[], type: MaintenanceType) {
|
||||||
const match = events.find((event) => event.type === type && (event.due_date || event.due_km != null));
|
const match = events.find((event) => event.type === type && (event.due_date || event.due_km != null));
|
||||||
return { dueDate: match?.due_date ?? null, dueKm: match?.due_km ?? null };
|
return { dueDate: match?.due_date ?? null, dueKm: match?.due_km ?? null };
|
||||||
@@ -69,17 +94,24 @@ export default async function MotorcycleDetailPage({
|
|||||||
const motorcycleId = Number(id);
|
const motorcycleId = Number(id);
|
||||||
if (!Number.isSafeInteger(motorcycleId) || motorcycleId < 1) notFound();
|
if (!Number.isSafeInteger(motorcycleId) || motorcycleId < 1) notFound();
|
||||||
|
|
||||||
|
const visible = motorcycleAccessFilter(user);
|
||||||
const bike = db
|
const bike = db
|
||||||
.prepare("SELECT * FROM motorcycles WHERE id = ?")
|
.prepare(
|
||||||
.get(motorcycleId) as MotorcycleRow | undefined;
|
`SELECT m.*, owner.name AS owner_name
|
||||||
|
FROM motorcycles m
|
||||||
|
JOIN users owner ON owner.id = m.created_by
|
||||||
|
WHERE m.id = ? AND ${visible.clause}`,
|
||||||
|
)
|
||||||
|
.get(motorcycleId, ...visible.params) as MotorcycleRow | undefined;
|
||||||
if (!bike) notFound();
|
if (!bike) notFound();
|
||||||
|
const userCanManageShares = canManageShares(user, motorcycleId);
|
||||||
|
|
||||||
const params_ = await searchParams;
|
const params_ = await searchParams;
|
||||||
|
|
||||||
const events = db
|
const events = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT e.id, e.type, e.title, e.description, e.event_date, e.km, e.cost_cents,
|
`SELECT e.id, e.type, e.title, e.description, e.event_date, e.km, e.cost_cents,
|
||||||
e.due_date, e.due_km, u.name AS creator,
|
e.due_date, e.due_km, COALESCE(u.name, 'Gelöschter Benutzer') AS creator,
|
||||||
a.id AS attachment_id, a.original_name AS attachment_name
|
a.id AS attachment_id, a.original_name AS attachment_name
|
||||||
FROM maintenance_events e
|
FROM maintenance_events e
|
||||||
LEFT JOIN users u ON u.id = e.created_by
|
LEFT JOIN users u ON u.id = e.created_by
|
||||||
@@ -91,7 +123,7 @@ export default async function MotorcycleDetailPage({
|
|||||||
|
|
||||||
const trips = db
|
const trips = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT t.id, t.trip_date, u.name AS driver, t.start_km, t.end_km, t.purpose, t.notes
|
`SELECT t.id, t.trip_date, COALESCE(u.name, 'Gelöschter Benutzer') AS driver, t.start_km, t.end_km, t.purpose, t.notes
|
||||||
FROM trips t
|
FROM trips t
|
||||||
LEFT JOIN users u ON u.id = t.driver_user_id
|
LEFT JOIN users u ON u.id = t.driver_user_id
|
||||||
WHERE t.motorcycle_id = ?
|
WHERE t.motorcycle_id = ?
|
||||||
@@ -99,10 +131,38 @@ export default async function MotorcycleDetailPage({
|
|||||||
)
|
)
|
||||||
.all(motorcycleId) as TripRow[];
|
.all(motorcycleId) as TripRow[];
|
||||||
|
|
||||||
const drivers = db.prepare("SELECT id, name FROM users ORDER BY name COLLATE NOCASE").all() as {
|
const drivers = db
|
||||||
|
.prepare("SELECT id, name FROM users WHERE active = 1 ORDER BY name COLLATE NOCASE")
|
||||||
|
.all() as {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
}[];
|
}[];
|
||||||
|
const shares = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT u.id, u.name
|
||||||
|
FROM motorcycle_shares ms
|
||||||
|
JOIN users u ON u.id = ms.user_id
|
||||||
|
WHERE ms.motorcycle_id = ? AND u.active = 1
|
||||||
|
ORDER BY u.name COLLATE NOCASE`,
|
||||||
|
)
|
||||||
|
.all(motorcycleId) as ShareRow[];
|
||||||
|
const shareCandidates = userCanManageShares
|
||||||
|
? (db
|
||||||
|
.prepare(
|
||||||
|
`SELECT u.id, u.name
|
||||||
|
FROM users u
|
||||||
|
WHERE u.active = 1
|
||||||
|
AND u.id != ?
|
||||||
|
AND u.id != ?
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM motorcycle_shares ms
|
||||||
|
WHERE ms.motorcycle_id = ? AND ms.user_id = u.id
|
||||||
|
)
|
||||||
|
ORDER BY u.name COLLATE NOCASE`,
|
||||||
|
)
|
||||||
|
.all(user.id, bike.created_by, motorcycleId) as ShareRow[])
|
||||||
|
: [];
|
||||||
|
|
||||||
const tuev = latestDue(events, "tuev_hu");
|
const tuev = latestDue(events, "tuev_hu");
|
||||||
const inspection = latestDue(events, "inspektion");
|
const inspection = latestDue(events, "inspektion");
|
||||||
@@ -126,8 +186,9 @@ export default async function MotorcycleDetailPage({
|
|||||||
<p className="muted">{bike.plate ?? "Ohne Kennzeichen"}</p>
|
<p className="muted">{bike.plate ?? "Ohne Kennzeichen"}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="heading-actions">
|
<div className="heading-actions">
|
||||||
|
<Link className="button" href={`/motorcycles/${bike.id}/maintenance`}>Wartungsansicht</Link>
|
||||||
<Link className="button" href={`/motorcycles/${bike.id}/edit`}>Bearbeiten</Link>
|
<Link className="button" href={`/motorcycles/${bike.id}/edit`}>Bearbeiten</Link>
|
||||||
{user.role === "admin" && (
|
{(user.role === "admin" || bike.created_by === user.id) && (
|
||||||
<form
|
<form
|
||||||
action={deleteMotorcycleAction}
|
action={deleteMotorcycleAction}
|
||||||
className="inline-form"
|
className="inline-form"
|
||||||
@@ -170,11 +231,68 @@ export default async function MotorcycleDetailPage({
|
|||||||
<div><dt>Baujahr</dt><dd>{bike.year ?? "—"}</dd></div>
|
<div><dt>Baujahr</dt><dd>{bike.year ?? "—"}</dd></div>
|
||||||
<div><dt>Kennzeichen</dt><dd>{bike.plate ?? "—"}</dd></div>
|
<div><dt>Kennzeichen</dt><dd>{bike.plate ?? "—"}</dd></div>
|
||||||
<div><dt>FIN / VIN</dt><dd className="mono">{bike.vin ?? "—"}</dd></div>
|
<div><dt>FIN / VIN</dt><dd className="mono">{bike.vin ?? "—"}</dd></div>
|
||||||
|
<div><dt>Besitzer</dt><dd>{bike.owner_name}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
{bike.notes && <p className="notes">{bike.notes}</p>}
|
{bike.notes && <p className="notes">{bike.notes}</p>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{userCanManageShares && (
|
||||||
|
<section className="section-block">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Freigaben</p>
|
||||||
|
<h2>Moped teilen</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="panel table-wrap">
|
||||||
|
{shares.length === 0 ? (
|
||||||
|
<p className="muted">Noch mit niemandem geteilt.</p>
|
||||||
|
) : (
|
||||||
|
<table className="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{shares.map((share) => (
|
||||||
|
<tr key={share.id}>
|
||||||
|
<td>{share.name}</td>
|
||||||
|
<td>
|
||||||
|
<form action={revokeMotorcycleShareAction} className="inline-form">
|
||||||
|
<input type="hidden" name="motorcycle_id" value={bike.id} />
|
||||||
|
<input type="hidden" name="user_id" value={share.id} />
|
||||||
|
<button className="link-button danger" type="submit">Entfernen</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<form action={shareMotorcycleAction} className="panel inline-form">
|
||||||
|
<input type="hidden" name="motorcycle_id" value={bike.id} />
|
||||||
|
<label>
|
||||||
|
Benutzer hinzufügen
|
||||||
|
<select name="username" required defaultValue="">
|
||||||
|
<option value="" disabled>Bitte wählen</option>
|
||||||
|
{shareCandidates.map((candidate) => (
|
||||||
|
<option key={candidate.id} value={candidate.name}>
|
||||||
|
{candidate.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button className="button button-primary" type="submit" disabled={shareCandidates.length === 0}>
|
||||||
|
Teilen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<section className="section-block">
|
<section className="section-block">
|
||||||
<div className="section-heading">
|
<div className="section-heading">
|
||||||
<div>
|
<div>
|
||||||
@@ -201,7 +319,7 @@ export default async function MotorcycleDetailPage({
|
|||||||
{(event.due_date || event.due_km != null) && (
|
{(event.due_date || event.due_km != null) && (
|
||||||
<span>Nächste Fälligkeit: {formatDate(event.due_date)} · {formatKm(event.due_km)}</span>
|
<span>Nächste Fälligkeit: {formatDate(event.due_date)} · {formatKm(event.due_km)}</span>
|
||||||
)}
|
)}
|
||||||
{event.creator && <span>von {event.creator}</span>}
|
<span>von {event.creator}</span>
|
||||||
{event.attachment_id && (
|
{event.attachment_id && (
|
||||||
<a href={`/attachments/${event.attachment_id}`} target="_blank" rel="noreferrer">
|
<a href={`/attachments/${event.attachment_id}`} target="_blank" rel="noreferrer">
|
||||||
📎 {event.attachment_name}
|
📎 {event.attachment_name}
|
||||||
@@ -250,7 +368,7 @@ export default async function MotorcycleDetailPage({
|
|||||||
{trips.map((trip) => (
|
{trips.map((trip) => (
|
||||||
<tr key={trip.id}>
|
<tr key={trip.id}>
|
||||||
<td>{formatDate(trip.trip_date)}</td>
|
<td>{formatDate(trip.trip_date)}</td>
|
||||||
<td>{trip.driver ?? "—"}</td>
|
<td>{trip.driver}</td>
|
||||||
<td className="mono">{formatKm(trip.start_km)}</td>
|
<td className="mono">{formatKm(trip.start_km)}</td>
|
||||||
<td className="mono">{formatKm(trip.end_km)}</td>
|
<td className="mono">{formatKm(trip.end_km)}</td>
|
||||||
<td className="mono">{formatKm(trip.end_km - trip.start_km)}</td>
|
<td className="mono">{formatKm(trip.end_km - trip.start_km)}</td>
|
||||||
|
|||||||
+12
-5
@@ -2,8 +2,10 @@ import Link from "next/link";
|
|||||||
import { Flash } from "@/components/flash";
|
import { Flash } from "@/components/flash";
|
||||||
import { InspectionSticker } from "@/components/inspection-sticker";
|
import { InspectionSticker } from "@/components/inspection-sticker";
|
||||||
import { StatusPill } from "@/components/status-pill";
|
import { StatusPill } from "@/components/status-pill";
|
||||||
|
import { requireSessionUser } from "@/lib/auth";
|
||||||
import { db, initDatabase } from "@/lib/db";
|
import { db, initDatabase } from "@/lib/db";
|
||||||
import { formatDate, formatKm, queryMessage } from "@/lib/format";
|
import { formatDate, formatKm, queryMessage } from "@/lib/format";
|
||||||
|
import { motorcycleAccessFilter } from "@/lib/motorcycle-access";
|
||||||
import { getDueStatus } from "@/lib/status";
|
import { getDueStatus } from "@/lib/status";
|
||||||
import { maintenanceLabels, type MaintenanceType } from "@/lib/types";
|
import { maintenanceLabels, type MaintenanceType } from "@/lib/types";
|
||||||
|
|
||||||
@@ -39,8 +41,10 @@ export default async function Dashboard({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
}) {
|
}) {
|
||||||
|
const user = await requireSessionUser();
|
||||||
initDatabase();
|
initDatabase();
|
||||||
const params = await searchParams;
|
const params = await searchParams;
|
||||||
|
const visible = motorcycleAccessFilter(user);
|
||||||
const motorcycles = db
|
const motorcycles = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT m.*,
|
`SELECT m.*,
|
||||||
@@ -60,16 +64,19 @@ export default async function Dashboard({
|
|||||||
WHERE e.motorcycle_id = m.id AND e.type = 'inspektion'
|
WHERE e.motorcycle_id = m.id AND e.type = 'inspektion'
|
||||||
AND (e.due_date IS NOT NULL OR e.due_km IS NOT NULL)
|
AND (e.due_date IS NOT NULL OR e.due_km IS NOT NULL)
|
||||||
ORDER BY e.event_date DESC, e.id DESC LIMIT 1) AS inspection_due_km
|
ORDER BY e.event_date DESC, e.id DESC LIMIT 1) AS inspection_due_km
|
||||||
FROM motorcycles m ORDER BY m.nickname COLLATE NOCASE`,
|
FROM motorcycles m
|
||||||
|
WHERE ${visible.clause}
|
||||||
|
ORDER BY m.nickname COLLATE NOCASE`,
|
||||||
)
|
)
|
||||||
.all() as BikeRow[];
|
.all(...visible.params) as BikeRow[];
|
||||||
const dueRows = db
|
const dueRows = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT e.id, e.motorcycle_id, m.nickname, e.type, e.title,
|
`SELECT e.id, e.motorcycle_id, m.nickname, e.type, e.title,
|
||||||
e.due_date, e.due_km, m.current_km
|
e.due_date, e.due_km, m.current_km
|
||||||
FROM maintenance_events e
|
FROM maintenance_events e
|
||||||
JOIN motorcycles m ON m.id = e.motorcycle_id
|
JOIN motorcycles m ON m.id = e.motorcycle_id
|
||||||
WHERE (e.due_date IS NOT NULL OR e.due_km IS NOT NULL)
|
WHERE ${visible.clause}
|
||||||
|
AND (e.due_date IS NOT NULL OR e.due_km IS NOT NULL)
|
||||||
AND e.id = (
|
AND e.id = (
|
||||||
SELECT newer.id FROM maintenance_events newer
|
SELECT newer.id FROM maintenance_events newer
|
||||||
WHERE newer.motorcycle_id = e.motorcycle_id
|
WHERE newer.motorcycle_id = e.motorcycle_id
|
||||||
@@ -78,7 +85,7 @@ export default async function Dashboard({
|
|||||||
ORDER BY newer.event_date DESC, newer.id DESC LIMIT 1
|
ORDER BY newer.event_date DESC, newer.id DESC LIMIT 1
|
||||||
)`,
|
)`,
|
||||||
)
|
)
|
||||||
.all() as DueRow[];
|
.all(...visible.params) as DueRow[];
|
||||||
const dueItems = dueRows
|
const dueItems = dueRows
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
@@ -100,7 +107,7 @@ export default async function Dashboard({
|
|||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Werkstattübersicht</p>
|
<p className="eyebrow">Werkstattübersicht</p>
|
||||||
<h1>Unsere Motorräder</h1>
|
<h1>Unsere Motorräder</h1>
|
||||||
<p className="muted">{motorcycles.length} Maschinen im gemeinsamen Fuhrpark</p>
|
<p className="muted">{motorcycles.length} Maschinen in deinem Fuhrpark & Freigaben</p>
|
||||||
</div>
|
</div>
|
||||||
<Link className="button button-primary" href="/motorcycles/new">Motorrad anlegen</Link>
|
<Link className="button button-primary" href="/motorcycles/new">Motorrad anlegen</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -9,24 +9,24 @@ import {
|
|||||||
setSessionCookie,
|
setSessionCookie,
|
||||||
} from "@/lib/auth";
|
} from "@/lib/auth";
|
||||||
import { db, initDatabase } from "@/lib/db";
|
import { db, initDatabase } from "@/lib/db";
|
||||||
import { emailValue, passwordValue } from "@/lib/validation";
|
import { passwordValue, text } from "@/lib/validation";
|
||||||
|
|
||||||
function messageUrl(path: string, kind: "error" | "success", message: string) {
|
function messageUrl(path: string, kind: "error" | "success", message: string) {
|
||||||
return `${path}?${kind}=${encodeURIComponent(message)}`;
|
return `${path}?${kind}=${encodeURIComponent(message)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginAction(formData: FormData) {
|
export async function loginAction(formData: FormData) {
|
||||||
let email: string;
|
let username: string;
|
||||||
let password: string;
|
let password: string;
|
||||||
try {
|
try {
|
||||||
email = emailValue(formData);
|
username = text(formData, "username", { required: true, max: 100 });
|
||||||
password = String(formData.get("password") ?? "");
|
password = String(formData.get("password") ?? "");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
redirect(messageUrl("/login", "error", error instanceof Error ? error.message : "Ungültige Eingabe."));
|
redirect(messageUrl("/login", "error", error instanceof Error ? error.message : "Ungültige Eingabe."));
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = authenticateUser(email, password);
|
const user = authenticateUser(username, password);
|
||||||
if (!user) redirect(messageUrl("/login", "error", "E-Mail oder Passwort ist falsch."));
|
if (!user) redirect(messageUrl("/login", "error", "Benutzername oder Passwort ist falsch."));
|
||||||
await setSessionCookie(user);
|
await setSessionCookie(user);
|
||||||
redirect("/");
|
redirect("/");
|
||||||
}
|
}
|
||||||
|
|||||||
+228
-14
@@ -4,11 +4,13 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { basename, join } from "node:path";
|
import { basename, join } from "node:path";
|
||||||
import { existsSync, unlinkSync, writeFileSync } from "node:fs";
|
import { existsSync, unlinkSync, writeFileSync } from "node:fs";
|
||||||
import { redirect, unstable_rethrow } from "next/navigation";
|
import { redirect, unstable_rethrow } from "next/navigation";
|
||||||
import { requireAdminUser, requireSessionUser } from "@/lib/auth";
|
import { requireSessionUser } from "@/lib/auth";
|
||||||
import { db, initDatabase, transaction } from "@/lib/db";
|
import { db, initDatabase, transaction } from "@/lib/db";
|
||||||
import { getUploadDir } from "@/lib/env";
|
import { getUploadDir } from "@/lib/env";
|
||||||
|
import { canManageShares, ensureMotorcycleAccess } from "@/lib/motorcycle-access";
|
||||||
import {
|
import {
|
||||||
dateValue,
|
dateValue,
|
||||||
|
decimal,
|
||||||
idValue,
|
idValue,
|
||||||
integer,
|
integer,
|
||||||
maintenanceType,
|
maintenanceType,
|
||||||
@@ -31,11 +33,81 @@ function errorMessage(error: unknown) {
|
|||||||
return error instanceof Error ? error.message : "Die Aktion konnte nicht ausgeführt werden.";
|
return error instanceof Error ? error.message : "Die Aktion konnte nicht ausgeführt werden.";
|
||||||
}
|
}
|
||||||
|
|
||||||
function motorcycleExists(id: number) {
|
function oilCheckFields(formData: FormData) {
|
||||||
return Boolean(db.prepare("SELECT 1 FROM motorcycles WHERE id = ?").get(id));
|
const oilCheckTempRaw = text(formData, "oil_check_temp", { max: 20 });
|
||||||
|
const oilCheckMethodRaw = text(formData, "oil_check_method", { max: 20 });
|
||||||
|
const oilCheckTemp =
|
||||||
|
oilCheckTempRaw === "warm" || oilCheckTempRaw === "cold" ? oilCheckTempRaw : null;
|
||||||
|
const oilCheckMethod =
|
||||||
|
oilCheckMethodRaw === "dipstick" || oilCheckMethodRaw === "sight_glass"
|
||||||
|
? oilCheckMethodRaw
|
||||||
|
: null;
|
||||||
|
if (oilCheckTempRaw && !oilCheckTemp) throw new Error("Ölprüfung: Ungültige Temperatur-Angabe.");
|
||||||
|
if (oilCheckMethodRaw && !oilCheckMethod) throw new Error("Ölprüfung: Ungültige Messmethode.");
|
||||||
|
|
||||||
|
const oilCheckRunMinutes = integer(formData, "oil_check_run_minutes", { min: 0, max: 120 });
|
||||||
|
const oilCheckWaitMinMinutes = integer(formData, "oil_check_wait_min_minutes", { min: 0, max: 120 });
|
||||||
|
const oilCheckWaitMaxMinutes = integer(formData, "oil_check_wait_max_minutes", { min: 0, max: 120 });
|
||||||
|
|
||||||
|
if (oilCheckTemp !== "warm") {
|
||||||
|
if (oilCheckRunMinutes != null || oilCheckWaitMinMinutes != null || oilCheckWaitMaxMinutes != null) {
|
||||||
|
throw new Error("Lauf- und Wartezeiten beim Ölstand sind nur bei warmem Motor zulässig.");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if ((oilCheckWaitMinMinutes == null) !== (oilCheckWaitMaxMinutes == null)) {
|
||||||
|
throw new Error("Bitte Min- und Max-Wartezeit gemeinsam angeben.");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
oilCheckWaitMinMinutes != null &&
|
||||||
|
oilCheckWaitMaxMinutes != null &&
|
||||||
|
oilCheckWaitMinMinutes > oilCheckWaitMaxMinutes
|
||||||
|
) {
|
||||||
|
throw new Error("Die Min-Wartezeit darf nicht größer als die Max-Wartezeit sein.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
oilCheckTemp,
|
||||||
|
oilCheckRunMinutes,
|
||||||
|
oilCheckWaitMinMinutes,
|
||||||
|
oilCheckWaitMaxMinutes,
|
||||||
|
oilCheckMethod,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function maintenanceSpecsFields(formData: FormData) {
|
||||||
|
const oilType = text(formData, "oil_type", { max: 80 }) || null;
|
||||||
|
const oilCapacityLiters = decimal(formData, "oil_capacity_liters", { min: 0.1, max: 20 });
|
||||||
|
const oilChecks = oilCheckFields(formData);
|
||||||
|
const chainTensionMinMm = integer(formData, "chain_tension_min_mm", { min: 1, max: 100 });
|
||||||
|
const chainTensionMaxMm = integer(formData, "chain_tension_max_mm", { min: 1, max: 100 });
|
||||||
|
if ((chainTensionMinMm == null) !== (chainTensionMaxMm == null)) {
|
||||||
|
throw new Error("Bitte Kettenspannung von/bis gemeinsam angeben.");
|
||||||
|
}
|
||||||
|
if (chainTensionMinMm != null && chainTensionMaxMm != null && chainTensionMinMm > chainTensionMaxMm) {
|
||||||
|
throw new Error("Kettenspannung: Von darf nicht größer als Bis sein.");
|
||||||
|
}
|
||||||
|
if ((oilType == null) !== (oilCapacityLiters == null)) {
|
||||||
|
throw new Error("Bitte Öltyp und Ölmenge zusammen angeben.");
|
||||||
|
}
|
||||||
|
if ((oilChecks.oilCheckTemp == null) !== (oilChecks.oilCheckMethod == null)) {
|
||||||
|
throw new Error("Bitte bei der Ölprüfung Temperatur und Methode zusammen angeben.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
tirePressureFrontBar: decimal(formData, "tire_pressure_front_bar", { min: 0.5, max: 6 }),
|
||||||
|
tirePressureRearBar: decimal(formData, "tire_pressure_rear_bar", { min: 0.5, max: 6 }),
|
||||||
|
chainTensionMinMm,
|
||||||
|
chainTensionMaxMm,
|
||||||
|
oilType,
|
||||||
|
oilCapacityLiters,
|
||||||
|
...oilChecks,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function motorcycleFields(formData: FormData) {
|
function motorcycleFields(formData: FormData) {
|
||||||
|
const maintenanceSpecs = maintenanceSpecsFields(formData);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
nickname: text(formData, "nickname", { required: true, max: 80 }),
|
nickname: text(formData, "nickname", { required: true, max: 80 }),
|
||||||
brand: text(formData, "brand", { required: true, max: 80 }),
|
brand: text(formData, "brand", { required: true, max: 80 }),
|
||||||
@@ -44,10 +116,50 @@ function motorcycleFields(formData: FormData) {
|
|||||||
vin: text(formData, "vin", { max: 50 }) || null,
|
vin: text(formData, "vin", { max: 50 }) || null,
|
||||||
plate: text(formData, "plate", { max: 30 }) || null,
|
plate: text(formData, "plate", { max: 30 }) || null,
|
||||||
currentKm: integer(formData, "current_km", { required: true, min: 0, max: 10_000_000 }) ?? 0,
|
currentKm: integer(formData, "current_km", { required: true, min: 0, max: 10_000_000 }) ?? 0,
|
||||||
|
...maintenanceSpecs,
|
||||||
notes: text(formData, "notes", { max: 5000 }) || null,
|
notes: text(formData, "notes", { max: 5000 }) || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateMotorcycleMaintenanceAction(formData: FormData) {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
initDatabase();
|
||||||
|
let id = 0;
|
||||||
|
try {
|
||||||
|
id = idValue(formData);
|
||||||
|
ensureMotorcycleAccess(user, id);
|
||||||
|
const value = maintenanceSpecsFields(formData);
|
||||||
|
const result = db
|
||||||
|
.prepare(
|
||||||
|
`UPDATE motorcycles
|
||||||
|
SET tire_pressure_front_bar = ?, tire_pressure_rear_bar = ?,
|
||||||
|
chain_tension_min_mm = ?, chain_tension_max_mm = ?,
|
||||||
|
oil_type = ?, oil_capacity_liters = ?,
|
||||||
|
oil_check_temp = ?, oil_check_run_minutes = ?, oil_check_wait_min_minutes = ?, oil_check_wait_max_minutes = ?, oil_check_method = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
value.tirePressureFrontBar,
|
||||||
|
value.tirePressureRearBar,
|
||||||
|
value.chainTensionMinMm,
|
||||||
|
value.chainTensionMaxMm,
|
||||||
|
value.oilType,
|
||||||
|
value.oilCapacityLiters,
|
||||||
|
value.oilCheckTemp,
|
||||||
|
value.oilCheckRunMinutes,
|
||||||
|
value.oilCheckWaitMinMinutes,
|
||||||
|
value.oilCheckWaitMaxMinutes,
|
||||||
|
value.oilCheckMethod,
|
||||||
|
id,
|
||||||
|
);
|
||||||
|
if (result.changes === 0) throw new Error("Motorrad wurde nicht gefunden.");
|
||||||
|
redirect(`/motorcycles/${id}/maintenance?success=${encodeURIComponent("Wartungswerte gespeichert.")}`);
|
||||||
|
} catch (error) {
|
||||||
|
unstable_rethrow(error);
|
||||||
|
go(id ? `/motorcycles/${id}/maintenance/edit` : "/", "error", errorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function createMotorcycleAction(formData: FormData) {
|
export async function createMotorcycleAction(formData: FormData) {
|
||||||
const user = await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
initDatabase();
|
initDatabase();
|
||||||
@@ -56,8 +168,12 @@ export async function createMotorcycleAction(formData: FormData) {
|
|||||||
const result = db
|
const result = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO motorcycles
|
`INSERT INTO motorcycles
|
||||||
(nickname, brand, model, year, vin, plate, current_km, notes, created_by)
|
(nickname, brand, model, year, vin, plate, current_km,
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
tire_pressure_front_bar, tire_pressure_rear_bar, chain_tension_min_mm, chain_tension_max_mm,
|
||||||
|
oil_type, oil_capacity_liters,
|
||||||
|
oil_check_temp, oil_check_run_minutes, oil_check_wait_min_minutes, oil_check_wait_max_minutes, oil_check_method,
|
||||||
|
notes, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ,
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
value.nickname,
|
value.nickname,
|
||||||
@@ -67,6 +183,17 @@ export async function createMotorcycleAction(formData: FormData) {
|
|||||||
value.vin,
|
value.vin,
|
||||||
value.plate,
|
value.plate,
|
||||||
value.currentKm,
|
value.currentKm,
|
||||||
|
value.tirePressureFrontBar,
|
||||||
|
value.tirePressureRearBar,
|
||||||
|
value.chainTensionMinMm,
|
||||||
|
value.chainTensionMaxMm,
|
||||||
|
value.oilType,
|
||||||
|
value.oilCapacityLiters,
|
||||||
|
value.oilCheckTemp,
|
||||||
|
value.oilCheckRunMinutes,
|
||||||
|
value.oilCheckWaitMinMinutes,
|
||||||
|
value.oilCheckWaitMaxMinutes,
|
||||||
|
value.oilCheckMethod,
|
||||||
value.notes,
|
value.notes,
|
||||||
user.id,
|
user.id,
|
||||||
);
|
);
|
||||||
@@ -78,17 +205,23 @@ export async function createMotorcycleAction(formData: FormData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateMotorcycleAction(formData: FormData) {
|
export async function updateMotorcycleAction(formData: FormData) {
|
||||||
await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
initDatabase();
|
initDatabase();
|
||||||
let id = 0;
|
let id = 0;
|
||||||
try {
|
try {
|
||||||
id = idValue(formData);
|
id = idValue(formData);
|
||||||
|
ensureMotorcycleAccess(user, id);
|
||||||
const value = motorcycleFields(formData);
|
const value = motorcycleFields(formData);
|
||||||
const result = db
|
const result = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`UPDATE motorcycles
|
`UPDATE motorcycles
|
||||||
SET nickname = ?, brand = ?, model = ?, year = ?, vin = ?, plate = ?,
|
SET nickname = ?, brand = ?, model = ?, year = ?, vin = ?, plate = ?,
|
||||||
current_km = ?, notes = ?
|
current_km = ?,
|
||||||
|
tire_pressure_front_bar = ?, tire_pressure_rear_bar = ?,
|
||||||
|
chain_tension_min_mm = ?, chain_tension_max_mm = ?,
|
||||||
|
oil_type = ?, oil_capacity_liters = ?,
|
||||||
|
oil_check_temp = ?, oil_check_run_minutes = ?, oil_check_wait_min_minutes = ?, oil_check_wait_max_minutes = ?, oil_check_method = ?,
|
||||||
|
notes = ?
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
@@ -99,6 +232,17 @@ export async function updateMotorcycleAction(formData: FormData) {
|
|||||||
value.vin,
|
value.vin,
|
||||||
value.plate,
|
value.plate,
|
||||||
value.currentKm,
|
value.currentKm,
|
||||||
|
value.tirePressureFrontBar,
|
||||||
|
value.tirePressureRearBar,
|
||||||
|
value.chainTensionMinMm,
|
||||||
|
value.chainTensionMaxMm,
|
||||||
|
value.oilType,
|
||||||
|
value.oilCapacityLiters,
|
||||||
|
value.oilCheckTemp,
|
||||||
|
value.oilCheckRunMinutes,
|
||||||
|
value.oilCheckWaitMinMinutes,
|
||||||
|
value.oilCheckWaitMaxMinutes,
|
||||||
|
value.oilCheckMethod,
|
||||||
value.notes,
|
value.notes,
|
||||||
id,
|
id,
|
||||||
);
|
);
|
||||||
@@ -111,10 +255,17 @@ export async function updateMotorcycleAction(formData: FormData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteMotorcycleAction(formData: FormData) {
|
export async function deleteMotorcycleAction(formData: FormData) {
|
||||||
await requireAdminUser();
|
const user = await requireSessionUser();
|
||||||
initDatabase();
|
initDatabase();
|
||||||
try {
|
try {
|
||||||
const id = idValue(formData);
|
const id = idValue(formData);
|
||||||
|
const owner = db
|
||||||
|
.prepare("SELECT created_by FROM motorcycles WHERE id = ?")
|
||||||
|
.get(id) as { created_by: number } | undefined;
|
||||||
|
if (!owner) throw new Error("Motorrad wurde nicht gefunden.");
|
||||||
|
if (user.role !== "admin" && owner.created_by !== user.id) {
|
||||||
|
throw new Error("Du darfst dieses Moped nicht löschen.");
|
||||||
|
}
|
||||||
const files = db
|
const files = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT a.stored_name
|
`SELECT a.stored_name
|
||||||
@@ -163,7 +314,7 @@ export async function createMaintenanceAction(formData: FormData) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
motorcycleId = idValue(formData, "motorcycle_id");
|
motorcycleId = idValue(formData, "motorcycle_id");
|
||||||
if (!motorcycleExists(motorcycleId)) throw new Error("Motorrad wurde nicht gefunden.");
|
ensureMotorcycleAccess(user, motorcycleId);
|
||||||
const type = maintenanceType(formData);
|
const type = maintenanceType(formData);
|
||||||
const title = text(formData, "title", { required: true, max: 120 });
|
const title = text(formData, "title", { required: true, max: 120 });
|
||||||
const description = text(formData, "description", { max: 5000 }) || null;
|
const description = text(formData, "description", { max: 5000 }) || null;
|
||||||
@@ -245,7 +396,7 @@ export async function createMaintenanceAction(formData: FormData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteMaintenanceAction(formData: FormData) {
|
export async function deleteMaintenanceAction(formData: FormData) {
|
||||||
await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
initDatabase();
|
initDatabase();
|
||||||
let motorcycleId = 0;
|
let motorcycleId = 0;
|
||||||
try {
|
try {
|
||||||
@@ -260,6 +411,7 @@ export async function deleteMaintenanceAction(formData: FormData) {
|
|||||||
.get(id) as { motorcycle_id: number; stored_name: string | null } | undefined;
|
.get(id) as { motorcycle_id: number; stored_name: string | null } | undefined;
|
||||||
if (!row) throw new Error("Wartungseintrag wurde nicht gefunden.");
|
if (!row) throw new Error("Wartungseintrag wurde nicht gefunden.");
|
||||||
motorcycleId = row.motorcycle_id;
|
motorcycleId = row.motorcycle_id;
|
||||||
|
ensureMotorcycleAccess(user, motorcycleId);
|
||||||
db.prepare("DELETE FROM maintenance_events WHERE id = ?").run(id);
|
db.prepare("DELETE FROM maintenance_events WHERE id = ?").run(id);
|
||||||
if (row.stored_name) {
|
if (row.stored_name) {
|
||||||
const path = join(getUploadDir(), basename(row.stored_name));
|
const path = join(getUploadDir(), basename(row.stored_name));
|
||||||
@@ -273,17 +425,20 @@ export async function deleteMaintenanceAction(formData: FormData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createTripAction(formData: FormData) {
|
export async function createTripAction(formData: FormData) {
|
||||||
await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
initDatabase();
|
initDatabase();
|
||||||
let motorcycleId = 0;
|
let motorcycleId = 0;
|
||||||
try {
|
try {
|
||||||
motorcycleId = idValue(formData, "motorcycle_id");
|
motorcycleId = idValue(formData, "motorcycle_id");
|
||||||
if (!motorcycleExists(motorcycleId)) throw new Error("Motorrad wurde nicht gefunden.");
|
ensureMotorcycleAccess(user, motorcycleId);
|
||||||
const tripDate = dateValue(formData, "trip_date", true);
|
const tripDate = dateValue(formData, "trip_date", true);
|
||||||
const driverUserId = integer(formData, "driver_user_id", { required: true, min: 1 });
|
const driverUserId = integer(formData, "driver_user_id", { required: true, min: 1 });
|
||||||
const startKm = integer(formData, "start_km", { required: true, min: 0, max: 10_000_000 });
|
const startKm = integer(formData, "start_km", { required: true, min: 0, max: 10_000_000 });
|
||||||
const endKm = integer(formData, "end_km", { required: true, min: 0, max: 10_000_000 });
|
const endKm = integer(formData, "end_km", { required: true, min: 0, max: 10_000_000 });
|
||||||
if (driverUserId == null || !db.prepare("SELECT 1 FROM users WHERE id = ?").get(driverUserId)) {
|
if (
|
||||||
|
driverUserId == null ||
|
||||||
|
!db.prepare("SELECT 1 FROM users WHERE id = ? AND active = 1").get(driverUserId)
|
||||||
|
) {
|
||||||
throw new Error("Fahrer wurde nicht gefunden.");
|
throw new Error("Fahrer wurde nicht gefunden.");
|
||||||
}
|
}
|
||||||
if (startKm == null || endKm == null || endKm < startKm) {
|
if (startKm == null || endKm == null || endKm < startKm) {
|
||||||
@@ -310,7 +465,7 @@ export async function createTripAction(formData: FormData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteTripAction(formData: FormData) {
|
export async function deleteTripAction(formData: FormData) {
|
||||||
await requireSessionUser();
|
const user = await requireSessionUser();
|
||||||
initDatabase();
|
initDatabase();
|
||||||
let motorcycleId = 0;
|
let motorcycleId = 0;
|
||||||
try {
|
try {
|
||||||
@@ -320,6 +475,7 @@ export async function deleteTripAction(formData: FormData) {
|
|||||||
| undefined;
|
| undefined;
|
||||||
if (!row) throw new Error("Fahrt wurde nicht gefunden.");
|
if (!row) throw new Error("Fahrt wurde nicht gefunden.");
|
||||||
motorcycleId = row.motorcycle_id;
|
motorcycleId = row.motorcycle_id;
|
||||||
|
ensureMotorcycleAccess(user, motorcycleId);
|
||||||
db.prepare("DELETE FROM trips WHERE id = ?").run(id);
|
db.prepare("DELETE FROM trips WHERE id = ?").run(id);
|
||||||
redirect(`/motorcycles/${motorcycleId}?success=${encodeURIComponent("Fahrt gelöscht.")}`);
|
redirect(`/motorcycles/${motorcycleId}?success=${encodeURIComponent("Fahrt gelöscht.")}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -327,3 +483,61 @@ export async function deleteTripAction(formData: FormData) {
|
|||||||
go(motorcycleId ? `/motorcycles/${motorcycleId}` : "/", "error", errorMessage(error));
|
go(motorcycleId ? `/motorcycles/${motorcycleId}` : "/", "error", errorMessage(error));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function shareMotorcycleAction(formData: FormData) {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
initDatabase();
|
||||||
|
let motorcycleId = 0;
|
||||||
|
try {
|
||||||
|
motorcycleId = idValue(formData, "motorcycle_id");
|
||||||
|
const username = text(formData, "username", { required: true, max: 100 });
|
||||||
|
if (!canManageShares(user, motorcycleId)) {
|
||||||
|
throw new Error("Du darfst die Freigaben dieses Mopeds nicht verwalten.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = db
|
||||||
|
.prepare("SELECT id, active FROM users WHERE name = ? COLLATE NOCASE")
|
||||||
|
.all(username) as { id: number; active: number }[];
|
||||||
|
if (target.length !== 1 || target[0].active !== 1) throw new Error("Benutzer wurde nicht gefunden.");
|
||||||
|
const targetUserId = target[0].id;
|
||||||
|
|
||||||
|
const owner = db
|
||||||
|
.prepare("SELECT created_by FROM motorcycles WHERE id = ?")
|
||||||
|
.get(motorcycleId) as { created_by: number } | undefined;
|
||||||
|
if (!owner) throw new Error("Motorrad wurde nicht gefunden.");
|
||||||
|
if (owner.created_by === targetUserId) throw new Error("Der Besitzer hat bereits Zugriff.");
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`INSERT OR IGNORE INTO motorcycle_shares (motorcycle_id, user_id, shared_by)
|
||||||
|
VALUES (?, ?, ?)`,
|
||||||
|
).run(motorcycleId, targetUserId, user.id);
|
||||||
|
|
||||||
|
redirect(`/motorcycles/${motorcycleId}?success=${encodeURIComponent("Freigabe gespeichert.")}`);
|
||||||
|
} catch (error) {
|
||||||
|
unstable_rethrow(error);
|
||||||
|
go(motorcycleId ? `/motorcycles/${motorcycleId}` : "/", "error", errorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeMotorcycleShareAction(formData: FormData) {
|
||||||
|
const user = await requireSessionUser();
|
||||||
|
initDatabase();
|
||||||
|
let motorcycleId = 0;
|
||||||
|
try {
|
||||||
|
motorcycleId = idValue(formData, "motorcycle_id");
|
||||||
|
const targetUserId = integer(formData, "user_id", { required: true, min: 1 });
|
||||||
|
if (targetUserId == null) throw new Error("Benutzer wurde nicht gefunden.");
|
||||||
|
if (!canManageShares(user, motorcycleId)) {
|
||||||
|
throw new Error("Du darfst die Freigaben dieses Mopeds nicht verwalten.");
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare("DELETE FROM motorcycle_shares WHERE motorcycle_id = ? AND user_id = ?").run(
|
||||||
|
motorcycleId,
|
||||||
|
targetUserId,
|
||||||
|
);
|
||||||
|
redirect(`/motorcycles/${motorcycleId}?success=${encodeURIComponent("Freigabe entfernt.")}`);
|
||||||
|
} catch (error) {
|
||||||
|
unstable_rethrow(error);
|
||||||
|
go(motorcycleId ? `/motorcycles/${motorcycleId}` : "/", "error", errorMessage(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+31
-22
@@ -1,10 +1,11 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { hashSync } from "bcryptjs";
|
import { hashSync } from "bcryptjs";
|
||||||
import { redirect, unstable_rethrow } from "next/navigation";
|
import { redirect, unstable_rethrow } from "next/navigation";
|
||||||
import { requireAdminUser } from "@/lib/auth";
|
import { requireAdminUser } from "@/lib/auth";
|
||||||
import { db, initDatabase, transaction } from "@/lib/db";
|
import { db, initDatabase, transaction } from "@/lib/db";
|
||||||
import { emailValue, idValue, passwordValue, roleValue, text } from "@/lib/validation";
|
import { idValue, passwordValue, roleValue, text } from "@/lib/validation";
|
||||||
import type { UserRole } from "@/lib/types";
|
import type { UserRole } from "@/lib/types";
|
||||||
|
|
||||||
function go(kind: "error" | "success", message: string): never {
|
function go(kind: "error" | "success", message: string): never {
|
||||||
@@ -13,7 +14,7 @@ function go(kind: "error" | "success", message: string): never {
|
|||||||
|
|
||||||
function errorMessage(error: unknown) {
|
function errorMessage(error: unknown) {
|
||||||
if (error instanceof Error && error.message.includes("UNIQUE constraint failed")) {
|
if (error instanceof Error && error.message.includes("UNIQUE constraint failed")) {
|
||||||
return "Diese E-Mail-Adresse ist bereits vergeben.";
|
return "Dieser Benutzername ist bereits vergeben.";
|
||||||
}
|
}
|
||||||
return error instanceof Error ? error.message : "Die Aktion konnte nicht ausgeführt werden.";
|
return error instanceof Error ? error.message : "Die Aktion konnte nicht ausgeführt werden.";
|
||||||
}
|
}
|
||||||
@@ -23,12 +24,16 @@ export async function createUserAction(formData: FormData) {
|
|||||||
initDatabase();
|
initDatabase();
|
||||||
try {
|
try {
|
||||||
const name = text(formData, "name", { required: true, max: 100 });
|
const name = text(formData, "name", { required: true, max: 100 });
|
||||||
const email = emailValue(formData);
|
const existing = db
|
||||||
|
.prepare("SELECT 1 FROM users WHERE name = ? COLLATE NOCASE AND active = 1")
|
||||||
|
.get(name);
|
||||||
|
if (existing) throw new Error("Dieser Benutzername ist bereits vergeben.");
|
||||||
const password = passwordValue(formData);
|
const password = passwordValue(formData);
|
||||||
const role = roleValue(formData);
|
const role = roleValue(formData);
|
||||||
|
const internalEmail = `user-${name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "konto"}-${randomUUID().slice(0, 8)}@local.invalid`;
|
||||||
db.prepare(
|
db.prepare(
|
||||||
"INSERT INTO users (name, email, password_hash, role) VALUES (?, ?, ?, ?)",
|
"INSERT INTO users (name, email, password_hash, role) VALUES (?, ?, ?, ?)",
|
||||||
).run(name, email, hashSync(password, 12), role);
|
).run(name, internalEmail, hashSync(password, 12), role);
|
||||||
go("success", "Benutzer wurde angelegt.");
|
go("success", "Benutzer wurde angelegt.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
unstable_rethrow(error);
|
unstable_rethrow(error);
|
||||||
@@ -43,20 +48,20 @@ export async function changeUserRoleAction(formData: FormData) {
|
|||||||
const id = idValue(formData);
|
const id = idValue(formData);
|
||||||
const role = roleValue(formData);
|
const role = roleValue(formData);
|
||||||
transaction(() => {
|
transaction(() => {
|
||||||
const target = db.prepare("SELECT role FROM users WHERE id = ?").get(id) as
|
const target = db.prepare("SELECT role FROM users WHERE id = ? AND active = 1").get(id) as
|
||||||
| { role: UserRole }
|
| { role: UserRole }
|
||||||
| undefined;
|
| undefined;
|
||||||
if (!target) throw new Error("Benutzer wurde nicht gefunden.");
|
if (!target) throw new Error("Benutzer wurde nicht gefunden.");
|
||||||
if (target.role === "admin" && role === "member") {
|
if (target.role === "admin" && role === "member") {
|
||||||
const count = db.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'admin'").get() as {
|
const count = db
|
||||||
count: number;
|
.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'admin' AND active = 1")
|
||||||
};
|
.get() as { count: number };
|
||||||
if (count.count <= 1) throw new Error("Der letzte Administrator kann nicht herabgestuft werden.");
|
if (count.count <= 1) throw new Error("Der letzte Administrator kann nicht herabgestuft werden.");
|
||||||
if (id === currentUser.id) {
|
if (id === currentUser.id) {
|
||||||
throw new Error("Die eigene Administratorrolle kann nicht herabgestuft werden.");
|
throw new Error("Die eigene Administratorrolle kann nicht herabgestuft werden.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
db.prepare("UPDATE users SET role = ? WHERE id = ?").run(role, id);
|
db.prepare("UPDATE users SET role = ? WHERE id = ? AND active = 1").run(role, id);
|
||||||
});
|
});
|
||||||
go("success", "Rolle wurde geändert.");
|
go("success", "Rolle wurde geändert.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -72,26 +77,30 @@ export async function deleteUserAction(formData: FormData) {
|
|||||||
const id = idValue(formData);
|
const id = idValue(formData);
|
||||||
if (id === currentUser.id) throw new Error("Das aktuell angemeldete Konto kann nicht gelöscht werden.");
|
if (id === currentUser.id) throw new Error("Das aktuell angemeldete Konto kann nicht gelöscht werden.");
|
||||||
transaction(() => {
|
transaction(() => {
|
||||||
const target = db.prepare("SELECT role FROM users WHERE id = ?").get(id) as
|
const target = db
|
||||||
|
.prepare("SELECT role FROM users WHERE id = ? AND active = 1")
|
||||||
|
.get(id) as
|
||||||
| { role: UserRole }
|
| { role: UserRole }
|
||||||
| undefined;
|
| undefined;
|
||||||
if (!target) throw new Error("Benutzer wurde nicht gefunden.");
|
if (!target) throw new Error("Benutzer wurde nicht gefunden.");
|
||||||
if (target.role === "admin") {
|
if (target.role === "admin") {
|
||||||
const count = db.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'admin'").get() as {
|
const count = db
|
||||||
count: number;
|
.prepare("SELECT COUNT(*) AS count FROM users WHERE role = 'admin' AND active = 1")
|
||||||
};
|
.get() as { count: number };
|
||||||
if (count.count <= 1) throw new Error("Der letzte Administrator kann nicht gelöscht werden.");
|
if (count.count <= 1) throw new Error("Der letzte Administrator kann nicht gelöscht werden.");
|
||||||
}
|
}
|
||||||
try {
|
db.prepare(
|
||||||
db.prepare("DELETE FROM users WHERE id = ?").run(id);
|
`UPDATE users
|
||||||
} catch (error) {
|
SET name = name || ' (gelöscht)',
|
||||||
if (error instanceof Error && error.message.includes("FOREIGN KEY")) {
|
email = ?,
|
||||||
throw new Error("Benutzer mit vorhandenen Einträgen kann nicht gelöscht werden.");
|
password_hash = ?,
|
||||||
}
|
role = 'member',
|
||||||
throw error;
|
active = 0,
|
||||||
}
|
deleted_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = ? AND active = 1`,
|
||||||
|
).run(`deleted-${id}-${randomUUID()}@invalid.local`, hashSync(randomUUID(), 12), id);
|
||||||
});
|
});
|
||||||
go("success", "Benutzer wurde gelöscht.");
|
go("success", "Benutzerkonto wurde gelöscht; vorhandene Einträge bleiben erhalten.");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
unstable_rethrow(error);
|
unstable_rethrow(error);
|
||||||
go("error", errorMessage(error));
|
go("error", errorMessage(error));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { NextRequest } from "next/server";
|
|||||||
import { getSessionUser } from "@/lib/auth";
|
import { getSessionUser } from "@/lib/auth";
|
||||||
import { db, initDatabase } from "@/lib/db";
|
import { db, initDatabase } from "@/lib/db";
|
||||||
import { getUploadDir } from "@/lib/env";
|
import { getUploadDir } from "@/lib/env";
|
||||||
|
import { motorcycleAccessFilter } from "@/lib/motorcycle-access";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
@@ -26,9 +27,16 @@ export async function GET(_request: NextRequest, ctx: RouteContext<"/attachments
|
|||||||
}
|
}
|
||||||
|
|
||||||
initDatabase();
|
initDatabase();
|
||||||
|
const access = motorcycleAccessFilter(user, "m");
|
||||||
const row = db
|
const row = db
|
||||||
.prepare("SELECT stored_name, original_name, mime_type FROM attachments WHERE id = ?")
|
.prepare(
|
||||||
.get(attachmentId) as AttachmentRow | undefined;
|
`SELECT a.stored_name, a.original_name, a.mime_type
|
||||||
|
FROM attachments a
|
||||||
|
JOIN maintenance_events e ON e.id = a.maintenance_event_id
|
||||||
|
JOIN motorcycles m ON m.id = e.motorcycle_id
|
||||||
|
WHERE a.id = ? AND ${access.clause}`,
|
||||||
|
)
|
||||||
|
.get(attachmentId, ...access.params) as AttachmentRow | undefined;
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return new Response("Anhang wurde nicht gefunden.", { status: 404 });
|
return new Response("Anhang wurde nicht gefunden.", { status: 404 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ export default async function LoginPage({
|
|||||||
<Flash error={queryMessage(params, "error")} />
|
<Flash error={queryMessage(params, "error")} />
|
||||||
<form action={loginAction} className="stack-form">
|
<form action={loginAction} className="stack-form">
|
||||||
<label>
|
<label>
|
||||||
E-Mail
|
Benutzername
|
||||||
<input name="email" type="email" autoComplete="email" required autoFocus />
|
<input name="username" autoComplete="username" required autoFocus />
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Passwort
|
Passwort
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function today() {
|
|||||||
|
|
||||||
export function MaintenanceForm({ motorcycleId }: { motorcycleId: number }) {
|
export function MaintenanceForm({ motorcycleId }: { motorcycleId: number }) {
|
||||||
return (
|
return (
|
||||||
<form action={createMaintenanceAction} className="panel form-grid" encType="multipart/form-data">
|
<form action={createMaintenanceAction} className="panel form-grid">
|
||||||
<input type="hidden" name="motorcycle_id" value={motorcycleId} />
|
<input type="hidden" name="motorcycle_id" value={motorcycleId} />
|
||||||
<label>
|
<label>
|
||||||
Art *
|
Art *
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { createMotorcycleAction, updateMotorcycleAction } from "@/app/actions/motorcycles";
|
import { createMotorcycleAction, updateMotorcycleAction } from "@/app/actions/motorcycles";
|
||||||
|
|
||||||
type MotorcycleValue = {
|
type MotorcycleValue = {
|
||||||
@@ -9,10 +12,25 @@ type MotorcycleValue = {
|
|||||||
vin: string | null;
|
vin: string | null;
|
||||||
plate: string | null;
|
plate: string | null;
|
||||||
current_km: number;
|
current_km: number;
|
||||||
|
tire_pressure_front_bar: number | null;
|
||||||
|
tire_pressure_rear_bar: number | null;
|
||||||
|
chain_tension_min_mm: number | null;
|
||||||
|
chain_tension_max_mm: number | null;
|
||||||
|
oil_type: string | null;
|
||||||
|
oil_capacity_liters: number | null;
|
||||||
|
oil_check_temp: "warm" | "cold" | null;
|
||||||
|
oil_check_run_minutes: number | null;
|
||||||
|
oil_check_wait_min_minutes: number | null;
|
||||||
|
oil_check_wait_max_minutes: number | null;
|
||||||
|
oil_check_method: "dipstick" | "sight_glass" | null;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MotorcycleForm({ motorcycle }: { motorcycle?: MotorcycleValue }) {
|
export function MotorcycleForm({ motorcycle }: { motorcycle?: MotorcycleValue }) {
|
||||||
|
const [oilCheckTemp, setOilCheckTemp] = useState<"" | "warm" | "cold">(
|
||||||
|
motorcycle?.oil_check_temp ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={motorcycle ? updateMotorcycleAction : createMotorcycleAction} className="panel form-grid">
|
<form action={motorcycle ? updateMotorcycleAction : createMotorcycleAction} className="panel form-grid">
|
||||||
{motorcycle && <input type="hidden" name="id" value={motorcycle.id} />}
|
{motorcycle && <input type="hidden" name="id" value={motorcycle.id} />}
|
||||||
@@ -57,6 +75,135 @@ export function MotorcycleForm({ motorcycle }: { motorcycle?: MotorcycleValue })
|
|||||||
defaultValue={motorcycle?.current_km ?? 0}
|
defaultValue={motorcycle?.current_km ?? 0}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Reifendruck vorne (bar)
|
||||||
|
<input
|
||||||
|
name="tire_pressure_front_bar"
|
||||||
|
type="number"
|
||||||
|
min="0.5"
|
||||||
|
max="6"
|
||||||
|
step="0.1"
|
||||||
|
defaultValue={motorcycle?.tire_pressure_front_bar ?? ""}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Reifendruck hinten (bar)
|
||||||
|
<input
|
||||||
|
name="tire_pressure_rear_bar"
|
||||||
|
type="number"
|
||||||
|
min="0.5"
|
||||||
|
max="6"
|
||||||
|
step="0.1"
|
||||||
|
defaultValue={motorcycle?.tire_pressure_rear_bar ?? ""}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Kettenspannung von (mm)
|
||||||
|
<input
|
||||||
|
name="chain_tension_min_mm"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
defaultValue={motorcycle?.chain_tension_min_mm ?? ""}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Kettenspannung bis (mm)
|
||||||
|
<input
|
||||||
|
name="chain_tension_max_mm"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
defaultValue={motorcycle?.chain_tension_max_mm ?? ""}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Benötigtes Öl
|
||||||
|
<input
|
||||||
|
name="oil_type"
|
||||||
|
maxLength={80}
|
||||||
|
placeholder="z. B. 10W-40, JASO MA2"
|
||||||
|
defaultValue={motorcycle?.oil_type ?? ""}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Ölmenge gesamt (Liter)
|
||||||
|
<input
|
||||||
|
name="oil_capacity_liters"
|
||||||
|
type="number"
|
||||||
|
min="0.1"
|
||||||
|
max="20"
|
||||||
|
step="0.1"
|
||||||
|
defaultValue={motorcycle?.oil_capacity_liters ?? ""}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Öl prüfen: Motorzustand
|
||||||
|
<select
|
||||||
|
name="oil_check_temp"
|
||||||
|
value={oilCheckTemp}
|
||||||
|
onChange={(event) => setOilCheckTemp(event.target.value as "" | "warm" | "cold")}
|
||||||
|
>
|
||||||
|
<option value="">Bitte wählen</option>
|
||||||
|
<option value="cold">Kalt</option>
|
||||||
|
<option value="warm">Warm</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{oilCheckTemp === "warm" && (
|
||||||
|
<label>
|
||||||
|
Öl prüfen: Motor vorher laufen (Minuten)
|
||||||
|
<input
|
||||||
|
name="oil_check_run_minutes"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="120"
|
||||||
|
step="1"
|
||||||
|
defaultValue={motorcycle?.oil_check_run_minutes ?? ""}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{oilCheckTemp === "warm" && (
|
||||||
|
<label>
|
||||||
|
Öl prüfen: Wartezeit min (Minuten)
|
||||||
|
<input
|
||||||
|
name="oil_check_wait_min_minutes"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="120"
|
||||||
|
step="1"
|
||||||
|
defaultValue={motorcycle?.oil_check_wait_min_minutes ?? ""}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{oilCheckTemp === "warm" && (
|
||||||
|
<label>
|
||||||
|
Öl prüfen: Wartezeit max (Minuten)
|
||||||
|
<input
|
||||||
|
name="oil_check_wait_max_minutes"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="120"
|
||||||
|
step="1"
|
||||||
|
defaultValue={motorcycle?.oil_check_wait_max_minutes ?? ""}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label>
|
||||||
|
Öl prüfen: Methode
|
||||||
|
<select name="oil_check_method" defaultValue={motorcycle?.oil_check_method ?? ""}>
|
||||||
|
<option value="">Bitte wählen</option>
|
||||||
|
<option value="dipstick">Peilstab</option>
|
||||||
|
<option value="sight_glass">Schauglas</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{oilCheckTemp === "warm" && (
|
||||||
|
<p className="muted span-2">Bei warmem Motor kannst du Laufzeit und ein Min/Max-Wartefenster hinterlegen.</p>
|
||||||
|
)}
|
||||||
|
{oilCheckTemp === "cold" && (
|
||||||
|
<p className="muted span-2">Bei kaltem Motor wird ohne Wartezeit gemessen.</p>
|
||||||
|
)}
|
||||||
<label className="span-2">
|
<label className="span-2">
|
||||||
Notizen
|
Notizen
|
||||||
<textarea name="notes" rows={5} maxLength={5000} defaultValue={motorcycle?.notes ?? ""} />
|
<textarea name="notes" rows={5} maxLength={5000} defaultValue={motorcycle?.notes ?? ""} />
|
||||||
|
|||||||
+9
-7
@@ -52,7 +52,7 @@ export async function getSessionUser(): Promise<SessionUser | null> {
|
|||||||
const payload = result.payload as unknown as SessionPayload;
|
const payload = result.payload as unknown as SessionPayload;
|
||||||
initDatabase();
|
initDatabase();
|
||||||
const user = db
|
const user = db
|
||||||
.prepare("SELECT id, name, email, role FROM users WHERE id = ?")
|
.prepare("SELECT id, name, email, role FROM users WHERE id = ? AND active = 1")
|
||||||
.get(Number(payload.id)) as
|
.get(Number(payload.id)) as
|
||||||
| { id: number; name: string; email: string; role: UserRole }
|
| { id: number; name: string; email: string; role: UserRole }
|
||||||
| undefined;
|
| undefined;
|
||||||
@@ -74,13 +74,15 @@ export async function requireAdminUser() {
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function authenticateUser(email: string, password: string): SessionUser | null {
|
export function authenticateUser(username: string, password: string): SessionUser | null {
|
||||||
initDatabase();
|
initDatabase();
|
||||||
const user = db
|
const users = db
|
||||||
.prepare("SELECT id, name, email, role, password_hash FROM users WHERE email = ?")
|
.prepare(
|
||||||
.get(email.trim().toLowerCase()) as
|
"SELECT id, name, email, role, password_hash FROM users WHERE name = ? COLLATE NOCASE AND active = 1 ORDER BY id ASC",
|
||||||
| (SessionUser & { password_hash: string })
|
)
|
||||||
| undefined;
|
.all(username.trim()) as (SessionUser & { password_hash: string })[];
|
||||||
|
if (users.length !== 1) return null;
|
||||||
|
const user = users[0];
|
||||||
|
|
||||||
if (!user || !compareSync(password, user.password_hash)) return null;
|
if (!user || !compareSync(password, user.password_hash)) return null;
|
||||||
return { id: user.id, name: user.name, email: user.email, role: user.role };
|
return { id: user.id, name: user.name, email: user.email, role: user.role };
|
||||||
|
|||||||
+149
-8
@@ -1,4 +1,5 @@
|
|||||||
import { DatabaseSync } from "node:sqlite";
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { hashSync } from "bcryptjs";
|
import { hashSync } from "bcryptjs";
|
||||||
import { getDatabasePath, getUploadDir } from "./env";
|
import { getDatabasePath, getUploadDir } from "./env";
|
||||||
|
|
||||||
@@ -27,6 +28,8 @@ function ensureSchema() {
|
|||||||
email TEXT NOT NULL COLLATE NOCASE UNIQUE,
|
email TEXT NOT NULL COLLATE NOCASE UNIQUE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
role TEXT NOT NULL CHECK (role IN ('admin', 'member')),
|
role TEXT NOT NULL CHECK (role IN ('admin', 'member')),
|
||||||
|
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
|
||||||
|
deleted_at TEXT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
) STRICT;
|
) STRICT;
|
||||||
|
|
||||||
@@ -39,11 +42,32 @@ function ensureSchema() {
|
|||||||
vin TEXT,
|
vin TEXT,
|
||||||
plate TEXT,
|
plate TEXT,
|
||||||
current_km INTEGER NOT NULL DEFAULT 0 CHECK (current_km >= 0),
|
current_km INTEGER NOT NULL DEFAULT 0 CHECK (current_km >= 0),
|
||||||
|
tire_pressure_front_bar REAL CHECK (tire_pressure_front_bar IS NULL OR tire_pressure_front_bar > 0),
|
||||||
|
tire_pressure_rear_bar REAL CHECK (tire_pressure_rear_bar IS NULL OR tire_pressure_rear_bar > 0),
|
||||||
|
chain_tension_mm INTEGER CHECK (chain_tension_mm IS NULL OR chain_tension_mm >= 0),
|
||||||
|
chain_tension_min_mm INTEGER CHECK (chain_tension_min_mm IS NULL OR chain_tension_min_mm >= 0),
|
||||||
|
chain_tension_max_mm INTEGER CHECK (chain_tension_max_mm IS NULL OR chain_tension_max_mm >= 0),
|
||||||
|
oil_type TEXT,
|
||||||
|
oil_capacity_liters REAL CHECK (oil_capacity_liters IS NULL OR oil_capacity_liters > 0),
|
||||||
|
oil_check_temp TEXT CHECK (oil_check_temp IS NULL OR oil_check_temp IN ('warm', 'cold')),
|
||||||
|
oil_check_run_minutes INTEGER CHECK (oil_check_run_minutes IS NULL OR oil_check_run_minutes >= 0),
|
||||||
|
oil_check_wait_min_minutes INTEGER CHECK (oil_check_wait_min_minutes IS NULL OR oil_check_wait_min_minutes >= 0),
|
||||||
|
oil_check_wait_max_minutes INTEGER CHECK (oil_check_wait_max_minutes IS NULL OR oil_check_wait_max_minutes >= 0),
|
||||||
|
oil_check_wait_minutes INTEGER CHECK (oil_check_wait_minutes IS NULL OR oil_check_wait_minutes >= 0),
|
||||||
|
oil_check_method TEXT CHECK (oil_check_method IS NULL OR oil_check_method IN ('dipstick', 'sight_glass')),
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
) STRICT;
|
) STRICT;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS motorcycle_shares (
|
||||||
|
motorcycle_id INTEGER NOT NULL REFERENCES motorcycles(id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
shared_by INTEGER NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (motorcycle_id, user_id)
|
||||||
|
) STRICT;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS maintenance_events (
|
CREATE TABLE IF NOT EXISTS maintenance_events (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
motorcycle_id INTEGER NOT NULL REFERENCES motorcycles(id) ON DELETE CASCADE,
|
motorcycle_id INTEGER NOT NULL REFERENCES motorcycles(id) ON DELETE CASCADE,
|
||||||
@@ -87,11 +111,95 @@ function ensureSchema() {
|
|||||||
ON maintenance_events(motorcycle_id, type, event_date DESC);
|
ON maintenance_events(motorcycle_id, type, event_date DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_trips_motorcycle
|
CREATE INDEX IF NOT EXISTS idx_trips_motorcycle
|
||||||
ON trips(motorcycle_id, trip_date DESC, id DESC);
|
ON trips(motorcycle_id, trip_date DESC, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_motorcycle_shares_user
|
||||||
|
ON motorcycle_shares(user_id, motorcycle_id);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const userColumns = db.prepare("PRAGMA table_info(users)").all() as { name: string }[];
|
||||||
|
if (!userColumns.some((column) => column.name === "active")) {
|
||||||
|
db.exec("ALTER TABLE users ADD COLUMN active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1))");
|
||||||
|
}
|
||||||
|
if (!userColumns.some((column) => column.name === "deleted_at")) {
|
||||||
|
db.exec("ALTER TABLE users ADD COLUMN deleted_at TEXT");
|
||||||
|
}
|
||||||
|
|
||||||
|
const motorcycleColumns = db.prepare("PRAGMA table_info(motorcycles)").all() as { name: string }[];
|
||||||
|
if (!motorcycleColumns.some((column) => column.name === "tire_pressure_front_bar")) {
|
||||||
|
db.exec("ALTER TABLE motorcycles ADD COLUMN tire_pressure_front_bar REAL");
|
||||||
|
}
|
||||||
|
if (!motorcycleColumns.some((column) => column.name === "tire_pressure_rear_bar")) {
|
||||||
|
db.exec("ALTER TABLE motorcycles ADD COLUMN tire_pressure_rear_bar REAL");
|
||||||
|
}
|
||||||
|
if (!motorcycleColumns.some((column) => column.name === "chain_tension_mm")) {
|
||||||
|
db.exec("ALTER TABLE motorcycles ADD COLUMN chain_tension_mm INTEGER");
|
||||||
|
}
|
||||||
|
if (!motorcycleColumns.some((column) => column.name === "chain_tension_min_mm")) {
|
||||||
|
db.exec("ALTER TABLE motorcycles ADD COLUMN chain_tension_min_mm INTEGER");
|
||||||
|
}
|
||||||
|
if (!motorcycleColumns.some((column) => column.name === "chain_tension_max_mm")) {
|
||||||
|
db.exec("ALTER TABLE motorcycles ADD COLUMN chain_tension_max_mm INTEGER");
|
||||||
|
}
|
||||||
|
if (!motorcycleColumns.some((column) => column.name === "oil_type")) {
|
||||||
|
db.exec("ALTER TABLE motorcycles ADD COLUMN oil_type TEXT");
|
||||||
|
}
|
||||||
|
if (!motorcycleColumns.some((column) => column.name === "oil_capacity_liters")) {
|
||||||
|
db.exec("ALTER TABLE motorcycles ADD COLUMN oil_capacity_liters REAL");
|
||||||
|
}
|
||||||
|
if (!motorcycleColumns.some((column) => column.name === "oil_check_temp")) {
|
||||||
|
db.exec("ALTER TABLE motorcycles ADD COLUMN oil_check_temp TEXT");
|
||||||
|
}
|
||||||
|
if (!motorcycleColumns.some((column) => column.name === "oil_check_run_minutes")) {
|
||||||
|
db.exec("ALTER TABLE motorcycles ADD COLUMN oil_check_run_minutes INTEGER");
|
||||||
|
}
|
||||||
|
if (!motorcycleColumns.some((column) => column.name === "oil_check_wait_min_minutes")) {
|
||||||
|
db.exec("ALTER TABLE motorcycles ADD COLUMN oil_check_wait_min_minutes INTEGER");
|
||||||
|
}
|
||||||
|
if (!motorcycleColumns.some((column) => column.name === "oil_check_wait_max_minutes")) {
|
||||||
|
db.exec("ALTER TABLE motorcycles ADD COLUMN oil_check_wait_max_minutes INTEGER");
|
||||||
|
}
|
||||||
|
if (!motorcycleColumns.some((column) => column.name === "oil_check_wait_minutes")) {
|
||||||
|
db.exec("ALTER TABLE motorcycles ADD COLUMN oil_check_wait_minutes INTEGER");
|
||||||
|
}
|
||||||
|
if (!motorcycleColumns.some((column) => column.name === "oil_check_method")) {
|
||||||
|
db.exec("ALTER TABLE motorcycles ADD COLUMN oil_check_method TEXT");
|
||||||
|
}
|
||||||
|
|
||||||
|
const motorcycleColumnsAfter = db.prepare("PRAGMA table_info(motorcycles)").all() as { name: string }[];
|
||||||
|
if (
|
||||||
|
motorcycleColumnsAfter.some((column) => column.name === "chain_tension_mm") &&
|
||||||
|
motorcycleColumnsAfter.some((column) => column.name === "chain_tension_min_mm") &&
|
||||||
|
motorcycleColumnsAfter.some((column) => column.name === "chain_tension_max_mm")
|
||||||
|
) {
|
||||||
|
db.exec(`
|
||||||
|
UPDATE motorcycles
|
||||||
|
SET chain_tension_min_mm = chain_tension_mm,
|
||||||
|
chain_tension_max_mm = chain_tension_mm
|
||||||
|
WHERE chain_tension_mm IS NOT NULL
|
||||||
|
AND chain_tension_min_mm IS NULL
|
||||||
|
AND chain_tension_max_mm IS NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
motorcycleColumnsAfter.some((column) => column.name === "oil_check_wait_minutes") &&
|
||||||
|
motorcycleColumnsAfter.some((column) => column.name === "oil_check_wait_min_minutes") &&
|
||||||
|
motorcycleColumnsAfter.some((column) => column.name === "oil_check_wait_max_minutes")
|
||||||
|
) {
|
||||||
|
db.exec(`
|
||||||
|
UPDATE motorcycles
|
||||||
|
SET oil_check_wait_min_minutes = oil_check_wait_minutes,
|
||||||
|
oil_check_wait_max_minutes = oil_check_wait_minutes
|
||||||
|
WHERE oil_check_wait_minutes IS NOT NULL
|
||||||
|
AND oil_check_wait_min_minutes IS NULL
|
||||||
|
AND oil_check_wait_max_minutes IS NULL
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.exec("CREATE INDEX IF NOT EXISTS idx_users_active ON users(active, name COLLATE NOCASE)");
|
||||||
}
|
}
|
||||||
|
|
||||||
function ensureInitialAdmin() {
|
function ensureInitialAdmin() {
|
||||||
const row = db.prepare("SELECT COUNT(*) AS count FROM users").get() as
|
const row = db.prepare("SELECT COUNT(*) AS count FROM users WHERE active = 1").get() as
|
||||||
| { count: number }
|
| { count: number }
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
@@ -99,13 +207,12 @@ function ensureInitialAdmin() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const email = process.env.ADMIN_EMAIL?.trim().toLowerCase();
|
|
||||||
const password = process.env.ADMIN_PASSWORD;
|
const password = process.env.ADMIN_PASSWORD;
|
||||||
const name = process.env.ADMIN_NAME?.trim();
|
const name = process.env.ADMIN_NAME?.trim();
|
||||||
|
|
||||||
if (!email || !password || !name) {
|
if (!password || !name) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Keine Benutzer vorhanden. ADMIN_EMAIL, ADMIN_PASSWORD und ADMIN_NAME müssen gesetzt sein.",
|
"Keine Benutzer vorhanden. ADMIN_PASSWORD und ADMIN_NAME müssen gesetzt sein.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,18 +220,52 @@ function ensureInitialAdmin() {
|
|||||||
throw new Error("ADMIN_PASSWORD muss mindestens 10 Zeichen lang sein.");
|
throw new Error("ADMIN_PASSWORD muss mindestens 10 Zeichen lang sein.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const adminSlug =
|
||||||
|
name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "konto";
|
||||||
db.prepare(
|
db.prepare(
|
||||||
"INSERT INTO users (name, email, password_hash, role) VALUES (?, ?, ?, 'admin')",
|
"INSERT INTO users (name, email, password_hash, role) VALUES (?, ?, ?, 'admin')",
|
||||||
).run(name, email, hashSync(password, 12));
|
).run(name, `admin-${adminSlug}@local.invalid`, hashSync(password, 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureReferencedUsersExist() {
|
||||||
|
const missingUserIds = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT DISTINCT refs.user_id AS id
|
||||||
|
FROM (
|
||||||
|
SELECT created_by AS user_id FROM motorcycles
|
||||||
|
UNION
|
||||||
|
SELECT created_by AS user_id FROM maintenance_events
|
||||||
|
UNION
|
||||||
|
SELECT driver_user_id AS user_id FROM trips
|
||||||
|
) refs
|
||||||
|
LEFT JOIN users u ON u.id = refs.user_id
|
||||||
|
WHERE refs.user_id IS NOT NULL AND u.id IS NULL
|
||||||
|
ORDER BY refs.user_id`,
|
||||||
|
)
|
||||||
|
.all() as { id: number }[];
|
||||||
|
|
||||||
|
const insertMissingUser = db.prepare(
|
||||||
|
`INSERT INTO users (id, name, email, password_hash, role, active, deleted_at)
|
||||||
|
VALUES (?, ?, ?, ?, 'member', 0, CURRENT_TIMESTAMP)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const missing of missingUserIds) {
|
||||||
|
insertMissingUser.run(
|
||||||
|
missing.id,
|
||||||
|
`Gelöschter Benutzer #${missing.id}`,
|
||||||
|
`deleted-${missing.id}-${randomUUID()}@invalid.local`,
|
||||||
|
hashSync(randomUUID(), 12),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function initDatabase() {
|
export function initDatabase() {
|
||||||
|
getUploadDir();
|
||||||
|
ensureSchema();
|
||||||
if (globalThis.__mopedDatabaseInitialized) {
|
if (globalThis.__mopedDatabaseInitialized) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
ensureReferencedUsersExist();
|
||||||
getUploadDir();
|
|
||||||
ensureSchema();
|
|
||||||
ensureInitialAdmin();
|
ensureInitialAdmin();
|
||||||
globalThis.__mopedDatabaseInitialized = true;
|
globalThis.__mopedDatabaseInitialized = true;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -12,7 +12,8 @@ export function getAuthSecret() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getDataDir() {
|
export function getDataDir() {
|
||||||
const dataDir = process.env.DATA_DIR ?? path.join(process.cwd(), "data");
|
const dataDir =
|
||||||
|
process.env.DATA_DIR ?? path.join(/* turbopackIgnore: true */ process.cwd(), "data");
|
||||||
mkdirSync(dataDir, { recursive: true });
|
mkdirSync(dataDir, { recursive: true });
|
||||||
return dataDir;
|
return dataDir;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,20 @@ export function integer(
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function decimal(
|
||||||
|
formData: FormData,
|
||||||
|
key: string,
|
||||||
|
options: { required?: boolean; min?: number; max?: number } = {},
|
||||||
|
) {
|
||||||
|
const raw = String(formData.get(key) ?? "").trim().replace(",", ".");
|
||||||
|
if (!raw && !options.required) return null;
|
||||||
|
const value = Number(raw);
|
||||||
|
if (!Number.isFinite(value)) throw new Error(`${key} muss eine Zahl sein.`);
|
||||||
|
if (options.min != null && value < options.min) throw new Error(`${key} ist zu klein.`);
|
||||||
|
if (options.max != null && value > options.max) throw new Error(`${key} ist zu groß.`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
export function dateValue(formData: FormData, key: string, required = false) {
|
export function dateValue(formData: FormData, key: string, required = false) {
|
||||||
const value = text(formData, key, { required, max: 10 });
|
const value = text(formData, key, { required, max: 10 });
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user