Sfoglia il codice sorgente

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 3 mesi fa
parent
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.
 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
 ## 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.
 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 typing import Optional
 from fastapi import Depends, HTTPException, status, Request, Response
 from fastapi import Depends, HTTPException, status, Request, Response
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select
+from sqlalchemy import select, func
 import bcrypt
 import bcrypt
 from cryptography.fernet import Fernet
 from cryptography.fernet import Fernet
 from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
 from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
@@ -169,6 +169,26 @@ async def get_current_user_optional(
         return None
         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(
 async def authenticate_user(
     db: AsyncSession,
     db: AsyncSession,
     username: str,
     username: str,
@@ -179,9 +199,9 @@ async def authenticate_user(
 
 
     Returns User if authentication succeeds, None otherwise.
     Returns User if authentication succeeds, None otherwise.
     """
     """
-    # Find user by username
+    # Find user by username (case-insensitive)
     result = await db.execute(
     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()
     user = result.scalar_one_or_none()
 
 
@@ -217,9 +237,9 @@ async def create_user(
 
 
     Raises HTTPException if username or email already exists.
     Raises HTTPException if username or email already exists.
     """
     """
-    # Check if username already exists
+    # Check if username already exists (case-insensitive)
     result = await db.execute(
     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():
     if result.scalar_one_or_none():
         raise HTTPException(
         raise HTTPException(
@@ -237,6 +257,11 @@ async def create_user(
             detail="Email already registered"
             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
     # Create new user
     user = User(
     user = User(
         username=username,
         username=username,
@@ -246,7 +271,8 @@ async def create_user(
         abs_api_token=encrypt_token(abs_api_token),
         abs_api_token=encrypt_token(abs_api_token),
         display_name=display_name or username,
         display_name=display_name or username,
         created_at=datetime.now(),
         created_at=datetime.now(),
-        is_active=True
+        is_active=True,
+        is_admin=is_first_user  # First user becomes admin
     )
     )
 
 
     db.add(user)
     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.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
 from sqlalchemy.orm import 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
 from app.config import get_settings
 
 
 
 
@@ -44,6 +44,20 @@ async def init_db():
         # Create all tables (will skip existing ones)
         # Create all tables (will skip existing ones)
         await conn.run_sync(Base.metadata.create_all)
         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():
 async def get_db():
     """Dependency for getting database session."""
     """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.staticfiles import StaticFiles
 from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
 from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.ext.asyncio import AsyncSession
-from sqlalchemy import select
+from sqlalchemy import select, func
 from contextlib import asynccontextmanager
 from contextlib import asynccontextmanager
 import json
 import json
 from datetime import datetime
 from datetime import datetime
 from typing import Optional
 from typing import Optional
 
 
 from app.database import init_db, get_db
 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.abs_client import get_abs_client
 from app.recommender import BookRecommender
 from app.recommender import BookRecommender
 from app.config import get_settings
 from app.config import get_settings
 from app.auth import (
 from app.auth import (
     get_current_user,
     get_current_user,
     get_current_user_optional,
     get_current_user_optional,
+    get_current_admin,
     authenticate_user,
     authenticate_user,
     create_user,
     create_user,
     set_session_cookie,
     set_session_cookie,
@@ -98,7 +99,20 @@ async def home(
         .order_by(Recommendation.created_at.desc())
         .order_by(Recommendation.created_at.desc())
         .limit(5)
         .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(
     return templates.TemplateResponse(
         "index.html",
         "index.html",
@@ -122,7 +136,6 @@ async def login_page(request: Request):
 
 
 @app.post("/api/auth/login")
 @app.post("/api/auth/login")
 async def login(
 async def login(
-    response: Response,
     username: str = Form(...),
     username: str = Form(...),
     password: str = Form(...),
     password: str = Form(...),
     db: AsyncSession = Depends(get_db)
     db: AsyncSession = Depends(get_db)
@@ -136,18 +149,11 @@ async def login(
             detail="Incorrect username or password"
             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)
 @app.get("/register", response_class=HTMLResponse)
@@ -158,7 +164,6 @@ async def register_page(request: Request):
 
 
 @app.post("/api/auth/register")
 @app.post("/api/auth/register")
 async def register(
 async def register(
-    response: Response,
     username: str = Form(...),
     username: str = Form(...),
     email: str = Form(...),
     email: str = Form(...),
     password: str = Form(...),
     password: str = Form(...),
@@ -169,6 +174,22 @@ async def register(
 ):
 ):
     """Register a new user."""
     """Register a new user."""
     try:
     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(
         user = await create_user(
             db=db,
             db=db,
             username=username,
             username=username,
@@ -179,18 +200,11 @@ async def register(
             display_name=display_name
             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:
     except HTTPException as e:
         raise e
         raise e
@@ -498,7 +512,7 @@ async def get_reading_stats(
         end_dt = datetime.fromisoformat(end_date) if end_date else None
         end_dt = datetime.fromisoformat(end_date) if end_date else None
 
 
         # Calculate stats
         # 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)
         stats = await stats_service.calculate_stats(start_dt, end_dt)
 
 
         return JSONResponse(stats)
         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")
 @app.get("/health")
 async def health_check():
 async def health_check():
     """Health check endpoint."""
     """Health check endpoint."""

+ 11 - 0
app/models.py

@@ -25,6 +25,7 @@ class User(Base):
     created_at = Column(DateTime, default=func.now())
     created_at = Column(DateTime, default=func.now())
     last_login = Column(DateTime)
     last_login = Column(DateTime)
     is_active = Column(Boolean, default=True)
     is_active = Column(Boolean, default=True)
+    is_admin = Column(Boolean, default=False)
 
 
     # Relationships
     # Relationships
     listening_sessions = relationship("ListeningSession", back_populates="user", cascade="all, delete-orphan")
     listening_sessions = relationship("ListeningSession", back_populates="user", cascade="all, delete-orphan")
@@ -94,3 +95,13 @@ class Recommendation(Base):
 
 
     # Relationships
     # Relationships
     user = relationship("User", back_populates="recommendations")
     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:
 class ReadingStatsService:
     """Service for calculating reading statistics."""
     """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.
         Initialize statistics service for a user.
 
 
         Args:
         Args:
             db: Database session
             db: Database session
             user_id: User ID to calculate stats for
             user_id: User ID to calculate stats for
+            abs_url: Audiobookshelf server URL for constructing full cover URLs
         """
         """
         self.db = db
         self.db = db
         self.user_id = user_id
         self.user_id = user_id
+        self.abs_url = abs_url
 
 
     async def calculate_stats(
     async def calculate_stats(
         self,
         self,
@@ -90,8 +92,8 @@ class ReadingStatsService:
         # Calculate current streak
         # Calculate current streak
         streak = await self._calculate_streak(finished_sessions)
         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 {
         return {
             "total_books": len(finished_sessions),
             "total_books": len(finished_sessions),
@@ -245,6 +247,13 @@ class ReadingStatsService:
                 duration_hours = (session.finished_at - session.started_at).total_seconds() / 3600
                 duration_hours = (session.finished_at - session.started_at).total_seconds() / 3600
                 listening_duration = round(duration_hours, 1)
                 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({
             recent.append({
                 "book_id": book.id,
                 "book_id": book.id,
                 "title": book.title,
                 "title": book.title,
@@ -252,7 +261,7 @@ class ReadingStatsService:
                 "finished_at": session.finished_at.isoformat(),
                 "finished_at": session.finished_at.isoformat(),
                 "rating": session.rating,
                 "rating": session.rating,
                 "listening_duration": listening_duration,
                 "listening_duration": listening_duration,
-                "cover_url": book.cover_url
+                "cover_url": cover_url
             })
             })
 
 
         return recent
         return recent

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

@@ -637,3 +637,107 @@ header h1 {
         grid-template-columns: repeat(2, 1fr);
         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 ====================
 // ==================== Authentication Functions ====================
 
 
 async function handleLogin(event) {
 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) {
 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() {
 async function logout() {

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

@@ -34,6 +34,9 @@ async function loadReadingStats() {
 
 
     } catch (error) {
     } catch (error) {
         console.error('Error loading stats:', 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');
         showMessage('Error loading statistics: ' + error.message, 'error');
     }
     }
 }
 }
@@ -141,9 +144,11 @@ function renderRecentBooks(recentBooks) {
 
 
     if (recentBooks.length === 0) {
     if (recentBooks.length === 0) {
         emptyEl.classList.remove('hidden');
         emptyEl.classList.remove('hidden');
+        listEl.classList.add('hidden');
         return;
         return;
     }
     }
 
 
+    emptyEl.classList.add('hidden');
     listEl.classList.remove('hidden');
     listEl.classList.remove('hidden');
 
 
     const html = recentBooks.map(book => {
     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">
             <ul class="nav-links">
                 <li><a href="/">Dashboard</a></li>
                 <li><a href="/">Dashboard</a></li>
                 <li><a href="/reading-log">Reading Log</a></li>
                 <li><a href="/reading-log">Reading Log</a></li>
+                {% if user.is_admin %}
+                <li><a href="/admin">Admin</a></li>
+                {% endif %}
             </ul>
             </ul>
             <div class="nav-user">
             <div class="nav-user">
                 <span class="user-name">{{ user.display_name }}</span>
                 <span class="user-name">{{ user.display_name }}</span>

+ 1 - 3
app/templates/index.html

@@ -32,9 +32,7 @@
                     </div>
                     </div>
                     {% if rec.genres %}
                     {% if rec.genres %}
                         <div class="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>
                             <span class="genre-tag">{{ genre }}</span>
                             {% endfor %}
                             {% endfor %}
                         </div>
                         </div>

+ 1 - 1
app/templates/login.html

@@ -14,7 +14,7 @@
 
 
             <div id="message" class="message hidden"></div>
             <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">
                 <div class="form-group">
                     <label for="username">Username</label>
                     <label for="username">Username</label>
                     <input
                     <input

+ 1 - 1
app/templates/register.html

@@ -14,7 +14,7 @@
 
 
             <div id="message" class="message hidden"></div>
             <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">
                 <div class="form-group">
                     <label for="username">Username</label>
                     <label for="username">Username</label>
                     <input
                     <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))