Quellcode durchsuchen

Add admin panel, service management, and deployment tooling

This commit adds comprehensive admin features and deployment utilities:

Admin Panel:
- User management interface with ability to view, promote, and delete users
- Application settings control (registration toggle)
- Admin-only access control with proper authorization checks

Service Management:
- Systemd service file for production deployment
- Setup, restart, and removal scripts for easy service management
- Service runs as dedicated user with proper permissions

Utility Scripts:
- Database migration tool for schema updates
- Password reset utility for admin recovery
- Authentication testing script for debugging

Additional Improvements:
- Enhanced authentication error handling
- UI improvements for admin interface
- Documentation updates in CLAUDE.md with startup instructions
- Database integrity checks and validation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Brad Lance vor 3 Monaten
Ursprung
Commit
000cd2ad4a

+ 22 - 0
CLAUDE.md

@@ -2,6 +2,28 @@
 
 This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
 
+## Startup Instructions
+
+**IMPORTANT**: At the start of each conversation, automatically check for open issues and milestones in the Gogs repository:
+
+1. **Check Milestones**: Fetch from `https://git.mrbamm.xyz/api/v1/repos/blance/absRecommend/milestones?token=bf7d69fd1c0c918719e842c8f8aea97df30aba60`
+   - List all milestones with their progress (open/closed issue counts)
+   - If there are active milestones, ask which milestone to focus on for feature planning
+
+2. **Check Open Issues**: Fetch from `https://git.mrbamm.xyz/api/v1/repos/blance/absRecommend/issues?state=open&token=bf7d69fd1c0c918719e842c8f8aea97df30aba60`
+   - Group issues by milestone if they have one
+   - List standalone issues separately
+   - If there are open issues, list them and ask which ones to work on
+
+3. **Working Priority**:
+   - For standalone issues: Work in order of tag (Error, Issue, Request) and then by age (oldest first)
+   - For milestone-based work: Focus on issues within the selected milestone as a cohesive feature set
+
+4. **When Completing Issues**:
+   - Add a detailed comment explaining the fix using the Gogs API
+   - Close the issue
+   - If all issues in a milestone are completed, mention that the milestone is complete
+
 ## Project Overview
 
 Audiobookshelf Recommendation System - A FastAPI web application that syncs with Audiobookshelf to track listening history and provide AI-powered book recommendations using Google Gemini.

+ 26 - 0
absrecommend.service

@@ -0,0 +1,26 @@
+[Unit]
+Description=Audiobookshelf Recommendation System
+After=network.target
+
+[Service]
+Type=simple
+User=blance
+WorkingDirectory=/home/blance/absRecommend
+Environment="PATH=/home/blance/absRecommend/venv/bin:/usr/local/bin:/usr/bin:/bin"
+ExecStart=/home/blance/absRecommend/venv/bin/python main.py
+
+# Restart policy
+Restart=always
+RestartSec=5
+
+# Logging
+StandardOutput=journal
+StandardError=journal
+SyslogIdentifier=absrecommend
+
+# Security settings
+NoNewPrivileges=true
+PrivateTmp=true
+
+[Install]
+WantedBy=multi-user.target

+ 32 - 6
app/auth.py

@@ -8,7 +8,7 @@ from datetime import datetime, timedelta
 from typing import Optional
 from fastapi import Depends, HTTPException, status, Request, Response
 from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select
+from sqlalchemy import select, func
 import bcrypt
 from cryptography.fernet import Fernet
 from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
