feat: protect write endpoints with JWT auth (story #08)

Add bcrypt user file auth, JWT middleware on all write routes, Pinia
authStore with localStorage persistence, login view with redirect
support, and v-if guards on all CRUD controls and admin nav link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 11:18:42 +02:00
co-authored by Claude Sonnet 4.6
parent 649c9687ac
commit 1b381d9385
32 changed files with 895 additions and 56 deletions
+12 -9
View File
@@ -2,6 +2,7 @@
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { usePublicationStore } from '../stores/publicationStore'
import { useAuthStore } from '../stores/authStore'
import {
uploadCoverImage,
deleteCoverImage,
@@ -23,6 +24,7 @@ import ImageOverlayDrawer from '../components/ImageOverlayDrawer.vue'
const props = defineProps<{ id: string }>()
const router = useRouter()
const store = usePublicationStore()
const auth = useAuthStore()
// Edit state
const editing = ref(false)
@@ -212,7 +214,7 @@ async function deleteFruitImg(imgId: number) {
<p class="text-sm text-gray-500 font-mono">{{ store.current.osdb_pub_id }}</p>
<p v-if="store.current.author" class="text-gray-700">{{ store.current.author }}</p>
</div>
<div class="flex gap-2">
<div v-if="auth.isLoggedIn" class="flex gap-2">
<button
class="rounded border px-3 py-1 text-sm hover:bg-gray-50"
@click="editing = !editing"
@@ -230,7 +232,7 @@ async function deleteFruitImg(imgId: number) {
</div>
<!-- Edit form -->
<div v-if="editing" class="mb-6 rounded border p-4">
<div v-if="auth.isLoggedIn && editing" class="mb-6 rounded border p-4">
<div v-if="serverError" class="mb-3 text-red-600">{{ serverError }}</div>
<div class="space-y-3">
<div>
@@ -266,6 +268,7 @@ async function deleteFruitImg(imgId: number) {
@click="overlayOpen = true"
/>
<button
v-if="auth.isLoggedIn"
class="rounded border px-2 py-1 text-sm text-red-600 hover:bg-red-50"
@click="removeCover"
>
@@ -274,7 +277,7 @@ async function deleteFruitImg(imgId: number) {
</div>
<div v-else class="mb-3 text-sm text-gray-400">Kein Titelbild vorhanden.</div>
<div v-if="coverError" class="mb-2 text-red-600 text-sm">{{ coverError }}</div>
<div class="flex items-center gap-2">
<div v-if="auth.isLoggedIn" class="flex items-center gap-2">
<input
type="file"
accept="image/*"
@@ -297,13 +300,13 @@ async function deleteFruitImg(imgId: number) {
<ul v-if="fruits.length" class="mb-3 space-y-1">
<li v-for="f in fruits" :key="f.fruit_id" class="flex items-center justify-between rounded border px-3 py-1 text-sm">
<span>{{ f.name }} <span class="text-gray-400 font-mono text-xs">({{ f.osdb_number }})</span></span>
<button class="text-red-600 hover:underline text-xs" @click="doUnlinkFruit(f.fruit_id)">
<button v-if="auth.isLoggedIn" class="text-red-600 hover:underline text-xs" @click="doUnlinkFruit(f.fruit_id)">
Entfernen
</button>
</li>
</ul>
<div v-else class="mb-3 text-sm text-gray-400">Keine Früchte verknüpft.</div>
<div class="flex gap-2">
<div v-if="auth.isLoggedIn" class="flex gap-2">
<input
v-model="linkFruitId"
type="number"
@@ -332,14 +335,14 @@ async function deleteFruitImg(imgId: number) {
<a :href="d.url" target="_blank" class="text-blue-600 hover:underline">
{{ d.fruit_name || `Frucht #${d.fruit_id}` }}
</a>
<button class="text-red-600 hover:underline text-xs" @click="deleteDesc(d.id)">
<button v-if="auth.isLoggedIn" class="text-red-600 hover:underline text-xs" @click="deleteDesc(d.id)">
Löschen
</button>
</li>
</ul>
<div v-else class="mb-3 text-sm text-gray-400">Keine Beschreibungen vorhanden.</div>
<div v-if="descError" class="mb-2 text-red-600 text-sm">{{ descError }}</div>
<div class="flex flex-wrap items-center gap-2">
<div v-if="auth.isLoggedIn" class="flex flex-wrap items-center gap-2">
<input
v-model="descFruitId"
type="number"
@@ -377,14 +380,14 @@ async function deleteFruitImg(imgId: number) {
class="h-24 w-24 rounded border object-cover"
/>
<span class="text-xs text-gray-500">Frucht #{{ img.fruit_id }}</span>
<button class="text-xs text-red-600 hover:underline" @click="deleteFruitImg(img.id)">
<button v-if="auth.isLoggedIn" class="text-xs text-red-600 hover:underline" @click="deleteFruitImg(img.id)">
Löschen
</button>
</div>
</div>
<div v-else class="mb-3 text-sm text-gray-400">Keine Fruchtbilder vorhanden.</div>
<div v-if="fruitImgError" class="mb-2 text-red-600 text-sm">{{ fruitImgError }}</div>
<div class="flex flex-wrap items-center gap-2">
<div v-if="auth.isLoggedIn" class="flex flex-wrap items-center gap-2">
<input
v-model="fruitImgFruitId"
type="number"