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
+2 -2
View File
@@ -1,9 +1,9 @@
## Taiga
- TAIGA_URL: https://taiga.db-extern.de
- TAIGA_PROJECT_SLUG: obstsortendatenbank
- TAIGA_PROJECT_SLUG: obstsortendatenbank-1
## Stories
Before implementing a story: read its spec file in docs/planning/specs/ first.
Before starting a story: read its spec file in docs/planning/specs/ first.
## Branching
Stories developed sequentially; prior story branch may not be merged to main yet.
+1
View File
@@ -11,6 +11,7 @@ frontend/.vite/
# Environment — never commit real credentials
.env
backend/.env
users.env
# Import data — large binary files, not committed
03-data/
+9 -5
View File
@@ -9,15 +9,19 @@ A full-stack fruit-variety database (Go + Vue 3).
- Administrators can bulk-import the legacy fruit database from XML using `scripts/import_fruits.py`, and then import all publications (cover images, linked fruits, PDFs, fruit images) using `scripts/import_publications.py`.
- Users can search fruits by name or synonym (case-insensitive, debounced) and filter by type or combined-type alias (e.g. "Birnen- und Quittensorten") from the fruit list.
- Users can manage publications (books, catalogues) with cover images, linked fruits, per-fruit PDF descriptions, and fruit images; fruit detail pages show publication descriptions and images.
- Maintainers can log in with a username and password to access data-modifying operations; all write API endpoints and admin controls are protected behind JWT authentication.
## Quick Start
```bash
cp .env.example .env # set credentials
make dev-db # start Postgres (waits until healthy)
make migrate-up # apply schema migrations
make run-backend # Echo API on :3000
make run-frontend # Vite dev server on :5000
cp .env.example .env # set credentials
cp backend/.env.example backend/.env # set JWT_SECRET and USERS_FILE
cp users.env.example users.env # create user file (see below)
./scripts/create_user.sh admin secret >> users.env # add a user
make dev-db # start Postgres (waits until healthy)
make migrate-up # apply schema migrations
make run-backend # Echo API on :3000
make run-frontend # Vite dev server on :5000
```
## Testing
+4
View File
@@ -0,0 +1,4 @@
DATABASE_URL=postgres://osdb:osdb@localhost:5432/osdb
PORT=3000
JWT_SECRET=change-me-use-a-long-random-string
USERS_FILE=../users.env
+8 -1
View File
@@ -10,6 +10,7 @@ import (
"syscall"
"time"
"osdb/internal/auth"
"osdb/internal/config"
"osdb/internal/database"
"osdb/internal/migrate"
@@ -52,7 +53,13 @@ func serve(cfg *config.Config) {
defer pool.Close()
log.Println("database: connected")
e := New(pool)
users, err := auth.LoadUsers(cfg.UsersFile)
if err != nil {
log.Fatalf("auth: load users from %s: %v", cfg.UsersFile, err)
}
log.Printf("auth: loaded %d user(s)", len(users))
e := New(pool, users, cfg.JWTSecret)
// Start server; signal failures via a channel so the main goroutine can
// handle them without calling os.Exit from a goroutine (which would skip
+26 -19
View File
@@ -6,17 +6,20 @@ import (
"github.com/labstack/echo/v4/middleware"
"osdb/internal/handler"
mw "osdb/internal/middleware"
"osdb/internal/repository"
)
// New creates and configures the Echo instance with all routes registered.
func New(pool *pgxpool.Pool) *echo.Echo {
func New(pool *pgxpool.Pool, users map[string]string, jwtSecret string) *echo.Echo {
e := echo.New()
e.HideBanner = true
e.Use(middleware.Logger())
e.Use(middleware.Recover())
jwtAuth := mw.JWTAuth(jwtSecret)
health := handler.NewHealthHandler()
// Root health — used by docker healthcheck + ops tooling
@@ -26,21 +29,25 @@ func New(pool *pgxpool.Pool) *echo.Echo {
api := e.Group("/api/v1")
api.GET("/health", health.Health)
// Auth (story #08)
authHandler := handler.NewAuthHandler(users, jwtSecret)
api.POST("/auth/login", authHandler.Login)
// Fruits (story #02)
fruitRepo := repository.NewFruitRepo(pool)
fruits := handler.NewFruitHandler(fruitRepo)
g := api.Group("/fruits")
g.GET("", fruits.List)
g.POST("", fruits.Create)
g.POST("", fruits.Create, jwtAuth)
g.GET("/:id", fruits.Get)
g.PUT("/:id", fruits.Update)
g.DELETE("/:id", fruits.Delete)
g.PUT("/:id", fruits.Update, jwtAuth)
g.DELETE("/:id", fruits.Delete, jwtAuth)
g.GET("/:id/images", fruits.ListImages)
g.POST("/:id/images", fruits.UploadImage, middleware.BodyLimit("5M"))
g.POST("/:id/images", fruits.UploadImage, jwtAuth, middleware.BodyLimit("5M"))
g.GET("/:id/images/:imageId", fruits.ServeImage)
g.GET("/:id/images/:imageId/thumbnail", fruits.ServeThumbnail)
g.DELETE("/:id/images/:imageId", fruits.DeleteImage)
g.DELETE("/:id/images/:imageId", fruits.DeleteImage, jwtAuth)
// Publications (story #04)
pubRepo := repository.NewPublicationRepo(pool)
@@ -52,29 +59,29 @@ func New(pool *pgxpool.Pool) *echo.Echo {
p := api.Group("/publications")
p.GET("", pubs.List)
p.POST("", pubs.Create)
p.POST("", pubs.Create, jwtAuth)
p.GET("/:id", pubs.Get)
p.PUT("/:id", pubs.Update)
p.DELETE("/:id", pubs.Delete)
p.POST("/:id/image", pubs.UploadCoverImage, middleware.BodyLimit("5M"))
p.PUT("/:id", pubs.Update, jwtAuth)
p.DELETE("/:id", pubs.Delete, jwtAuth)
p.POST("/:id/image", pubs.UploadCoverImage, jwtAuth, middleware.BodyLimit("5M"))
p.GET("/:id/image", pubs.ServeCoverImage)
p.DELETE("/:id/image", pubs.DeleteCoverImage)
p.DELETE("/:id/image", pubs.DeleteCoverImage, jwtAuth)
p.GET("/:id/fruits", pubs.ListFruits)
p.POST("/:id/fruits", pubs.LinkFruit)
p.DELETE("/:id/fruits/:fruitId", pubs.UnlinkFruit)
p.POST("/:id/fruits", pubs.LinkFruit, jwtAuth)
p.DELETE("/:id/fruits/:fruitId", pubs.UnlinkFruit, jwtAuth)
p.GET("/:id/descriptions", pubs.ListDescriptions)
p.POST("/:id/descriptions", pubs.UploadDescription, middleware.BodyLimit("5M"))
p.POST("/:id/descriptions", pubs.UploadDescription, jwtAuth, middleware.BodyLimit("5M"))
p.GET("/:id/descriptions/:descId", pubs.ServeDescription)
p.DELETE("/:id/descriptions/:descId", pubs.DeleteDescription)
p.DELETE("/:id/descriptions/:descId", pubs.DeleteDescription, jwtAuth)
p.GET("/:id/fruit-images", pubs.ListFruitImages)
p.POST("/:id/fruit-images", pubs.UploadFruitImage, middleware.BodyLimit("5M"))
p.POST("/:id/fruit-images", pubs.UploadFruitImage, jwtAuth, middleware.BodyLimit("5M"))
p.GET("/:id/fruit-images/:imgId", pubs.ServeFruitImage)
p.GET("/:id/fruit-images/:imgId/thumbnail", pubs.ServeFruitImageThumbnail)
p.DELETE("/:id/fruit-images/:imgId", pubs.DeleteFruitImage)
p.DELETE("/:id/fruit-images/:imgId", pubs.DeleteFruitImage, jwtAuth)
// Admin (story #07)
// Admin (story #07) — all admin routes require auth
admin := handler.NewAdminHandler(fruitRepo, pubRepo)
adminGroup := api.Group("/admin")
adminGroup := api.Group("/admin", jwtAuth)
adminGroup.POST("/backfill-thumbnails", admin.BackfillThumbnails)
return e
+1
View File
@@ -9,6 +9,7 @@ require (
)
require (
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
+2
View File
@@ -27,6 +27,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+83
View File
@@ -0,0 +1,83 @@
package auth
import (
"bufio"
"errors"
"os"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
// Claims is the JWT payload.
type Claims struct {
Username string `json:"username"`
jwt.RegisteredClaims
}
// LoadUsers reads username:bcrypt_hash pairs from path.
// Lines starting with '#' and blank lines are ignored.
func LoadUsers(path string) (map[string]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
users := make(map[string]string)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
continue
}
users[parts[0]] = parts[1]
}
return users, scanner.Err()
}
// VerifyPassword checks username+password against the bcrypt hash stored for that user.
// The peppered input is "username:password".
func VerifyPassword(users map[string]string, username, password string) bool {
hash, ok := users[username]
if !ok {
return false
}
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(username+":"+password)) == nil
}
// GenerateJWT creates a signed HS256 token for username, valid for 1 hour.
func GenerateJWT(username, secret string) (string, error) {
claims := Claims{
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
}
// ValidateJWT parses and verifies token, returning its claims.
func ValidateJWT(tokenStr, secret string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return []byte(secret), nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}
+125
View File
@@ -0,0 +1,125 @@
package auth_test
import (
"os"
"path/filepath"
"testing"
"golang.org/x/crypto/bcrypt"
"osdb/internal/auth"
)
// hashPepper mirrors the pepper convention: bcrypt("username:password")
func hashPepper(username, password string) string {
h, err := bcrypt.GenerateFromPassword([]byte(username+":"+password), bcrypt.MinCost)
if err != nil {
panic(err)
}
return string(h)
}
func writeTempUsersFile(t *testing.T, content string) string {
t.Helper()
f, err := os.CreateTemp(t.TempDir(), "users*.env")
if err != nil {
t.Fatal(err)
}
if _, err := f.WriteString(content); err != nil {
t.Fatal(err)
}
f.Close()
return f.Name()
}
func TestLoadUsers_ParsesValidFile(t *testing.T) {
hash := hashPepper("admin", "secret")
path := writeTempUsersFile(t, "admin:"+hash+"\n")
users, err := auth.LoadUsers(path)
if err != nil {
t.Fatalf("LoadUsers: %v", err)
}
if len(users) != 1 {
t.Fatalf("expected 1 user, got %d", len(users))
}
if _, ok := users["admin"]; !ok {
t.Error("expected 'admin' in users map")
}
}
func TestLoadUsers_SkipsCommentLines(t *testing.T) {
hash := hashPepper("admin", "secret")
content := "# comment\nadmin:" + hash + "\n"
path := writeTempUsersFile(t, content)
users, err := auth.LoadUsers(path)
if err != nil {
t.Fatalf("LoadUsers: %v", err)
}
if len(users) != 1 {
t.Fatalf("expected 1 user, got %d", len(users))
}
}
func TestLoadUsers_MissingFile(t *testing.T) {
_, err := auth.LoadUsers(filepath.Join(t.TempDir(), "nonexistent.env"))
if err == nil {
t.Error("expected error for missing file")
}
}
func TestVerifyPassword_CorrectPassword(t *testing.T) {
hash := hashPepper("admin", "secret")
users := map[string]string{"admin": hash}
if !auth.VerifyPassword(users, "admin", "secret") {
t.Error("expected password to match")
}
}
func TestVerifyPassword_WrongPassword(t *testing.T) {
hash := hashPepper("admin", "secret")
users := map[string]string{"admin": hash}
if auth.VerifyPassword(users, "admin", "wrong") {
t.Error("expected password to not match")
}
}
func TestVerifyPassword_UnknownUser(t *testing.T) {
if auth.VerifyPassword(map[string]string{}, "nobody", "secret") {
t.Error("expected false for unknown user")
}
}
func TestGenerateAndValidateJWT(t *testing.T) {
token, err := auth.GenerateJWT("admin", "testsecret")
if err != nil {
t.Fatalf("GenerateJWT: %v", err)
}
if token == "" {
t.Error("expected non-empty token")
}
claims, err := auth.ValidateJWT(token, "testsecret")
if err != nil {
t.Fatalf("ValidateJWT: %v", err)
}
if claims.Username != "admin" {
t.Errorf("expected username 'admin', got %q", claims.Username)
}
}
func TestValidateJWT_WrongSecret(t *testing.T) {
token, _ := auth.GenerateJWT("admin", "correct")
if _, err := auth.ValidateJWT(token, "wrong"); err == nil {
t.Error("expected error for wrong secret")
}
}
func TestValidateJWT_MalformedToken(t *testing.T) {
if _, err := auth.ValidateJWT("not.a.token", "secret"); err == nil {
t.Error("expected error for malformed token")
}
}
+16
View File
@@ -9,11 +9,15 @@ import (
type Config struct {
DatabaseURL string
Port string
UsersFile string
JWTSecret string
}
// Load reads configuration from environment variables.
// DATABASE_URL is required (no silent default — fails fast if missing).
// PORT defaults to "3000".
// USERS_FILE defaults to "users.env".
// JWT_SECRET is required.
func Load() *Config {
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
@@ -21,13 +25,25 @@ func Load() *Config {
"Copy .env.example to .env and set the correct value.")
}
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
log.Fatal("JWT_SECRET environment variable is required but not set.")
}
port := os.Getenv("PORT")
if port == "" {
port = "3000"
}
usersFile := os.Getenv("USERS_FILE")
if usersFile == "" {
usersFile = "users.env"
}
return &Config{
DatabaseURL: dbURL,
Port: port,
UsersFile: usersFile,
JWTSecret: jwtSecret,
}
}
+44
View File
@@ -0,0 +1,44 @@
package handler
import (
"net/http"
"github.com/labstack/echo/v4"
"osdb/internal/auth"
)
// AuthHandler handles authentication endpoints.
type AuthHandler struct {
users map[string]string
jwtSecret string
}
// NewAuthHandler creates an AuthHandler with the given user map and JWT secret.
func NewAuthHandler(users map[string]string, jwtSecret string) *AuthHandler {
return &AuthHandler{users: users, jwtSecret: jwtSecret}
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// Login handles POST /api/v1/auth/login.
func (h *AuthHandler) Login(c echo.Context) error {
var req loginRequest
if err := c.Bind(&req); err != nil || req.Username == "" {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
}
if !auth.VerifyPassword(h.users, req.Username, req.Password) {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
}
token, err := auth.GenerateJWT(req.Username, h.jwtSecret)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "token generation failed"})
}
return c.JSON(http.StatusOK, map[string]string{"token": token})
}
@@ -0,0 +1,75 @@
package handler_test
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"golang.org/x/crypto/bcrypt"
"github.com/labstack/echo/v4"
"osdb/internal/handler"
)
func makeUsersMap(username, password string) map[string]string {
h, _ := bcrypt.GenerateFromPassword([]byte(username+":"+password), bcrypt.MinCost)
return map[string]string{username: string(h)}
}
func TestAuthHandler_Login_ValidCredentials(t *testing.T) {
users := makeUsersMap("admin", "secret")
h := handler.NewAuthHandler(users, "jwttest")
body := `{"username":"admin","password":"secret"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c := echo.New().NewContext(req, rec)
if err := h.Login(c); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d — body: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "token") {
t.Errorf("expected 'token' in response, got: %s", rec.Body.String())
}
}
func TestAuthHandler_Login_InvalidCredentials(t *testing.T) {
users := makeUsersMap("admin", "secret")
h := handler.NewAuthHandler(users, "jwttest")
body := `{"username":"admin","password":"wrong"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c := echo.New().NewContext(req, rec)
if err := h.Login(c); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rec.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rec.Code)
}
}
func TestAuthHandler_Login_MissingBody(t *testing.T) {
users := makeUsersMap("admin", "secret")
h := handler.NewAuthHandler(users, "jwttest")
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", nil)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c := echo.New().NewContext(req, rec)
if err := h.Login(c); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rec.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rec.Code)
}
}
+27
View File
@@ -0,0 +1,27 @@
package middleware
import (
"net/http"
"strings"
"github.com/labstack/echo/v4"
"osdb/internal/auth"
)
// JWTAuth returns an Echo middleware that requires a valid Bearer JWT.
func JWTAuth(secret string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
header := c.Request().Header.Get("Authorization")
if !strings.HasPrefix(header, "Bearer ") {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "missing token"})
}
tokenStr := strings.TrimPrefix(header, "Bearer ")
if _, err := auth.ValidateJWT(tokenStr, secret); err != nil {
return c.JSON(http.StatusUnauthorized, map[string]string{"error": "invalid token"})
}
return next(c)
}
}
}
@@ -0,0 +1,82 @@
package middleware_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/labstack/echo/v4"
"osdb/internal/auth"
mw "osdb/internal/middleware"
)
const testSecret = "testsecret"
func okHandler(c echo.Context) error {
return c.String(http.StatusOK, "ok")
}
func TestJWTAuth_NoToken_Returns401(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/", nil)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
h := mw.JWTAuth(testSecret)(okHandler)
_ = h(c)
if rec.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rec.Code)
}
}
func TestJWTAuth_ValidToken_Passes(t *testing.T) {
token, _ := auth.GenerateJWT("admin", testSecret)
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
h := mw.JWTAuth(testSecret)(okHandler)
if err := h(c); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if rec.Code != http.StatusOK {
t.Errorf("expected 200, got %d", rec.Code)
}
}
func TestJWTAuth_InvalidToken_Returns401(t *testing.T) {
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("Authorization", "Bearer invalid.token.here")
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
h := mw.JWTAuth(testSecret)(okHandler)
_ = h(c)
if rec.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rec.Code)
}
}
func TestJWTAuth_WrongSecret_Returns401(t *testing.T) {
token, _ := auth.GenerateJWT("admin", "other-secret")
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set("Authorization", "Bearer "+token)
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
h := mw.JWTAuth(testSecret)(okHandler)
_ = h(c)
if rec.Code != http.StatusUnauthorized {
t.Errorf("expected 401, got %d", rec.Code)
}
}
+2
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import NavBar from './components/NavBar.vue'
</script>
<template>
<NavBar />
<RouterView />
</template>
+15
View File
@@ -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()
}
+16
View File
@@ -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')
}
+8 -5
View File
@@ -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)
}
+14 -11
View File
@@ -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)
}
+36
View File
@@ -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>
+24 -1
View File
@@ -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
+75
View File
@@ -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()
})
})
+23
View File
@@ -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 }
})
+48
View File
@@ -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>
+5 -3
View File
@@ -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">
+3
View File
@@ -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"
>
+73
View File
@@ -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>
+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"
+3
View File
@@ -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"
>
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Usage: ./scripts/create_user.sh <username> <password>
# Prints a line suitable for appending to users.env
set -euo pipefail
if [ $# -ne 2 ]; then
echo "Usage: $0 <username> <password>" >&2
exit 1
fi
USERNAME="$1"
PASSWORD="$2"
# bcrypt the peppered input "username:password"
PEPPERED="${USERNAME}:${PASSWORD}"
# Try Python 3 with bcrypt first, then fall back to htpasswd
if python3 -c "import bcrypt" 2>/dev/null; then
HASH=$(python3 -c "
import bcrypt, sys
pw = sys.argv[1].encode()
print(bcrypt.hashpw(pw, bcrypt.gensalt(rounds=12)).decode())
" "$PEPPERED")
elif command -v htpasswd &>/dev/null; then
HASH=$(htpasswd -bnBC 12 "" "$PEPPERED" | tr -d ':\n' | sed 's/^\$/\$/')
else
echo "Error: install 'bcrypt' (pip install bcrypt) or 'htpasswd' (apache2-utils)" >&2
exit 1
fi
echo "${USERNAME}:${HASH}"
+2
View File
@@ -0,0 +1,2 @@
# username:bcrypt_hash — generate entries with: ./scripts/create_user.sh <username> <password>
# admin:$2a$12$...