@@ -169,6 +169,26 @@ async def get_current_user_optional(
         return None
 
 
+async def get_current_admin(
+    request: Request,
+    db: AsyncSession = Depends(get_db)
+) -> User:
+    """
+    Get the current authenticated admin user.
+
+    Raises 403 Forbidden if user is not an admin.
+    """
+    user = await get_current_user(request, db)
+
+    if not user.is_admin:
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail="Admin access required"
+        )
+
+    return user
+
+
 async def authenticate_user(
     db: AsyncSession,
     username: str,
@@ -179,9 +199,9 @@ async def authenticate_user(
 
     Returns User if authentication succeeds, None otherwise.
     """
-    # Find user by username
+    # Find user by username (case-insensitive)
     result = await db.execute(
-        select(User).where(User.username == username)
+        select(User).where(func.lower(User.username) == func.lower(username))
     )
     user = result.scalar_one_or_none()
 
@@ -217,9 +237,9 @@ async def create_user(
 
     Raises HTTPException if username or email already exists.
     """
-    # Check if username already exists
+    # Check if username already exists (case-insensitive)
     result = await db.execute(
-        select(User).where(User.username == username)
+        select(User).where(func.lower(User.username) == func.lower(username))
     )
     if result.scalar_one_or_none():
         raise HTTPException(
@@ -237,6 +257,11 @@ async def create_user(
             detail="Email already registered"
         )
 
+    # Check if this is the first user (make them admin)
+    result = await db.execute(select(func.count(User.id)))
+    user_count = result.scalar()
+    is_first_user = user_count == 0
+
     # Create new user
     user = User(
         username=username,
@@ -246,7 +271,8 @@ async def create_user(
         abs_api_token=encrypt_token(abs_api_token),
         display_name=display_name or username,
         created_at=datetime.now(),
-        is_active=True
+        is_active=True,
+        is_admin=is_first_user  # First user becomes admin
     )
 
     db.add(user)

+ 16 - 2
app/database.py

@@ -1,7 +1,7 @@
 from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
 from sqlalchemy.orm import sessionmaker
-from sqlalchemy import text
-from app.models import Base
+from sqlalchemy import text, select
+from app.models import Base, AppSettings
 from app.config import get_settings
 
 
@@ -44,6 +44,20 @@ async def init_db():
         # Create all tables (will skip existing ones)
         await conn.run_sync(Base.metadata.create_all)
 
+    # Initialize default settings
+    async with async_session() as session:
+        # Check if settings already exist
+        result = await session.execute(
+            select(AppSettings).where(AppSettings.key == "allow_registration")
+        )
+        if not result.scalar_one_or_none():
+            # Create default settings
+            default_settings = [
+                AppSettings(key="allow_registration", value="true"),
+            ]
+            session.add_all(default_settings)
+            await session.commit()
+
 
 async def get_db():
     """Dependency for getting database session."""

+ 195 - 28
app/main.py

@@ -3,20 +3,21 @@ from fastapi.templating import Jinja2Templates
 from fastapi.staticfiles import StaticFiles
 from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
 from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select
+from sqlalchemy import select, func
 from contextlib import asynccontextmanager
 import json
 from datetime import datetime
 from typing import Optional
 
 from app.database import init_db, get_db
-from app.models import Book, ListeningSession, Recommendation, User
+from app.models import Book, ListeningSession, Recommendation, User, AppSettings
 from app.abs_client import get_abs_client
 from app.recommender import BookRecommender
 from app.config import get_settings
 from app.auth import (
     get_current_user,
     get_current_user_optional,
+    get_current_admin,
     authenticate_user,
     create_user,
     set_session_cookie,
@@ -98,7 +99,20 @@ async def home(
         .order_by(Recommendation.created_at.desc())
         .limit(5)
     )
-    recommendations = recs_result.scalars().all()
+    recommendations_raw = recs_result.scalars().all()
+
+    # Parse JSON fields for template
+    recommendations = []
+    for rec in recommendations_raw:
+        rec_dict = {
+            "id": rec.id,
+            "title": rec.title,
+            "author": rec.author,
+            "description": rec.description,
+            "reason": rec.reason,
+            "genres": json.loads(rec.genres) if rec.genres else []
+        }
+        recommendations.append(rec_dict)
 
     return templates.TemplateResponse(
         "index.html",
@@ -122,7 +136,6 @@ async def login_page(request: Request):
 
 @app.post("/api/auth/login")
 async def login(
-    response: Response,
     username: str = Form(...),
     password: str = Form(...),
     db: AsyncSession = Depends(get_db)
@@ -136,18 +149,11 @@ async def login(
             detail="Incorrect username or password"
         )
 
-    # Set session cookie
-    set_session_cookie(response, user.id)
+    # Create redirect response and set session cookie
+    redirect = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
+    set_session_cookie(redirect, user.id)
 
-    return JSONResponse({
-        "status": "success",
-        "message": "Logged in successfully",
-        "user": {
-            "username": user.username,
-            "email": user.email,
-            "display_name": user.display_name
-        }
-    })
+    return redirect
 
 
 @app.get("/register", response_class=HTMLResponse)
@@ -158,7 +164,6 @@ async def register_page(request: Request):
 
 @app.post("/api/auth/register")
 async def register(
-    response: Response,
     username: str = Form(...),
     email: str = Form(...),
     password: str = Form(...),
@@ -169,6 +174,22 @@ async def register(
 ):
     """Register a new user."""
     try:
+        # Check if registration is allowed
+        result = await db.execute(
+            select(AppSettings).where(AppSettings.key == "allow_registration")
+        )
+        allow_reg_setting = result.scalar_one_or_none()
+
+        # Check if there are any existing users (first user is always allowed)
+        result = await db.execute(select(func.count(User.id)))
+        user_count = result.scalar()
+
+        if user_count > 0 and allow_reg_setting and allow_reg_setting.value.lower() != 'true':
+            raise HTTPException(
+                status_code=status.HTTP_403_FORBIDDEN,
+                detail="Registration is currently disabled"
+            )
+
         user = await create_user(
             db=db,
             username=username,
@@ -179,18 +200,11 @@ async def register(
             display_name=display_name
         )
 
-        # Set session cookie
-        set_session_cookie(response, user.id)
+        # Create redirect response and set session cookie
+        redirect = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
+        set_session_cookie(redirect, user.id)
 
-        return JSONResponse({
-            "status": "success",
-            "message": "Account created successfully",
-            "user": {
-                "username": user.username,
-                "email": user.email,
-                "display_name": user.display_name
-            }
-        })
+        return redirect
 
     except HTTPException as e:
         raise e
@@ -498,7 +512,7 @@ async def get_reading_stats(
         end_dt = datetime.fromisoformat(end_date) if end_date else None
 
         # Calculate stats
-        stats_service = ReadingStatsService(db, user.id)
+        stats_service = ReadingStatsService(db, user.id, user.abs_url)
         stats = await stats_service.calculate_stats(start_dt, end_dt)
 
         return JSONResponse(stats)
@@ -560,6 +574,159 @@ async def update_session_rating(
         )
 
 
+# ==================== Admin Routes ====================
+
+
+@app.get("/admin", response_class=HTMLResponse)
+async def admin_page(
+    request: Request,
+    user: User = Depends(get_current_admin)
+):
+    """Admin panel page."""
+    return templates.TemplateResponse(
+        "admin.html",
+        {
+            "request": request,
+            "user": user
+        }
+    )
+
+
+@app.get("/api/admin/settings")
+async def get_admin_settings(
+    db: AsyncSession = Depends(get_db),
+    admin: User = Depends(get_current_admin)
+):
+    """Get application settings."""
+    result = await db.execute(select(AppSettings))
+    settings = result.scalars().all()
+
+    settings_dict = {s.key: s.value for s in settings}
+
+    return JSONResponse(settings_dict)
+
+
+@app.put("/api/admin/settings/{key}")
+async def update_setting(
+    key: str,
+    value: str = Form(...),
+    db: AsyncSession = Depends(get_db),
+    admin: User = Depends(get_current_admin)
+):
+    """Update an application setting."""
+    result = await db.execute(
+        select(AppSettings).where(AppSettings.key == key)
+    )
+    setting = result.scalar_one_or_none()
+
+    if not setting:
+        # Create new setting
+        setting = AppSettings(key=key, value=value)
+        db.add(setting)
+    else:
+        # Update existing
+        setting.value = value
+
+    await db.commit()
+
+    return JSONResponse({
+        "status": "success",
+        "message": f"Setting {key} updated"
+    })
+
+
+@app.get("/api/admin/users")
+async def get_users(
+    db: AsyncSession = Depends(get_db),
+    admin: User = Depends(get_current_admin)
+):
+    """Get all users."""
+    result = await db.execute(select(User).order_by(User.created_at.desc()))
+    users = result.scalars().all()
+
+    users_list = [
+        {
+            "id": user.id,
+            "username": user.username,
+            "email": user.email,
+            "display_name": user.display_name,
+            "is_admin": user.is_admin,
+            "is_active": user.is_active,
+            "created_at": user.created_at.isoformat() if user.created_at else None,
+            "last_login": user.last_login.isoformat() if user.last_login else None,
+            "is_current": user.id == admin.id
+        }
+        for user in users
+    ]
+
+    return JSONResponse({"users": users_list})
+
+
+@app.put("/api/admin/users/{user_id}/admin")
+async def toggle_user_admin(
+    user_id: int,
+    is_admin: str = Form(...),
+    db: AsyncSession = Depends(get_db),
+    admin: User = Depends(get_current_admin)
+):
+    """Toggle admin status for a user."""
+    # Prevent admin from removing their own admin status
+    if user_id == admin.id:
+        return JSONResponse(
+            {"status": "error", "message": "Cannot modify your own admin status"},
+            status_code=400
+        )
+
+    result = await db.execute(select(User).where(User.id == user_id))
+    user = result.scalar_one_or_none()
+
+    if not user:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="User not found"
+        )
+
+    user.is_admin = is_admin.lower() == 'true'
+    await db.commit()
+
+    return JSONResponse({
+        "status": "success",
+        "message": "User admin status updated"
+    })
+
+
+@app.delete("/api/admin/users/{user_id}")
+async def delete_user(
+    user_id: int,
+    db: AsyncSession = Depends(get_db),
+    admin: User = Depends(get_current_admin)
+):
+    """Delete a user."""
+    # Prevent admin from deleting themselves
+    if user_id == admin.id:
+        return JSONResponse(
+            {"status": "error", "message": "Cannot delete your own account"},
+            status_code=400
+        )
+
+    result = await db.execute(select(User).where(User.id == user_id))
+    user = result.scalar_one_or_none()
+
+    if not user:
+        raise HTTPException(
+            status_code=status.HTTP_404_NOT_FOUND,
+            detail="User not found"
+        )
+
+    await db.delete(user)
+    await db.commit()
+
+    return JSONResponse({
+        "status": "success",
+        "message": "User deleted successfully"
+    })
+
+
 @app.get("/health")
 async def health_check():
     """Health check endpoint."""

+ 11 - 0
app/models.py

@@ -25,6 +25,7 @@ class User(Base):
     created_at = Column(DateTime, default=func.now())
     last_login = Column(DateTime)
     is_active = Column(Boolean, default=True)
+    is_admin = Column(Boolean, default=False)
 
     # Relationships
     listening_sessions = relationship("ListeningSession", back_populates="user", cascade="all, delete-orphan")
@@ -94,3 +95,13 @@ class Recommendation(Base):
 
     # Relationships
     user = relationship("User", back_populates="recommendations")
+
+
+class AppSettings(Base):
+    """Application-wide settings."""
+    __tablename__ = "app_settings"
+
+    id = Column(Integer, primary_key=True, autoincrement=True)
+    key = Column(String, unique=True, nullable=False, index=True)
+    value = Column(String, nullable=False)
+    updated_at = Column(DateTime, default=func.now(), onupdate=func.now())

+ 13 - 4
app/services/stats.py

@@ -16,16 +16,18 @@ from app.models import ListeningSession, Book
 class ReadingStatsService:
     """Service for calculating reading statistics."""
 
-    def __init__(self, db: AsyncSession, user_id: int):
+    def __init__(self, db: AsyncSession, user_id: int, abs_url: str = None):
         """
         Initialize statistics service for a user.
 
         Args:
             db: Database session
             user_id: User ID to calculate stats for
+            abs_url: Audiobookshelf server URL for constructing full cover URLs
         """
         self.db = db
         self.user_id = user_id
+        self.abs_url = abs_url
 
     async def calculate_stats(
         self,
@@ -90,8 +92,8 @@ class ReadingStatsService:
         # Calculate current streak
         streak = await self._calculate_streak(finished_sessions)
 
-        # Calculate recent books
-        recent_books = await self._get_recent_books(finished_sessions, books_dict, limit=10)
+        # Calculate recent books (show all finished books)
+        recent_books = await self._get_recent_books(finished_sessions, books_dict, limit=1000)
 
         return {
             "total_books": len(finished_sessions),
@@ -245,6 +247,13 @@ class ReadingStatsService:
                 duration_hours = (session.finished_at - session.started_at).total_seconds() / 3600
                 listening_duration = round(duration_hours, 1)
 
+            # Construct full cover URL if abs_url is available
+            cover_url = book.cover_url
+            if cover_url and self.abs_url:
+                # If cover_url is a relative path, prepend abs_url
+                if not cover_url.startswith(('http://', 'https://')):
+                    cover_url = f"{self.abs_url.rstrip('/')}{cover_url if cover_url.startswith('/') else '/' + cover_url}"
+
             recent.append({
                 "book_id": book.id,
                 "title": book.title,
@@ -252,7 +261,7 @@ class ReadingStatsService:
                 "finished_at": session.finished_at.isoformat(),
                 "rating": session.rating,
                 "listening_duration": listening_duration,
-                "cover_url": book.cover_url
+                "cover_url": cover_url
             })
 
         return recent

+ 104 - 0
app/static/css/style.css

@@ -637,3 +637,107 @@ header h1 {
         grid-template-columns: repeat(2, 1fr);
     }
 }
+
+/* ==================== Admin Panel Styles ==================== */
+
+.settings-grid {
+    display: grid;
+    gap: 20px;
+    margin-top: 20px;
+}
+
+.setting-item {
+    background: white;
+    padding: 20px;
+    border-radius: 8px;
+    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+}
+
+.setting-item label {
+    display: flex;
+    align-items: center;
+    gap: 10px;
+    cursor: pointer;
+    font-size: 1rem;
+}
+
+.setting-item input[type="checkbox"] {
+    width: 20px;
+    height: 20px;
+    cursor: pointer;
+}
+
+.section-actions {
+    display: flex;
+    gap: 10px;
+    margin-bottom: 20px;
+}
+
+.users-table {
+    width: 100%;
+    background: white;
+    border-radius: 8px;
+    overflow: hidden;
+    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+    border-collapse: collapse;
+}
+
+.users-table thead {
+    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+    color: white;
+}
+
+.users-table th {
+    padding: 15px;
+    text-align: left;
+    font-weight: 600;
+}
+
+.users-table td {
+    padding: 15px;
+    border-bottom: 1px solid #e0e0e0;
+}
+
+.users-table tbody tr:hover {
+    background-color: #f5f5f5;
+}
+
+.users-table tbody tr:last-child td {
+    border-bottom: none;
+}
+
+.badge {
+    display: inline-block;
+    padding: 4px 8px;
+    border-radius: 4px;
+    font-size: 0.85rem;
+    font-weight: 600;
+}
+
+.badge-admin {
+    background-color: #fbbf24;
+    color: #78350f;
+}
+
+.badge-active {
+    background-color: #34d399;
+    color: #064e3b;
+}
+
+.badge-inactive {
+    background-color: #f87171;
+    color: #7f1d1d;
+}
+
+.btn-danger {
+    background-color: #ef4444;
+}
+
+.btn-danger:hover {
+    background-color: #dc2626;
+}
+
+.text-muted {
+    color: #999;
+    font-style: italic;
+}

+ 196 - 0
app/static/js/admin.js

@@ -0,0 +1,196 @@
+// ==================== Admin Panel Functions ====================
+
+async function loadSettings() {
+    try {
+        const response = await fetch('/api/admin/settings');
+
+        if (response.status === 401) {
+            window.location.href = '/login';
+            return;
+        }
+
+        if (response.status === 403) {
+            showMessage('Admin access required', 'error');
+            return;
+        }
+
+        const data = await response.json();
+
+        // Update checkbox state
+        document.getElementById('allow-registration').checked =
+            data.allow_registration === 'true' || data.allow_registration === true;
+
+    } catch (error) {
+        console.error('Error loading settings:', error);
+        showMessage('Error loading settings: ' + error.message, 'error');
+    }
+}
+
+async function toggleRegistration(checkbox) {
+    try {
+        const formData = new FormData();
+        formData.append('value', checkbox.checked ? 'true' : 'false');
+
+        const response = await fetch('/api/admin/settings/allow_registration', {
+            method: 'PUT',
+            body: formData
+        });
+
+        if (response.status === 401) {
+            window.location.href = '/login';
+            return;
+        }
+
+        if (response.status === 403) {
+            showMessage('Admin access required', 'error');
+            return;
+        }
+
+        const data = await response.json();
+
+        if (data.status === 'success') {
+            showMessage(
+                checkbox.checked ? 'Registration enabled' : 'Registration disabled',
+                'success'
+            );
+        } else {
+            showMessage(data.message || 'Failed to update setting', 'error');
+            // Revert checkbox
+            checkbox.checked = !checkbox.checked;
+        }
+    } catch (error) {
+        showMessage('Error updating setting: ' + error.message, 'error');
+        // Revert checkbox
+        checkbox.checked = !checkbox.checked;
+    }
+}
+
+async function loadUsers() {
+    try {
+        const loadingEl = document.getElementById('users-loading');
+        const containerEl = document.getElementById('users-container');
+
+        loadingEl.classList.remove('hidden');
+        containerEl.classList.add('hidden');
+
+        const response = await fetch('/api/admin/users');
+
+        if (response.status === 401) {
+            window.location.href = '/login';
+            return;
+        }
+
+        if (response.status === 403) {
+            showMessage('Admin access required', 'error');
+            return;
+        }
+
+        const data = await response.json();
+
+        loadingEl.classList.add('hidden');
+        containerEl.classList.remove('hidden');
+
+        renderUsers(data.users || []);
+
+    } catch (error) {
+        console.error('Error loading users:', error);
+        showMessage('Error loading users: ' + error.message, 'error');
+        document.getElementById('users-loading').classList.add('hidden');
+    }
+}
+
+function renderUsers(users) {
+    const listEl = document.getElementById('users-list');
+
+    if (users.length === 0) {
+        listEl.innerHTML = '<tr><td colspan="8" style="text-align: center;">No users found</td></tr>';
+        return;
+    }
+
+    const html = users.map(user => {
+        const createdDate = new Date(user.created_at).toLocaleDateString();
+        const lastLogin = user.last_login ? new Date(user.last_login).toLocaleDateString() : 'Never';
+
+        return `
+            <tr>
+                <td>${user.username}</td>
+                <td>${user.email}</td>
+                <td>${user.display_name || '-'}</td>
+                <td>${user.is_admin ? '<span class="badge badge-admin">Admin</span>' : '-'}</td>
+                <td>${user.is_active ? '<span class="badge badge-active">Active</span>' : '<span class="badge badge-inactive">Inactive</span>'}</td>
+                <td>${createdDate}</td>
+                <td>${lastLogin}</td>
+                <td>
+                    ${!user.is_admin ? `
+                        <button class="btn btn-small btn-secondary" onclick="toggleAdmin(${user.id}, true)">Make Admin</button>
+                    ` : ''}
+                    ${!user.is_current ? `
+                        <button class="btn btn-small btn-danger" onclick="deleteUser(${user.id}, '${user.username}')">Delete</button>
+                    ` : '<span class="text-muted">Current User</span>'}
+                </td>
+            </tr>
+        `;
+    }).join('');
+
+    listEl.innerHTML = html;
+}
+
+async function toggleAdmin(userId, makeAdmin) {
+    if (!confirm(`Are you sure you want to ${makeAdmin ? 'grant' : 'remove'} admin privileges ${makeAdmin ? 'to' : 'from'} this user?`)) {
+        return;
+    }
+
+    try {
+        const formData = new FormData();
+        formData.append('is_admin', makeAdmin ? 'true' : 'false');
+
+        const response = await fetch(`/api/admin/users/${userId}/admin`, {
+            method: 'PUT',
+            body: formData
+        });
+
+        if (response.status === 401) {
+            window.location.href = '/login';
+            return;
+        }
+
+        const data = await response.json();
+
+        if (data.status === 'success') {
+            showMessage('User privileges updated successfully', 'success');
+            loadUsers(); // Reload user list
+        } else {
+            showMessage(data.message || 'Failed to update user', 'error');
+        }
+    } catch (error) {
+        showMessage('Error updating user: ' + error.message, 'error');
+    }
+}
+
+async function deleteUser(userId, username) {
+    if (!confirm(`Are you sure you want to delete user "${username}"? This action cannot be undone and will delete all their data.`)) {
+        return;
+    }
+
+    try {
+        const response = await fetch(`/api/admin/users/${userId}`, {
+            method: 'DELETE'
+        });
+
+        if (response.status === 401) {
+            window.location.href = '/login';
+            return;
+        }
+
+        const data = await response.json();
+
+        if (data.status === 'success') {
+            showMessage('User deleted successfully', 'success');
+            loadUsers(); // Reload user list
+        } else {
+            showMessage(data.message || 'Failed to delete user', 'error');
+        }
+    } catch (error) {
+        showMessage('Error deleting user: ' + error.message, 'error');
+    }
+}

+ 4 - 48
app/static/js/app.js

@@ -25,57 +25,13 @@ async function handleApiError(response) {
 // ==================== Authentication Functions ====================
 
 async function handleLogin(event) {
-    event.preventDefault();
-
-    const form = event.target;
-    const formData = new FormData(form);
-
-    try {
-        const response = await fetch('/api/auth/login', {
-            method: 'POST',
-            body: formData
-        });
-
-        const data = await response.json();
-
-        if (response.ok && data.status === 'success') {
-            showMessage('Login successful! Redirecting...', 'success');
-            setTimeout(() => {
-                window.location.href = '/';
-            }, 1000);
-        } else {
-            showMessage(data.detail || 'Login failed', 'error');
-        }
-    } catch (error) {
-        showMessage('Error logging in: ' + error.message, 'error');
-    }
+    // Let the form submit naturally - server will handle redirect
+    // No need to prevent default or use fetch
 }
 
 async function handleRegister(event) {
-    event.preventDefault();
-
-    const form = event.target;
-    const formData = new FormData(form);
-
-    try {
-        const response = await fetch('/api/auth/register', {
-            method: 'POST',
-            body: formData
-        });
-
-        const data = await response.json();
-
-        if (response.ok && data.status === 'success') {
-            showMessage('Account created successfully! Redirecting...', 'success');
-            setTimeout(() => {
-                window.location.href = '/';
-            }, 1000);
-        } else {
-            showMessage(data.detail || 'Registration failed', 'error');
-        }
-    } catch (error) {
-        showMessage('Error creating account: ' + error.message, 'error');
-    }
+    // Let the form submit naturally - server will handle redirect
+    // No need to prevent default or use fetch
 }
 
 async function logout() {

+ 5 - 0
app/static/js/reading-log.js

@@ -34,6 +34,9 @@ async function loadReadingStats() {
 
     } catch (error) {
         console.error('Error loading stats:', error);
+        // Hide loading indicators even on error
+        document.getElementById('stats-loading').classList.add('hidden');
+        document.getElementById('books-loading').classList.add('hidden');
         showMessage('Error loading statistics: ' + error.message, 'error');
     }
 }
@@ -141,9 +144,11 @@ function renderRecentBooks(recentBooks) {
 
     if (recentBooks.length === 0) {
         emptyEl.classList.remove('hidden');
+        listEl.classList.add('hidden');
         return;
     }
 
+    emptyEl.classList.add('hidden');
     listEl.classList.remove('hidden');
 
     const html = recentBooks.map(book => {

+ 63 - 0
app/templates/admin.html

@@ -0,0 +1,63 @@
+{% extends "base.html" %}
+
+{% block title %}Admin Panel - Audiobookshelf Recommendations{% endblock %}
+
+{% block content %}
+<header>
+    <h1>Admin Panel</h1>
+    <p class="subtitle">Manage users and application settings</p>
+</header>
+
+<div id="message" class="message hidden"></div>
+
+<!-- Settings Section -->
+<section class="section">
+    <h2>Application Settings</h2>
+    <div class="settings-grid">
+        <div class="setting-item">
+            <label for="allow-registration">
+                <input type="checkbox" id="allow-registration" onchange="toggleRegistration(this)">
+                Allow user registration from login page
+            </label>
+        </div>
+    </div>
+</section>
+
+<!-- User Management Section -->
+<section class="section">
+    <h2>User Management</h2>
+    <div class="section-actions">
+        <button onclick="loadUsers()" class="btn btn-secondary">Refresh Users</button>
+    </div>
+    <div id="users-loading" class="loading">Loading users...</div>
+    <div id="users-container" class="hidden">
+        <table class="users-table">
+            <thead>
+                <tr>
+                    <th>Username</th>
+                    <th>Email</th>
+                    <th>Display Name</th>
+                    <th>Admin</th>
+                    <th>Active</th>
+                    <th>Created</th>
+                    <th>Last Login</th>
+                    <th>Actions</th>
+                </tr>
+            </thead>
+            <tbody id="users-list">
+            </tbody>
+        </table>
+    </div>
+</section>
+{% endblock %}
+
+{% block extra_scripts %}
+<script src="/static/js/admin.js"></script>
+<script>
+    // Initialize admin panel on page load
+    document.addEventListener('DOMContentLoaded', () => {
+        loadSettings();
+        loadUsers();
+    });
+</script>
+{% endblock %}

+ 3 - 0
app/templates/base.html

@@ -17,6 +17,9 @@
             <ul class="nav-links">
                 <li><a href="/">Dashboard</a></li>
                 <li><a href="/reading-log">Reading Log</a></li>
+                {% if user.is_admin %}
+                <li><a href="/admin">Admin</a></li>
+                {% endif %}
             </ul>
             <div class="nav-user">
                 <span class="user-name">{{ user.display_name }}</span>

+ 1 - 3
app/templates/index.html

@@ -32,9 +32,7 @@
                     </div>
                     {% if rec.genres %}
                         <div class="genres">
-                            {% set genres = rec.genres if rec.genres is string else rec.genres|tojson %}
-                            {% set genres_list = genres|from_json if genres is string else genres %}
-                            {% for genre in genres_list %}
+                            {% for genre in rec.genres %}
                             <span class="genre-tag">{{ genre }}</span>
                             {% endfor %}
                         </div>

+ 1 - 1
app/templates/login.html

@@ -14,7 +14,7 @@
 
             <div id="message" class="message hidden"></div>
 
-            <form id="login-form" onsubmit="handleLogin(event)">
+            <form id="login-form" method="POST" action="/api/auth/login">
                 <div class="form-group">
                     <label for="username">Username</label>
                     <input

+ 1 - 1
app/templates/register.html

@@ -14,7 +14,7 @@
 
             <div id="message" class="message hidden"></div>
 
-            <form id="register-form" onsubmit="handleRegister(event)">
+            <form id="register-form" method="POST" action="/api/auth/register">
                 <div class="form-group">
                     <label for="username">Username</label>
                     <input

+ 72 - 0
migrate_database.py

@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+"""
+Database migration script to add admin features.
+
+Adds:
+- is_admin column to users table
+- app_settings table
+"""
+
+import sqlite3
+import sys
+
+def migrate_database():
+    """Perform database migration."""
+    db_path = "./absrecommend.db"
+
+    try:
+        conn = sqlite3.connect(db_path)
+        cursor = conn.cursor()
+
+        print("Starting database migration...")
+
+        # Check if is_admin column exists
+        cursor.execute("PRAGMA table_info(users)")
+        columns = [col[1] for col in cursor.fetchall()]
+
+        if 'is_admin' not in columns:
+            print("Adding is_admin column to users table...")
+            cursor.execute("ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT 0")
+
+            # Make the first user an admin
+            cursor.execute("SELECT COUNT(*) FROM users")
+            user_count = cursor.fetchone()[0]
+
+            if user_count > 0:
+                print("Making the first user an admin...")
+                cursor.execute("UPDATE users SET is_admin = 1 ORDER BY id LIMIT 1")
+        else:
+            print("is_admin column already exists")
+
+        # Check if app_settings table exists
+        cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'")
+        if not cursor.fetchone():
+            print("Creating app_settings table...")
+            cursor.execute("""
+                CREATE TABLE app_settings (
+                    id INTEGER PRIMARY KEY AUTOINCREMENT,
+                    key VARCHAR NOT NULL UNIQUE,
+                    value VARCHAR NOT NULL,
+                    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
+                )
+            """)
+
+            # Add default settings
+            cursor.execute("INSERT INTO app_settings (key, value) VALUES ('allow_registration', 'true')")
+        else:
+            print("app_settings table already exists")
+
+        conn.commit()
+        print("\n✓ Migration completed successfully!")
+        print("\nYou can now restart the service:")
+        print("  ./restart-service.sh")
+
+    except sqlite3.Error as e:
+        print(f"\n✗ Migration failed: {e}", file=sys.stderr)
+        sys.exit(1)
+    finally:
+        if conn:
+            conn.close()
+
+if __name__ == "__main__":
+    migrate_database()

+ 48 - 0
remove-service.sh

@@ -0,0 +1,48 @@
+#!/bin/bash
+
+# Uninstall script for absRecommend systemd service
+
+set -e  # Exit on error
+
+SERVICE_NAME="absrecommend"
+
+echo "========================================="
+echo "absRecommend Service Removal"
+echo "========================================="
+echo ""
+
+# Check if running as root
+if [ "$EUID" -eq 0 ]; then
+    echo "ERROR: Please run this script as a regular user (not root/sudo)"
+    echo "The script will prompt for sudo password when needed"
+    exit 1
+fi
+
+# Stop service
+echo "Stopping service..."
+sudo systemctl stop $SERVICE_NAME || true
+
+# Disable service
+echo "Disabling service..."
+sudo systemctl disable $SERVICE_NAME || true
+
+# Remove service file
+echo "Removing service file..."
+sudo rm -f /etc/systemd/system/${SERVICE_NAME}.service
+
+# Reload systemd
+echo "Reloading systemd..."
+sudo systemctl daemon-reload
+
+# Reset failed state
+sudo systemctl reset-failed || true
+
+echo ""
+echo "========================================="
+echo "Service Removed Successfully"
+echo "========================================="
+echo ""
+echo "The service has been stopped, disabled, and removed."
+echo "You can now run the application manually with:"
+echo "  ./venv/bin/python main.py"
+echo ""

+ 71 - 0
reset-password.py

@@ -0,0 +1,71 @@
+#!/usr/bin/env python3
+"""Reset user password"""
+
+import asyncio
+import sys
+from app.database import async_session
+from app.auth import get_password_hash
+from app.models import User
+from sqlalchemy import select
+
+async def reset_password(username: str, new_password: str):
+    """Reset password for a user"""
+    async with async_session() as db:
+        # Find user
+        result = await db.execute(
+            select(User).where(User.username == username)
+        )
+        user = result.scalar_one_or_none()
+
+        if not user:
+            print(f"✗ User '{username}' not found")
+            return
+
+        # Update password
+        user.hashed_password = get_password_hash(new_password)
+        await db.commit()
+
+        print(f"✓ Password reset successful for user: {username}")
+        print(f"  User ID: {user.id}")
+        print(f"  Email: {user.email}")
+
+async def list_users():
+    """List all users"""
+    async with async_session() as db:
+        result = await db.execute(select(User))
+        users = result.scalars().all()
+
+        if not users:
+            print("No users found in database")
+            return
+
+        print("Users in database:")
+        print("-" * 70)
+        for user in users:
+            print(f"  ID: {user.id}")
+            print(f"  Username: {user.username}")
+            print(f"  Email: {user.email}")
+            print(f"  Active: {user.is_active}")
+            print(f"  Created: {user.created_at}")
+            print("-" * 70)
+
+if __name__ == "__main__":
+    if len(sys.argv) == 1 or sys.argv[1] == "list":
+        print("Listing all users...")
+        print("=" * 70)
+        asyncio.run(list_users())
+    elif len(sys.argv) == 3:
+        username = sys.argv[1]
+        new_password = sys.argv[2]
+        print(f"Resetting password for user: {username}")
+        print("=" * 70)
+        asyncio.run(reset_password(username, new_password))
+    else:
+        print("Usage:")
+        print("  List users:        ./reset-password.py list")
+        print("  Reset password:    ./reset-password.py <username> <new_password>")
+        print("")
+        print("Examples:")
+        print("  ./reset-password.py list")
+        print("  ./reset-password.py Blance newpassword123")
+        sys.exit(1)

+ 14 - 0
restart-service.sh

@@ -0,0 +1,14 @@
+#!/bin/bash
+
+# Quick restart script for the absRecommend service
+
+echo "Restarting absRecommend service..."
+sudo systemctl restart absrecommend
+
+echo "Checking status..."
+sleep 1
+sudo systemctl status absrecommend --no-pager -l
+
+echo ""
+echo "Service restarted successfully!"
+echo "Access the app at: http://0.0.0.0:8000"

+ 80 - 0
setup-service.sh

@@ -0,0 +1,80 @@
+#!/bin/bash
+
+# Setup script for absRecommend systemd service
+# This script installs and configures the service to run on boot with auto-restart
+
+set -e  # Exit on error
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SERVICE_FILE="$SCRIPT_DIR/absrecommend.service"
+SERVICE_NAME="absrecommend"
+
+echo "========================================="
+echo "absRecommend Service Setup"
+echo "========================================="
+echo ""
+
+# Check if running as root
+if [ "$EUID" -eq 0 ]; then
+    echo "ERROR: Please run this script as a regular user (not root/sudo)"
+    echo "The script will prompt for sudo password when needed"
+    exit 1
+fi
+
+# Check if service file exists
+if [ ! -f "$SERVICE_FILE" ]; then
+    echo "ERROR: Service file not found at $SERVICE_FILE"
+    exit 1
+fi
+
+# Stop any existing background processes
+echo "Stopping any running instances..."
+pkill -f "python.*main.py" || true
+sleep 2
+
+# Copy service file
+echo "Installing service file..."
+sudo cp "$SERVICE_FILE" /etc/systemd/system/
+
+# Reload systemd
+echo "Reloading systemd..."
+sudo systemctl daemon-reload
+
+# Enable service
+echo "Enabling service to start on boot..."
+sudo systemctl enable $SERVICE_NAME
+
+# Start service
+echo "Starting service..."
+sudo systemctl start $SERVICE_NAME
+
+# Wait a moment for service to start
+sleep 2
+
+# Check status
+echo ""
+echo "========================================="
+echo "Service Status:"
+echo "========================================="
+sudo systemctl status $SERVICE_NAME --no-pager || true
+
+echo ""
+echo "========================================="
+echo "Setup Complete!"
+echo "========================================="
+echo ""
+echo "The service is now running and will:"
+echo "  - Start automatically on system boot"
+echo "  - Restart automatically if it crashes"
+echo "  - Wait 5 seconds between restart attempts"
+echo ""
+echo "Useful commands:"
+echo "  View logs:       sudo journalctl -u $SERVICE_NAME -f"
+echo "  Restart:         sudo systemctl restart $SERVICE_NAME"
+echo "  Stop:            sudo systemctl stop $SERVICE_NAME"
+echo "  Start:           sudo systemctl start $SERVICE_NAME"
+echo "  Status:          sudo systemctl status $SERVICE_NAME"
+echo "  Disable startup: sudo systemctl disable $SERVICE_NAME"
+echo ""
+echo "Application running at: http://0.0.0.0:8000"
+echo ""

+ 37 - 0
test-auth.py

@@ -0,0 +1,37 @@
+#!/usr/bin/env python3
+"""Test script to verify user authentication"""
+
+import asyncio
+import sys
+from app.database import async_session
+from app.auth import authenticate_user
+
+async def test_login(username: str, password: str):
+    """Test user login"""
+    async with async_session() as db:
+        user = await authenticate_user(db, username, password)
+        if user:
+            print(f"✓ Authentication successful!")
+            print(f"  User ID: {user.id}")
+            print(f"  Username: {user.username}")
+            print(f"  Email: {user.email}")
+            print(f"  Active: {user.is_active}")
+        else:
+            print(f"✗ Authentication failed for username: {username}")
+            print(f"  Possible issues:")
+            print(f"    - Username doesn't exist")
+            print(f"    - Password is incorrect")
+            print(f"    - User account is inactive")
+
+if __name__ == "__main__":
+    if len(sys.argv) != 3:
+        print("Usage: ./test-auth.py <username> <password>")
+        print("Example: ./test-auth.py Blance mypassword")
+        sys.exit(1)
+
+    username = sys.argv[1]
+    password = sys.argv[2]
+
+    print(f"Testing authentication for user: {username}")
+    print("-" * 50)
+    asyncio.run(test_login(username, password))