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:
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
import NavBar from './components/NavBar.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NavBar />
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
}
|
||||
|
||||
export async function login(username: string, password: string): Promise<LoginResponse> {
|
||||
const resp = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
if (!resp.ok) {
|
||||
throw new Error('Invalid credentials')
|
||||
}
|
||||
return resp.json()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
const TOKEN_KEY = 'auth_token'
|
||||
|
||||
export function bearerHeader(): Record<string, string> {
|
||||
try {
|
||||
const token = localStorage.getItem(TOKEN_KEY)
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export function handleUnauthorized(): never {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
window.location.href = '/login'
|
||||
throw new Error('Unauthorized')
|
||||
}
|
||||
@@ -62,8 +62,11 @@ export function imageUrl(fruitId: number, imageId: number): string {
|
||||
return `/api/v1/fruits/${fruitId}/images/${imageId}`
|
||||
}
|
||||
|
||||
import { bearerHeader, handleUnauthorized } from './authHeader'
|
||||
|
||||
async function checkOk(res: Response): Promise<Response> {
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) handleUnauthorized()
|
||||
let msg = `${res.status}`
|
||||
try {
|
||||
const body = await res.json()
|
||||
@@ -100,7 +103,7 @@ export async function getFruit(id: number): Promise<Fruit> {
|
||||
export async function createFruit(dto: FruitWriteDTO): Promise<Fruit> {
|
||||
const res = await fetch('/api/v1/fruits', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...bearerHeader() },
|
||||
body: JSON.stringify(dto),
|
||||
})
|
||||
return (await checkOk(res)).json()
|
||||
@@ -109,14 +112,14 @@ export async function createFruit(dto: FruitWriteDTO): Promise<Fruit> {
|
||||
export async function updateFruit(id: number, dto: FruitWriteDTO): Promise<Fruit> {
|
||||
const res = await fetch(`/api/v1/fruits/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...bearerHeader() },
|
||||
body: JSON.stringify(dto),
|
||||
})
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function deleteFruit(id: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/fruits/${id}`, { method: 'DELETE' })
|
||||
const res = await fetch(`/api/v1/fruits/${id}`, { method: 'DELETE', headers: bearerHeader() })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
@@ -136,11 +139,11 @@ export async function addImage(
|
||||
form.append('image_type', imageType)
|
||||
if (title) form.append('title', title)
|
||||
// Do NOT set Content-Type — browser sets it with the correct multipart boundary
|
||||
const res = await fetch(`/api/v1/fruits/${fruitId}/images`, { method: 'POST', body: form })
|
||||
const res = await fetch(`/api/v1/fruits/${fruitId}/images`, { method: 'POST', body: form, headers: bearerHeader() })
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function deleteImage(fruitId: number, imageId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/fruits/${fruitId}/images/${imageId}`, { method: 'DELETE' })
|
||||
const res = await fetch(`/api/v1/fruits/${fruitId}/images/${imageId}`, { method: 'DELETE', headers: bearerHeader() })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
@@ -54,8 +54,11 @@ export function pubFruitImageUrl(pubId: number, imgId: number): string {
|
||||
return `/api/v1/publications/${pubId}/fruit-images/${imgId}`
|
||||
}
|
||||
|
||||
import { bearerHeader, handleUnauthorized } from './authHeader'
|
||||
|
||||
async function checkOk(res: Response): Promise<Response> {
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) handleUnauthorized()
|
||||
let msg = `${res.status}`
|
||||
try {
|
||||
const body = await res.json()
|
||||
@@ -83,7 +86,7 @@ export async function getPublication(id: number): Promise<Publication> {
|
||||
export async function createPublication(dto: PublicationWriteDTO): Promise<Publication> {
|
||||
const res = await fetch('/api/v1/publications', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...bearerHeader() },
|
||||
body: JSON.stringify(dto),
|
||||
})
|
||||
return (await checkOk(res)).json()
|
||||
@@ -92,26 +95,26 @@ export async function createPublication(dto: PublicationWriteDTO): Promise<Publi
|
||||
export async function updatePublication(id: number, dto: PublicationWriteDTO): Promise<Publication> {
|
||||
const res = await fetch(`/api/v1/publications/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...bearerHeader() },
|
||||
body: JSON.stringify(dto),
|
||||
})
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function deletePublication(id: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${id}`, { method: 'DELETE' })
|
||||
const res = await fetch(`/api/v1/publications/${id}`, { method: 'DELETE', headers: bearerHeader() })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function uploadCoverImage(pubId: number, file: File): Promise<void> {
|
||||
const form = new FormData()
|
||||
form.append('image', file)
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/image`, { method: 'POST', body: form })
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/image`, { method: 'POST', body: form, headers: bearerHeader() })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function deleteCoverImage(pubId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/image`, { method: 'DELETE' })
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/image`, { method: 'DELETE', headers: bearerHeader() })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
@@ -123,14 +126,14 @@ export async function listPubFruits(pubId: number): Promise<PublicationFruit[]>
|
||||
export async function linkFruit(pubId: number, fruitId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruits`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { 'Content-Type': 'application/json', ...bearerHeader() },
|
||||
body: JSON.stringify({ fruit_id: fruitId }),
|
||||
})
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
export async function unlinkFruit(pubId: number, fruitId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruits/${fruitId}`, { method: 'DELETE' })
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruits/${fruitId}`, { method: 'DELETE', headers: bearerHeader() })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
@@ -143,12 +146,12 @@ export async function uploadDescription(pubId: number, fruitId: number, file: Fi
|
||||
const form = new FormData()
|
||||
form.append('pdf', file)
|
||||
form.append('fruit_id', String(fruitId))
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/descriptions`, { method: 'POST', body: form })
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/descriptions`, { method: 'POST', body: form, headers: bearerHeader() })
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function deleteDescription(pubId: number, descId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/descriptions/${descId}`, { method: 'DELETE' })
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/descriptions/${descId}`, { method: 'DELETE', headers: bearerHeader() })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
@@ -161,12 +164,12 @@ export async function uploadPubFruitImage(pubId: number, fruitId: number, file:
|
||||
const form = new FormData()
|
||||
form.append('image', file)
|
||||
form.append('fruit_id', String(fruitId))
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruit-images`, { method: 'POST', body: form })
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruit-images`, { method: 'POST', body: form, headers: bearerHeader() })
|
||||
return (await checkOk(res)).json()
|
||||
}
|
||||
|
||||
export async function deletePubFruitImage(pubId: number, imgId: number): Promise<void> {
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruit-images/${imgId}`, { method: 'DELETE' })
|
||||
const res = await fetch(`/api/v1/publications/${pubId}/fruit-images/${imgId}`, { method: 'DELETE', headers: bearerHeader() })
|
||||
await checkOk(res)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
|
||||
function logout() {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="bg-white border-b border-gray-200 px-6 py-3 flex items-center gap-6 text-sm">
|
||||
<RouterLink to="/fruits" class="font-medium text-gray-700 hover:text-green-700">Früchte</RouterLink>
|
||||
<RouterLink to="/publications" class="font-medium text-gray-700 hover:text-green-700">Publikationen</RouterLink>
|
||||
<RouterLink v-if="auth.isLoggedIn" to="/admin" class="font-medium text-gray-700 hover:text-green-700">Administration</RouterLink>
|
||||
<div class="ml-auto">
|
||||
<button
|
||||
v-if="auth.isLoggedIn"
|
||||
@click="logout"
|
||||
class="text-gray-600 hover:text-red-600 transition-colors"
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
<RouterLink
|
||||
v-else
|
||||
to="/login"
|
||||
class="text-gray-600 hover:text-green-700 transition-colors"
|
||||
>
|
||||
Anmelden
|
||||
</RouterLink>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
import HelloWorld from '../views/HelloWorld.vue'
|
||||
import FruitList from '../views/FruitList.vue'
|
||||
import FruitCreate from '../views/FruitCreate.vue'
|
||||
@@ -6,6 +7,8 @@ import FruitDetail from '../views/FruitDetail.vue'
|
||||
import PublicationList from '../views/PublicationList.vue'
|
||||
import PublicationCreate from '../views/PublicationCreate.vue'
|
||||
import PublicationDetail from '../views/PublicationDetail.vue'
|
||||
import LoginView from '../views/LoginView.vue'
|
||||
import AdminView from '../views/AdminView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -14,14 +17,19 @@ const router = createRouter({
|
||||
path: '/',
|
||||
component: HelloWorld,
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
component: LoginView,
|
||||
},
|
||||
{
|
||||
path: '/fruits',
|
||||
component: FruitList,
|
||||
},
|
||||
{
|
||||
// /fruits/new must come before /:id so vue-router v5 doesn't capture "new" as a param
|
||||
// /fruits/new must come before /:id so vue-router doesn't capture "new" as a param
|
||||
path: '/fruits/new',
|
||||
component: FruitCreate,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/fruits/:id',
|
||||
@@ -36,13 +44,28 @@ const router = createRouter({
|
||||
// /publications/new must come before /:id
|
||||
path: '/publications/new',
|
||||
component: PublicationCreate,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/publications/:id',
|
||||
component: PublicationDetail,
|
||||
props: true,
|
||||
},
|
||||
{
|
||||
path: '/admin',
|
||||
component: AdminView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (to.meta.requiresAuth) {
|
||||
const auth = useAuthStore()
|
||||
if (!auth.isLoggedIn) {
|
||||
return { path: '/login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
import { useAuthStore } from './authStore'
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
|
||||
function makeLocalStorageMock() {
|
||||
const store: Record<string, string> = {}
|
||||
return {
|
||||
getItem: (k: string) => store[k] ?? null,
|
||||
setItem: (k: string, v: string) => { store[k] = v },
|
||||
removeItem: (k: string) => { delete store[k] },
|
||||
}
|
||||
}
|
||||
|
||||
describe('authStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('localStorage', makeLocalStorageMock())
|
||||
setActivePinia(createPinia())
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('starts unauthenticated when localStorage is empty', () => {
|
||||
const store = useAuthStore()
|
||||
expect(store.isLoggedIn).toBe(false)
|
||||
expect(store.token).toBeNull()
|
||||
})
|
||||
|
||||
it('restores auth state from localStorage on init', () => {
|
||||
localStorage.setItem('auth_token', 'my-stored-token')
|
||||
const store = useAuthStore()
|
||||
expect(store.isLoggedIn).toBe(true)
|
||||
expect(store.token).toBe('my-stored-token')
|
||||
})
|
||||
|
||||
it('login sets token and isLoggedIn on success', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ token: 'new-jwt-token' }),
|
||||
})
|
||||
|
||||
const store = useAuthStore()
|
||||
await store.login('admin', 'secret')
|
||||
|
||||
expect(store.isLoggedIn).toBe(true)
|
||||
expect(store.token).toBe('new-jwt-token')
|
||||
expect(localStorage.getItem('auth_token')).toBe('new-jwt-token')
|
||||
})
|
||||
|
||||
it('login throws on invalid credentials', async () => {
|
||||
fetchMock.mockResolvedValueOnce({ ok: false })
|
||||
|
||||
const store = useAuthStore()
|
||||
await expect(store.login('admin', 'wrong')).rejects.toThrow()
|
||||
expect(store.isLoggedIn).toBe(false)
|
||||
})
|
||||
|
||||
it('logout clears token and isLoggedIn', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ token: 'tok' }),
|
||||
})
|
||||
const store = useAuthStore()
|
||||
await store.login('admin', 'secret')
|
||||
store.logout()
|
||||
|
||||
expect(store.isLoggedIn).toBe(false)
|
||||
expect(store.token).toBeNull()
|
||||
expect(localStorage.getItem('auth_token')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { login as apiLogin } from '../api/auth'
|
||||
|
||||
const TOKEN_KEY = 'auth_token'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY))
|
||||
const isLoggedIn = computed(() => token.value !== null)
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
const resp = await apiLogin(username, password)
|
||||
token.value = resp.token
|
||||
localStorage.setItem(TOKEN_KEY, resp.token)
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = null
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
return { token, isLoggedIn, login, logout }
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { bearerHeader, handleUnauthorized } from '../api/authHeader'
|
||||
|
||||
const running = ref(false)
|
||||
const result = ref<string | null>(null)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function backfill() {
|
||||
running.value = true
|
||||
result.value = null
|
||||
error.value = null
|
||||
try {
|
||||
const resp = await fetch('/api/v1/admin/backfill-thumbnails', {
|
||||
method: 'POST',
|
||||
headers: bearerHeader(),
|
||||
})
|
||||
if (resp.status === 401) handleUnauthorized()
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
result.value = JSON.stringify(data, null, 2)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Fehler'
|
||||
} finally {
|
||||
running.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-6 max-w-xl mx-auto">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-6">Administration</h1>
|
||||
|
||||
<section class="border rounded p-4 bg-gray-50 space-y-3">
|
||||
<h2 class="font-semibold text-gray-800">Thumbnails rückfüllen</h2>
|
||||
<p class="text-sm text-gray-600">Erzeugt Thumbnails für alle Bilder, die noch keines haben.</p>
|
||||
<button
|
||||
@click="backfill"
|
||||
:disabled="running"
|
||||
class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50 transition-colors text-sm"
|
||||
>
|
||||
{{ running ? 'Läuft...' : 'Starten' }}
|
||||
</button>
|
||||
<div v-if="error" class="text-red-600 text-sm">{{ error }}</div>
|
||||
<pre v-if="result" class="text-xs bg-white border rounded p-2 overflow-auto">{{ result }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useFruitStore } from '../stores/fruitStore'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
import { addImage, deleteImage, FRUIT_TYPES } from '../api/fruits'
|
||||
import type { Fruit } from '../api/fruits'
|
||||
import { getFruitDescriptions, getFruitPublicationImages } from '../api/publications'
|
||||
@@ -10,6 +11,7 @@ import type { PublicationDescription, PublicationFruitImage } from '../api/publi
|
||||
const props = defineProps<{ id: string }>()
|
||||
const router = useRouter()
|
||||
const store = useFruitStore()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const editing = ref(false)
|
||||
const editName = ref('')
|
||||
@@ -134,7 +136,7 @@ async function removeImage(imageId: number) {
|
||||
<div v-else>
|
||||
<div class="flex items-start justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">{{ store.current.name }}</h1>
|
||||
<div class="flex gap-2">
|
||||
<div v-if="auth.isLoggedIn" class="flex gap-2">
|
||||
<button
|
||||
v-if="!editing"
|
||||
@click="editing = true"
|
||||
@@ -229,7 +231,7 @@ async function removeImage(imageId: number) {
|
||||
<div v-else class="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6">
|
||||
<div v-for="img in store.current.images" :key="img.id" class="relative group border rounded overflow-hidden">
|
||||
<img :src="img.url" :alt="img.title ?? img.image_type" class="w-full h-32 object-cover" />
|
||||
<div class="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div v-if="auth.isLoggedIn" class="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
@click="removeImage(img.id)"
|
||||
class="bg-red-600 text-white rounded-full w-6 h-6 text-xs flex items-center justify-center hover:bg-red-700"
|
||||
@@ -242,7 +244,7 @@ async function removeImage(imageId: number) {
|
||||
</div>
|
||||
|
||||
<!-- Upload form -->
|
||||
<div class="border rounded p-4 bg-gray-50">
|
||||
<div v-if="auth.isLoggedIn" class="border rounded p-4 bg-gray-50">
|
||||
<h3 class="text-sm font-medium text-gray-700 mb-3">Bild hochladen</h3>
|
||||
<div v-if="uploadError" class="mb-3 text-red-600 text-sm">{{ uploadError }}</div>
|
||||
<div class="space-y-3">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { onMounted, onUnmounted, computed, ref } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useFruitStore } from '../stores/fruitStore'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
import FruitCard from '../components/FruitCard.vue'
|
||||
|
||||
const SEARCH_TYPES = [
|
||||
@@ -30,6 +31,7 @@ const SEARCH_TYPES = [
|
||||
]
|
||||
|
||||
const store = useFruitStore()
|
||||
const auth = useAuthStore()
|
||||
const nameInput = ref(store.searchName)
|
||||
const typeSelect = ref(store.searchType)
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -76,6 +78,7 @@ function onTypeChange() {
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Früchte</h1>
|
||||
<RouterLink
|
||||
v-if="auth.isLoggedIn"
|
||||
to="/fruits/new"
|
||||
class="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700 transition-colors"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const error = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
async function submit() {
|
||||
error.value = null
|
||||
loading.value = true
|
||||
try {
|
||||
await auth.login(username.value, password.value)
|
||||
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/fruits'
|
||||
router.push(redirect)
|
||||
} catch {
|
||||
error.value = 'Ungültige Zugangsdaten'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-screen items-center justify-center bg-gray-50">
|
||||
<form
|
||||
@submit.prevent="submit"
|
||||
class="w-full max-w-sm bg-white rounded shadow p-8 space-y-5"
|
||||
>
|
||||
<h1 class="text-xl font-bold text-gray-900">Anmelden</h1>
|
||||
|
||||
<div v-if="error" class="p-3 bg-red-50 border border-red-300 rounded text-red-700 text-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Benutzername</label>
|
||||
<input
|
||||
v-model="username"
|
||||
type="text"
|
||||
required
|
||||
autocomplete="username"
|
||||
class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Passwort</label>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="w-full border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="w-full bg-green-600 text-white py-2 rounded hover:bg-green-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{{ loading ? 'Anmelden...' : 'Anmelden' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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"
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { usePublicationStore } from '../stores/publicationStore'
|
||||
import { useAuthStore } from '../stores/authStore'
|
||||
|
||||
const store = usePublicationStore()
|
||||
const auth = useAuthStore()
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
@@ -18,6 +20,7 @@ onMounted(async () => {
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold">Publikationen</h1>
|
||||
<router-link
|
||||
v-if="auth.isLoggedIn"
|
||||
to="/publications/new"
|
||||
class="rounded bg-green-600 px-4 py-2 text-white hover:bg-green-700"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user