Add OIDC authentication and multi-user support (#606)

Closes #552

## Summary

Adds OIDC authentication and multi-user support to Shelfmark. Users can
now be managed individually with per-user download settings, while
maintaining full backwards compatibility with existing auth modes
(no-auth, builtin, proxy, CWA).

### Authentication
- **OIDC login** with PKCE, auto-discovery, group-based admin mapping
- **Password fallback** when OIDC is enabled (prevents admin lockout)
- **Auto-provisioning** of OIDC users (configurable on/off)
- **Email-based linking** of pre-created users to OIDC accounts
- **Lockout prevention** — requires a local admin before OIDC can be
enabled

### User Management
- **SQLite user database** (`users.db`) with admin CRUD API
- **Users management tab** in settings UI (admin-only)
- **Settings restricted to admins** in multi-user modes (builtin/OIDC) —
non-admin users cannot access settings
- Create, edit, and delete users with role assignment (admin/user)
- Password management for builtin auth users
- OIDC users shown with provider badge (password fields hidden)
- Per-user configurable settings:
  - **Download destination** — custom folder path per user
- **BookLore library & path** — dropdown select, each user's books go to
their own library
  - **Email recipients** — per-user email delivery targets
- **`{User}` template variable** — use in destination paths (e.g.,
`/books/{User}/`)
- Settings override model: per-user values override globals, empty/unset
falls back to global defaults

### Download Scoping
- **Per-user download visibility** — non-admins only see their own
downloads
- **Username display** in downloads sidebar (shows who requested each
download)
- **WebSocket room-based filtering** — admins see all, users see only
their own
- **Download progress scoping** — progress events routed to correct user
rooms

### BookLore Integration
- **Dynamic dropdown selects** for library/path (replaces text inputs)
- **Per-user library/path overrides** via user settings
- **Options cache refresh** after Test Connection

### Security
- SQL injection prevention (column whitelist on user updates)
- Generic OIDC error messages (no internal detail leakage)
- Admin self-deletion and last-local-admin deletion guards
- OIDC role overwrite fix (only updates role when admin_group is
configured)

## Migration

**No migration script needed.** The `users.db` is created automatically
on first startup. Existing builtin auth users are auto-migrated to the
database on their first login. All other auth modes (no-auth, proxy,
CWA) continue working unchanged.

## Test Plan

- [x] All 519 tests passing, 0 failures
- [ ] Test no-auth mode: settings accessible, downloads work without
login
- [ ] Test builtin auth: legacy credentials auto-migrate on login, new
users can be created
- [ ] Test OIDC auth: login flow, callback, auto-provisioning,
group-based admin
- [ ] Test CWA auth: unchanged behavior
- [ ] Test proxy auth: unchanged behavior
- [ ] Test per-user downloads: non-admin sees only own downloads
- [ ] Test BookLore dropdowns: library/path selection, per-user
overrides
- [ ] Test Docker build: no Dockerfile changes needed

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Joshua Saul
2026-02-11 17:44:27 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent b5923635a6
commit 2d2f54729f
43 changed files with 4646 additions and 102 deletions
+1
View File
@@ -232,3 +232,4 @@ pyrightconfig.json
AGENTS.md
.claude/
.playwright-mcp/
frontend-dist/
+23 -5
View File
@@ -151,15 +151,21 @@ If you need Cloudflare bypass with the Lite image, configure an external resolve
## 🔐 Authentication
Authentication is optional but recommended for shared or exposed instances. Three authentication methods are available in Settings:
Authentication is optional but recommended for shared or exposed instances. Four authentication methods are available in Settings:
**1. Single Username/Password**
**1. Built-in Username/Password**
**2. Proxy (Forward) Authentication**
Multi-user support with admin user management. The first user is always admin. Admins can create additional users, set per-user download destinations, and manage roles.
**2. OpenID Connect (OIDC)**
Integrate with any OIDC provider (Authentik, Keycloak, Pocket ID, etc.) for SSO. Supports auto-provisioning, group-based admin mapping, and per-user download settings. Configure your provider's discovery URL, client ID, and client secret in Settings.
**3. Proxy (Forward) Authentication**
Proxy auth trusts headers set by your reverse proxy (e.g. `X-Auth-User`). Ensure Shelfmark is not directly exposed, and configure your proxy to strip/overwrite these headers for all inbound requests.
**3. Calibre-Web Database**
**4. Calibre-Web Database**
If you're running Calibre-Web, you can reuse its user database by mounting it:
@@ -168,6 +174,15 @@ volumes:
- /path/to/calibre-web/app.db:/auth/app.db:ro
```
### Multi-User Features (Built-in & OIDC)
- Admin user management panel in Settings
- Per-user download destination overrides
- Per-user BookLore library/path overrides
- Per-user email recipient overrides
- Download queue scoped per user (admins see all)
- `{User}` template variable for organizing downloads by user
## Health Monitoring
The application exposes a health endpoint at `/api/health` (no authentication required). Add a health check to your compose:
@@ -215,13 +230,16 @@ The frontend dev server proxies to the backend on port 8084.
├─────────────────────────────────────────────────────────────┤
│ Flask Backend │
│ (REST API + WebSocket) │
├─────────────────────────────────────────────────────────────┤
│ Authentication │
│ (Built-in / OIDC / Proxy / CWA / None) │
├───────────────────┬─────────────────────┬───────────────────┤
│ Metadata Providers│ Download Queue │ Cloudflare │
│ │ & Orchestrator │ Bypass │
├───────────────────┼─────────────────────┼───────────────────┤
│ • Hardcover │ • Task scheduling │ • Internal │
│ • Open Library │ • Progress tracking │ • External │
│ │ • Retry logic │ (FlareSolverr) │
│ │ • Per-user scoping │ (FlareSolverr) │
├───────────────────┴─────────────────────┴───────────────────┤
│ Release Sources │
├─────────────────────────────────────────────────────────────┤
+60 -8
View File
@@ -4,7 +4,7 @@ import logging
import threading
from typing import Optional, Dict, Any, Callable, List
from flask_socketio import SocketIO
from flask_socketio import SocketIO, join_room, leave_room
logger = logging.getLogger(__name__)
@@ -20,6 +20,9 @@ class WebSocketManager:
self._on_first_connect_callbacks: List[Callable[[], None]] = []
self._on_all_disconnect_callbacks: List[Callable[[], None]] = []
self._needs_rewarm = False # Flag to trigger warmup callbacks on next connect
self._user_rooms: Dict[str, int] = {} # room_name -> ref count
self._rooms_lock = threading.Lock()
self._queue_status_fn: Optional[Callable] = None # Reference to queue_status()
def init_app(self, app, socketio: SocketIO):
"""Initialize the WebSocket manager with Flask-SocketIO instance."""
@@ -100,19 +103,62 @@ class WebSocketManager:
"""Check if WebSocket is enabled and ready."""
return self._enabled and self.socketio is not None
def set_queue_status_fn(self, fn: Callable):
"""Set the queue_status function reference for per-room filtering."""
self._queue_status_fn = fn
def join_user_room(self, sid: str, is_admin: bool, db_user_id: Optional[int] = None):
"""Join the appropriate room based on user role."""
if is_admin or db_user_id is None:
join_room("admins", sid=sid)
else:
room = f"user_{db_user_id}"
join_room(room, sid=sid)
with self._rooms_lock:
self._user_rooms[room] = self._user_rooms.get(room, 0) + 1
def leave_user_room(self, sid: str, is_admin: bool, db_user_id: Optional[int] = None):
"""Leave the user's room on disconnect."""
if is_admin or db_user_id is None:
leave_room("admins", sid=sid)
else:
room = f"user_{db_user_id}"
leave_room(room, sid=sid)
with self._rooms_lock:
count = self._user_rooms.get(room, 1) - 1
if count <= 0:
self._user_rooms.pop(room, None)
else:
self._user_rooms[room] = count
def broadcast_status_update(self, status_data: Dict[str, Any]):
"""Broadcast status update to all connected clients."""
"""Broadcast status update to all connected clients, filtered by user room."""
if not self.is_enabled():
return
try:
# When calling socketio.emit() outside event handlers, it broadcasts by default
self.socketio.emit('status_update', status_data)
logger.debug(f"Broadcasted status update to all clients")
# Admins (and no-auth users) get full status
self.socketio.emit('status_update', status_data, to="admins")
# Each user room gets filtered status
with self._rooms_lock:
active_rooms = list(self._user_rooms.keys())
if active_rooms and self._queue_status_fn:
for room in active_rooms:
try:
# Extract user_id from room name "user_123"
uid = int(room.split("_", 1)[1])
filtered = self._queue_status_fn(user_id=uid)
self.socketio.emit('status_update', filtered, to=room)
except Exception as e:
logger.error(f"Failed to send status update for room {room}: {e}")
logger.debug("Broadcasted status update to all rooms")
except Exception as e:
logger.error(f"Error broadcasting status update: {e}")
def broadcast_download_progress(self, book_id: str, progress: float, status: str):
def broadcast_download_progress(self, book_id: str, progress: float, status: str, user_id: Optional[int] = None):
"""Broadcast download progress update for a specific book."""
if not self.is_enabled():
return
@@ -123,8 +169,14 @@ class WebSocketManager:
'progress': progress,
'status': status
}
# When calling socketio.emit() outside event handlers, it broadcasts by default
self.socketio.emit('download_progress', data)
# Admins always see all progress
self.socketio.emit('download_progress', data, to="admins")
# If task belongs to a specific user, send to their room too
if user_id is not None:
room = f"user_{user_id}"
with self._rooms_lock:
if room in self._user_rooms:
self.socketio.emit('download_progress', data, to=room)
logger.debug(f"Broadcasted progress for book {book_id}: {progress}%")
except Exception as e:
logger.error(f"Error broadcasting download progress: {e}")
+143
View File
@@ -14,6 +14,7 @@ from shelfmark.core.settings_registry import (
PasswordField,
CheckboxField,
ActionButton,
TagListField,
)
logger = setup_logger(__name__)
@@ -114,6 +115,29 @@ def _on_save_security(values: Dict[str, Any]) -> Dict[str, Any]:
Returns:
Dict with processed values to save and any validation errors.
"""
# If switching to OIDC, ensure a local admin exists as fallback
if values.get("AUTH_METHOD") == "oidc":
import os
from shelfmark.core.user_db import UserDB
db_path = os.path.join(os.environ.get("CONFIG_DIR", "/config"), "users.db")
udb = UserDB(db_path)
udb.initialize()
users = udb.list_users()
has_local_admin = any(
u.get("password_hash") and u.get("role") == "admin"
for u in users
)
if not has_local_admin:
return {
"error": True,
"message": (
"Create a local admin account first (Users tab) before enabling OIDC. "
"This ensures you can still log in with a password if SSO is unavailable."
),
"values": values,
}
password = values.get("BUILTIN_PASSWORD", "")
password_confirm = values.get("BUILTIN_PASSWORD_CONFIRM", "")
@@ -157,6 +181,33 @@ def _on_save_security(values: Dict[str, Any]) -> Dict[str, Any]:
return {"error": False, "values": values}
def _test_oidc_connection() -> Dict[str, Any]:
"""Test OIDC connection by fetching the discovery document."""
import requests
try:
config = load_config_file("security")
discovery_url = config.get("OIDC_DISCOVERY_URL", "")
if not discovery_url:
return {"success": False, "message": "Discovery URL is not configured."}
resp = requests.get(discovery_url, timeout=10)
resp.raise_for_status()
doc = resp.json()
# Validate required fields
required = ["issuer", "authorization_endpoint", "token_endpoint"]
missing = [f for f in required if f not in doc]
if missing:
return {"success": False, "message": f"Discovery document missing fields: {', '.join(missing)}"}
return {"success": True, "message": f"Connected to {doc['issuer']}"}
except Exception as e:
logger.error(f"OIDC connection test failed: {e}")
return {"success": False, "message": f"Connection failed: {str(e)}"}
@register_settings("security", "Security", icon="shield", order=5)
def security_settings():
"""Security and authentication settings."""
@@ -168,6 +219,7 @@ def security_settings():
{"label": "No Authentication", "value": "none"},
{"label": "Username/Password", "value": "builtin"},
{"label": "Proxy Authentication", "value": "proxy"},
{"label": "OIDC (OpenID Connect)", "value": "oidc"},
]
if cwa_db_available:
auth_method_options.append({"label": "Calibre-Web Database", "value": "cwa"})
@@ -281,6 +333,97 @@ def security_settings():
env_supported=False,
show_when={"field": "AUTH_METHOD", "value": "cwa"},
),
# === OIDC SETTINGS ===
TextField(
key="OIDC_DISCOVERY_URL",
label="Discovery URL",
description=(
"OpenID Connect discovery endpoint URL."
" Usually ends with /.well-known/openid-configuration."
),
placeholder="https://auth.example.com/.well-known/openid-configuration",
required=True,
env_supported=False,
show_when={"field": "AUTH_METHOD", "value": "oidc"},
),
TextField(
key="OIDC_CLIENT_ID",
label="Client ID",
description="OAuth2 client ID from your identity provider.",
placeholder="shelfmark",
required=True,
env_supported=False,
show_when={"field": "AUTH_METHOD", "value": "oidc"},
),
PasswordField(
key="OIDC_CLIENT_SECRET",
label="Client Secret",
description="OAuth2 client secret from your identity provider.",
required=True,
env_supported=False,
show_when={"field": "AUTH_METHOD", "value": "oidc"},
),
TagListField(
key="OIDC_SCOPES",
label="Scopes",
description="OAuth2 scopes to request from the identity provider. Managed automatically: includes essential scopes and the group claim when using admin group authorization.",
default=["openid", "email", "profile"],
env_supported=False,
show_when={"field": "AUTH_METHOD", "value": "oidc"},
),
TextField(
key="OIDC_GROUP_CLAIM",
label="Group Claim Name",
description=(
"The name of the claim in the ID token that contains user groups."
),
placeholder="groups",
default="groups",
env_supported=False,
show_when={"field": "AUTH_METHOD", "value": "oidc"},
),
TextField(
key="OIDC_ADMIN_GROUP",
label="Admin Group Name",
description=(
"Users in this group will be given admin access (if enabled below). "
"Leave empty to use database roles only."
),
placeholder="shelfmark-admins",
default="",
env_supported=False,
show_when={"field": "AUTH_METHOD", "value": "oidc"},
),
CheckboxField(
key="OIDC_USE_ADMIN_GROUP",
label="Use Admin Group for Authorization",
description=(
"When enabled, users in the Admin Group are granted admin access. "
"When disabled, admin access is determined solely by database roles."
),
default=True,
env_supported=False,
show_when={"field": "AUTH_METHOD", "value": "oidc"},
),
CheckboxField(
key="OIDC_AUTO_PROVISION",
label="Auto-Provision Users",
description=(
"Automatically create a user account on first OIDC login."
" When disabled, users must be pre-created by an admin."
),
default=True,
env_supported=False,
show_when={"field": "AUTH_METHOD", "value": "oidc"},
),
ActionButton(
key="test_oidc",
label="Test Connection",
description="Fetch the OIDC discovery document and validate configuration.",
style="primary",
callback=_test_oidc_connection,
show_when={"field": "AUTH_METHOD", "value": "oidc"},
),
]
return fields
+5 -5
View File
@@ -799,7 +799,7 @@ def download_settings():
TextField(
key="DESTINATION",
label="Destination",
description="Directory where downloaded files are saved.",
description="Directory where downloaded files are saved. Use {User} for per-user folders (e.g. /books/{User}).",
default="/books",
required=True,
env_var="INGEST_DIR", # Legacy env var name for backwards compatibility
@@ -839,7 +839,7 @@ def download_settings():
TextField(
key="TEMPLATE_RENAME",
label="Naming Template",
description="Variables: {Author}, {Title}, {Year}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\\'); use Organize for folders.",
description="Variables: {Author}, {Title}, {Year}, {User}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\\'); use Organize for folders.",
default="{Author} - {Title} ({Year})",
placeholder="{Author} - {Title} ({Year})",
show_when=[
@@ -851,7 +851,7 @@ def download_settings():
TextField(
key="TEMPLATE_ORGANIZE",
label="Path Template",
description="Use / to create folders. Variables: {Author}, {Title}, {Year}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.",
description="Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}. Universal adds: {Series}, {SeriesPosition}, {Subtitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.",
default="{Author}/{Title} ({Year})",
placeholder="{Author}/{Series/}{Title} ({Year})",
show_when=[
@@ -1074,7 +1074,7 @@ def download_settings():
TextField(
key="TEMPLATE_AUDIOBOOK_RENAME",
label="Naming Template",
description="Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\\'); use Organize for folders.",
description="Variables: {Author}, {Title}, {Year}, {User}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\\'); use Organize for folders.",
default="{Author} - {Title}",
placeholder="{Author} - {Title}{ - Part }{PartNumber}",
show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "rename"},
@@ -1084,7 +1084,7 @@ def download_settings():
TextField(
key="TEMPLATE_AUDIOBOOK_ORGANIZE",
label="Path Template",
description="Use / to create folders. Variables: {Author}, {Title}, {Year}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.",
description="Use / to create folders. Variables: {Author}, {Title}, {Year}, {User}, {Series}, {SeriesPosition}, {Subtitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.",
default="{Author}/{Title}",
placeholder="{Author}/{Series/}{Title}{ - Part }{PartNumber}",
show_when={"field": "FILE_ORGANIZATION_AUDIOBOOK", "value": "organize"},
+23
View File
@@ -0,0 +1,23 @@
"""Users settings tab registration.
This registers a 'users' tab in the settings sidebar.
The actual user management is handled by a custom frontend component
that talks to /api/admin/users endpoints.
"""
from shelfmark.core.settings_registry import (
HeadingField,
register_settings,
)
@register_settings("users", "Users", icon="users", order=6)
def users_settings():
"""User management tab - rendered as a custom component on the frontend."""
return [
HeadingField(
key="users_heading",
title="User Accounts",
description="Manage user accounts for multi-user authentication.",
),
]
+246
View File
@@ -0,0 +1,246 @@
"""Admin user management API routes.
Registers /api/admin/users CRUD endpoints for managing users.
All endpoints require admin session.
"""
from functools import wraps
from flask import Flask, jsonify, request, session
from werkzeug.security import generate_password_hash
from shelfmark.config.booklore_settings import (
get_booklore_library_options,
get_booklore_path_options,
)
from shelfmark.core.logger import setup_logger
from shelfmark.core.settings_registry import load_config_file
from shelfmark.core.user_db import UserDB
logger = setup_logger(__name__)
_DOWNLOAD_DEFAULTS = {
"BOOKS_OUTPUT_MODE": "folder",
"DESTINATION": "/books",
"BOOKLORE_LIBRARY_ID": "",
"BOOKLORE_PATH_ID": "",
"EMAIL_RECIPIENTS": [],
}
def _get_auth_mode():
"""Get current auth mode from config."""
try:
config = load_config_file("security")
return config.get("AUTH_METHOD", "none")
except Exception:
return "none"
def _require_admin(f):
"""Decorator to require admin session for admin routes.
In no-auth mode, everyone has access (is_admin defaults True).
In auth-required modes, requires an authenticated session with admin role.
"""
@wraps(f)
def decorated(*args, **kwargs):
auth_mode = _get_auth_mode()
if auth_mode != "none":
if "user_id" not in session:
return jsonify({"error": "Authentication required"}), 401
if not session.get("is_admin", False):
return jsonify({"error": "Admin access required"}), 403
return f(*args, **kwargs)
return decorated
def _sanitize_user(user: dict) -> dict:
"""Remove sensitive fields from user dict before returning to client."""
user.pop("password_hash", None)
return user
def register_admin_routes(app: Flask, user_db: UserDB) -> None:
"""Register admin user management routes on the Flask app."""
@app.route("/api/admin/users", methods=["GET"])
@_require_admin
def admin_list_users():
"""List all users."""
users = user_db.list_users()
return jsonify([_sanitize_user(u) for u in users])
@app.route("/api/admin/users", methods=["POST"])
@_require_admin
def admin_create_user():
"""Create a new user with password authentication."""
data = request.get_json() or {}
username = (data.get("username") or "").strip()
password = data.get("password", "")
email = (data.get("email") or "").strip() or None
display_name = (data.get("display_name") or "").strip() or None
role = data.get("role", "user")
if not username:
return jsonify({"error": "Username is required"}), 400
if not password or len(password) < 4:
return jsonify({"error": "Password must be at least 4 characters"}), 400
if role not in ("admin", "user"):
return jsonify({"error": "Role must be 'admin' or 'user'"}), 400
# First user is always admin
if not user_db.list_users():
role = "admin"
# Check if username already exists
if user_db.get_user(username=username):
return jsonify({"error": "Username already exists"}), 409
password_hash = generate_password_hash(password)
try:
user = user_db.create_user(
username=username,
password_hash=password_hash,
email=email,
display_name=display_name,
role=role,
)
except ValueError:
return jsonify({"error": "Username already exists"}), 409
logger.info(f"Admin created user: {username} (role={role})")
return jsonify(_sanitize_user(user)), 201
@app.route("/api/admin/users/<int:user_id>", methods=["GET"])
@_require_admin
def admin_get_user(user_id):
"""Get a user by ID with their settings."""
user = user_db.get_user(user_id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
result = _sanitize_user(user)
result["settings"] = user_db.get_user_settings(user_id)
return jsonify(result)
@app.route("/api/admin/users/<int:user_id>", methods=["PUT"])
@_require_admin
def admin_update_user(user_id):
"""Update user fields and/or settings."""
user = user_db.get_user(user_id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
data = request.get_json() or {}
# Handle optional password update
password = data.get("password", "")
if password:
if len(password) < 4:
return jsonify({"error": "Password must be at least 4 characters"}), 400
user_db.update_user(user_id, password_hash=generate_password_hash(password))
# Update user fields
user_fields = {}
for field in ("role", "email", "display_name"):
if field in data:
user_fields[field] = data[field]
if "role" in user_fields and user_fields["role"] not in ("admin", "user"):
return jsonify({"error": "Role must be 'admin' or 'user'"}), 400
# Prevent changing OIDC user role when group-based auth is enabled
if "role" in user_fields and user.get("oidc_subject") and user_fields["role"] != user.get("role"):
security_config = load_config_file("security")
use_admin_group = security_config.get("OIDC_USE_ADMIN_GROUP", True)
if use_admin_group:
admin_group = security_config.get("OIDC_ADMIN_GROUP", "")
msg = (
f"Admin roles for OIDC users are managed by the '{admin_group}' group in your identity provider"
if admin_group
else "Disable 'Use Admin Group for Authorization' in security settings to manage roles manually"
)
return jsonify({
"error": "Cannot change role for OIDC user when group-based authorization is enabled",
"message": msg,
}), 400
# Prevent demoting the last admin
if "role" in user_fields and user_fields["role"] != "admin":
if user.get("role") == "admin":
other_admins = [
u for u in user_db.list_users()
if u["role"] == "admin" and u["id"] != user_id
]
if not other_admins:
return jsonify({"error": "Cannot remove admin role from the last admin account"}), 400
if user_fields:
user_db.update_user(user_id, **user_fields)
# Update per-user settings
if "settings" in data and isinstance(data["settings"], dict):
user_db.set_user_settings(user_id, data["settings"])
updated = user_db.get_user(user_id=user_id)
result = _sanitize_user(updated)
result["settings"] = user_db.get_user_settings(user_id)
logger.info(f"Admin updated user {user_id}")
return jsonify(result)
@app.route("/api/admin/download-defaults", methods=["GET"])
@_require_admin
def admin_download_defaults():
"""Return global download settings relevant to per-user overrides."""
config = load_config_file("downloads")
keys = [
"BOOKS_OUTPUT_MODE",
"DESTINATION",
"BOOKLORE_LIBRARY_ID",
"BOOKLORE_PATH_ID",
"EMAIL_RECIPIENTS",
]
defaults = {k: config.get(k, _DOWNLOAD_DEFAULTS.get(k)) for k in keys}
# Include OIDC settings for UI warnings (e.g., when admin tries to set OIDC user role)
security_config = load_config_file("security")
defaults["OIDC_ADMIN_GROUP"] = security_config.get("OIDC_ADMIN_GROUP", "")
defaults["OIDC_USE_ADMIN_GROUP"] = security_config.get("OIDC_USE_ADMIN_GROUP", True)
defaults["OIDC_AUTO_PROVISION"] = security_config.get("OIDC_AUTO_PROVISION", True)
return jsonify(defaults)
@app.route("/api/admin/booklore-options", methods=["GET"])
@_require_admin
def admin_booklore_options():
"""Return available BookLore library and path options."""
return jsonify({
"libraries": get_booklore_library_options(),
"paths": get_booklore_path_options(),
})
@app.route("/api/admin/users/<int:user_id>", methods=["DELETE"])
@_require_admin
def admin_delete_user(user_id):
"""Delete a user."""
# Prevent self-deletion
if session.get("db_user_id") == user_id:
return jsonify({"error": "Cannot delete your own account"}), 400
user = user_db.get_user(user_id=user_id)
if not user:
return jsonify({"error": "User not found"}), 404
# Prevent deleting the last local admin
if user.get("role") == "admin" and user.get("password_hash"):
local_admins = [
u for u in user_db.list_users()
if u["role"] == "admin" and u.get("password_hash") and u["id"] != user_id
]
if not local_admins:
return jsonify({"error": "Cannot delete the last local admin account"}), 400
user_db.delete_user(user_id)
logger.info(f"Admin deleted user {user_id}: {user['username']}")
return jsonify({"success": True})
+4
View File
@@ -94,6 +94,10 @@ class DownloadTask:
output_mode: Optional[str] = None # e.g. "folder", "booklore", "email"
output_args: Dict[str, Any] = field(default_factory=dict) # Per-output parameters (e.g. email recipient)
# User association (multi-user support)
user_id: Optional[int] = None # DB user ID who queued this download
username: Optional[str] = None # Username for {User} template variable
# Runtime state
priority: int = 0
added_time: float = field(default_factory=time.time)
+1 -1
View File
@@ -12,7 +12,7 @@ logger = setup_logger(__name__)
# Known variable tokens, sorted longest-first to avoid partial matches
# e.g., "SeriesPosition" must match before "Series"
KNOWN_TOKENS = ['seriesposition', 'partnumber', 'subtitle', 'author', 'series', 'title', 'year']
KNOWN_TOKENS = ['seriesposition', 'partnumber', 'subtitle', 'author', 'series', 'title', 'year', 'user']
# Match any {...} block for template parsing
BRACE_PATTERN = re.compile(r'\{([^}]+)\}')
+102
View File
@@ -0,0 +1,102 @@
"""OIDC authentication helpers.
Handles group claim parsing, user info extraction, and user provisioning.
Flask route handlers are registered separately in main.py.
"""
from typing import Any, Dict, List, Optional
from shelfmark.core.logger import setup_logger
from shelfmark.core.user_db import UserDB
logger = setup_logger(__name__)
def parse_group_claims(id_token: Dict[str, Any], group_claim: str) -> List[str]:
"""Extract group list from an ID token claim.
Supports list, comma-separated string, or pipe-separated string.
Returns empty list if claim is missing.
"""
raw = id_token.get(group_claim)
if raw is None:
return []
if isinstance(raw, list):
return [str(g).strip() for g in raw if str(g).strip()]
if isinstance(raw, str):
delimiter = "," if "," in raw else "|"
return [g.strip() for g in raw.split(delimiter) if g.strip()]
return []
def extract_user_info(id_token: Dict[str, Any]) -> Dict[str, Any]:
"""Extract user info from OIDC ID token claims.
Returns a dict with keys: oidc_subject, username, email, display_name.
Falls back through preferred_username -> email -> sub for username.
"""
sub = id_token.get("sub", "")
email = id_token.get("email")
display_name = id_token.get("name")
username = id_token.get("preferred_username") or email or sub
return {
"oidc_subject": sub,
"username": username,
"email": email,
"display_name": display_name,
}
def provision_oidc_user(
db: UserDB,
user_info: Dict[str, Any],
is_admin: Optional[bool] = None,
) -> Dict[str, Any]:
"""Create or update a user from OIDC claims.
If a user with the same oidc_subject exists, updates their info.
If the username is taken by a different user, appends a numeric suffix.
Admin role is synced from IdP when group-based auth is enabled (is_admin is not None):
- is_admin=True → DB role = admin
- is_admin=False → DB role = user (downgrade if needed)
When group-based auth is disabled (is_admin=None), preserve existing DB role.
The database is always the single source of truth for auth checks.
"""
oidc_subject = user_info["oidc_subject"]
# Check if user already exists by OIDC subject
existing = db.get_user(oidc_subject=oidc_subject)
if existing:
updates: Dict[str, Any] = {
"email": user_info.get("email"),
"display_name": user_info.get("display_name"),
}
# Sync role from IdP when group-based auth is enabled
if is_admin is not None:
updates["role"] = "admin" if is_admin else "user"
db.update_user(existing["id"], **updates)
return db.get_user(user_id=existing["id"])
role = "admin" if is_admin else "user"
# New user — resolve username conflicts
username = user_info["username"] or oidc_subject
if db.get_user(username=username):
# Username taken, append suffix
suffix = 1
while db.get_user(username=f"{username}_{suffix}"):
suffix += 1
username = f"{username}_{suffix}"
user = db.create_user(
username=username,
email=user_info.get("email"),
display_name=user_info.get("display_name"),
oidc_subject=oidc_subject,
role=role,
)
logger.info(f"Provisioned OIDC user: {username} (sub={oidc_subject})")
return user
+272
View File
@@ -0,0 +1,272 @@
"""OIDC Flask route handlers.
Registers /api/auth/oidc/login and /api/auth/oidc/callback endpoints.
Separated from main.py to keep the OIDC logic self-contained.
"""
import hashlib
import secrets
import base64
from urllib.parse import urlencode
import requests as http_requests
from flask import Flask, redirect, request, session, jsonify
from shelfmark.core.logger import setup_logger
from shelfmark.core.oidc_auth import (
extract_user_info,
parse_group_claims,
provision_oidc_user,
)
from shelfmark.core.settings_registry import load_config_file
from shelfmark.core.user_db import UserDB
logger = setup_logger(__name__)
# Cache discovery document in memory (refreshed on restart)
_discovery_cache = {}
def _fetch_discovery(discovery_url: str) -> dict:
"""Fetch and cache the OIDC discovery document."""
if discovery_url in _discovery_cache:
return _discovery_cache[discovery_url]
resp = http_requests.get(discovery_url, timeout=10)
resp.raise_for_status()
doc = resp.json()
_discovery_cache[discovery_url] = doc
return doc
def _generate_pkce():
"""Generate PKCE code_verifier and code_challenge."""
code_verifier = secrets.token_urlsafe(64)
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
return code_verifier, code_challenge
def _exchange_code(
token_endpoint: str,
code: str,
code_verifier: str,
client_id: str,
client_secret: str,
redirect_uri: str,
userinfo_endpoint: str | None = None,
) -> dict:
"""Exchange authorization code for tokens and return ID token claims.
If id_token is missing from the response, falls back to calling the userinfo endpoint.
"""
resp = http_requests.post(
token_endpoint,
data={
"grant_type": "authorization_code",
"code": code,
"code_verifier": code_verifier,
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
},
timeout=10,
)
resp.raise_for_status()
token_data = resp.json()
# Decode ID token (we trust the IdP since we just exchanged the code over TLS)
import json as json_mod
id_token_raw = token_data.get("id_token", "")
if id_token_raw:
# Decode JWT payload without verification (already validated by TLS + code exchange)
payload = id_token_raw.split(".")[1]
# Add required Base64 padding (0-3 '=' characters)
payload += "=" * ((-len(payload)) % 4)
claims = json_mod.loads(base64.urlsafe_b64decode(payload))
else:
# No ID token in response — try userinfo endpoint
access_token = token_data.get("access_token")
if userinfo_endpoint and access_token:
try:
logger.info("ID token not found in token response, fetching from userinfo endpoint")
userinfo_resp = http_requests.get(
userinfo_endpoint,
headers={"Authorization": f"Bearer {access_token}"},
timeout=10,
)
userinfo_resp.raise_for_status()
claims = userinfo_resp.json()
except http_requests.RequestException as e:
logger.error(f"Failed to fetch userinfo: {e}")
raise ValueError("OIDC authentication failed: missing id_token and userinfo endpoint unavailable")
else:
logger.error("OIDC token response missing both id_token and access_token")
raise ValueError("OIDC authentication failed: invalid token response")
return claims
def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
"""Register OIDC authentication routes on the Flask app."""
@app.route("/api/auth/oidc/login", methods=["GET"])
def oidc_login():
"""Initiate OIDC login flow. Redirects to IdP."""
try:
config = load_config_file("security")
discovery_url = config.get("OIDC_DISCOVERY_URL", "")
client_id = config.get("OIDC_CLIENT_ID", "")
# Build scopes from config (user-editable) with openid guaranteed
configured_scopes = config.get("OIDC_SCOPES", ["openid", "email", "profile"])
scopes = list(dict.fromkeys(["openid"] + configured_scopes)) # dedupe, openid first
admin_group = config.get("OIDC_ADMIN_GROUP", "")
group_claim = config.get("OIDC_GROUP_CLAIM", "groups")
use_admin_group = config.get("OIDC_USE_ADMIN_GROUP", True)
# Add group claim to scopes when using admin group authorization
if admin_group and use_admin_group and group_claim and group_claim not in scopes:
scopes.append(group_claim)
if not discovery_url or not client_id:
return jsonify({"error": "OIDC not configured"}), 500
discovery = _fetch_discovery(discovery_url)
auth_endpoint = discovery["authorization_endpoint"]
# Generate PKCE and state
code_verifier, code_challenge = _generate_pkce()
state = secrets.token_urlsafe(32)
# Store in session for callback validation
session["oidc_state"] = state
session["oidc_code_verifier"] = code_verifier
# Build callback URL
redirect_uri = request.url_root.rstrip("/") + "/api/auth/oidc/callback"
params = {
"client_id": client_id,
"response_type": "code",
"scope": " ".join(scopes),
"redirect_uri": redirect_uri,
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
return redirect(f"{auth_endpoint}?{urlencode(params)}")
except Exception as e:
logger.error(f"OIDC login error: {e}")
return jsonify({"error": "OIDC login failed"}), 500
@app.route("/api/auth/oidc/callback", methods=["GET"])
def oidc_callback():
"""Handle OIDC callback from IdP."""
try:
code = request.args.get("code")
state = request.args.get("state")
error = request.args.get("error")
if error:
logger.warning(f"OIDC callback error from IdP: {error}")
return jsonify({"error": "Authentication failed"}), 400
# Validate state
expected_state = session.get("oidc_state")
code_verifier = session.get("oidc_code_verifier")
if not expected_state or not code_verifier:
return jsonify({"error": "Session expired. Please try logging in again."}), 400
if not state or state != expected_state:
return jsonify({"error": "Invalid state parameter"}), 400
if not code:
return jsonify({"error": "Missing authorization code"}), 400
# Load config
config = load_config_file("security")
discovery_url = config.get("OIDC_DISCOVERY_URL", "")
client_id = config.get("OIDC_CLIENT_ID", "")
client_secret = config.get("OIDC_CLIENT_SECRET", "")
group_claim = config.get("OIDC_GROUP_CLAIM", "groups")
admin_group = config.get("OIDC_ADMIN_GROUP", "")
use_admin_group = config.get("OIDC_USE_ADMIN_GROUP", True)
auto_provision = config.get("OIDC_AUTO_PROVISION", True)
discovery = _fetch_discovery(discovery_url)
token_endpoint = discovery["token_endpoint"]
userinfo_endpoint = discovery.get("userinfo_endpoint")
redirect_uri = request.url_root.rstrip("/") + "/api/auth/oidc/callback"
# Exchange code for tokens
claims = _exchange_code(
token_endpoint=token_endpoint,
code=code,
code_verifier=code_verifier,
client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
userinfo_endpoint=userinfo_endpoint,
)
# Extract user info and check groups
user_info = extract_user_info(claims)
groups = parse_group_claims(claims, group_claim)
# Determine admin status from group membership (if enabled)
is_admin = None
if admin_group and use_admin_group:
is_admin = admin_group in groups
# Check if user exists by OIDC subject first
existing_user = user_db.get_user(oidc_subject=user_info["oidc_subject"])
# If no match by subject, try email linking (for pre-created users)
# Only link when the IdP has verified the email to prevent privilege escalation
email_verified = claims.get("email_verified", False)
if not existing_user and user_info.get("email") and email_verified:
matching_users = [
u for u in user_db.list_users()
if u.get("email") and u["email"].lower() == user_info["email"].lower()
]
if len(matching_users) == 1:
existing_user = matching_users[0]
# Link OIDC subject to existing user
user_db.update_user(existing_user["id"], oidc_subject=user_info["oidc_subject"])
logger.info(f"Linked OIDC subject {user_info['oidc_subject']} to existing user {existing_user['username']}")
elif len(matching_users) > 1:
logger.warning(f"OIDC email linking skipped: multiple local accounts match email {user_info['email']}")
if not existing_user and not auto_provision:
logger.warning(f"OIDC login rejected: auto-provision disabled for {user_info['username']}")
return jsonify({"error": "Account not found. Contact your administrator."}), 403
# Provision or update user (database role is synced from group if enabled)
user = provision_oidc_user(user_db, user_info, is_admin=is_admin)
# Set session - database role is the single source of truth
session["user_id"] = user["username"]
session["is_admin"] = user.get("role") == "admin"
session["db_user_id"] = user["id"]
session.permanent = True
# Clean up OIDC session data
session.pop("oidc_state", None)
session.pop("oidc_code_verifier", None)
logger.info(f"OIDC login successful: {user['username']} (admin={is_admin})")
# Redirect to frontend (respect subpath deployments)
return redirect(request.script_root or "/")
except ValueError as e:
# Specific errors from _exchange_code (e.g., missing id_token and userinfo unavailable)
logger.error(f"OIDC callback error: {e}")
return jsonify({"error": str(e)}), 400
except Exception as e:
logger.error(f"OIDC callback error: {e}")
return jsonify({"error": "Authentication failed"}), 500
+11 -3
View File
@@ -107,14 +107,22 @@ class BookQueue:
if task_id in self._task_data:
self._task_data[task_id].status_message = message
def get_status(self) -> Dict[QueueStatus, Dict[str, DownloadTask]]:
"""Get current queue status grouped by status."""
def get_status(self, user_id: Optional[int] = None) -> Dict[QueueStatus, Dict[str, DownloadTask]]:
"""Get current queue status grouped by status.
Args:
user_id: If provided, only return tasks belonging to this user
(plus legacy tasks with no user_id). If None, return all.
"""
self.refresh()
with self._lock:
result: Dict[QueueStatus, Dict[str, DownloadTask]] = {status: {} for status in QueueStatus}
for task_id, status in self._status.items():
if task_id in self._task_data:
result[status][task_id] = self._task_data[task_id]
task = self._task_data[task_id]
if user_id is not None and task.user_id is not None and task.user_id != user_id:
continue
result[status][task_id] = task
return result
def get_queue_order(self) -> List[Dict[str, Any]]:
+189
View File
@@ -0,0 +1,189 @@
"""SQLite user database for multi-user support."""
import json
import sqlite3
import threading
from typing import Any, Dict, List, Optional
from shelfmark.core.logger import setup_logger
logger = setup_logger(__name__)
_CREATE_TABLES_SQL = """
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
email TEXT,
display_name TEXT,
password_hash TEXT,
oidc_subject TEXT UNIQUE,
role TEXT NOT NULL DEFAULT 'user',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS user_settings (
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
settings_json TEXT NOT NULL DEFAULT '{}'
);
"""
class UserDB:
"""Thread-safe SQLite user database."""
def __init__(self, db_path: str):
self._db_path = db_path
self._lock = threading.Lock()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
def initialize(self) -> None:
"""Create database and tables if they don't exist."""
with self._lock:
conn = self._connect()
try:
conn.executescript(_CREATE_TABLES_SQL)
conn.execute("PRAGMA journal_mode=WAL")
conn.commit()
finally:
conn.close()
logger.info(f"User database initialized at {self._db_path}")
def create_user(
self,
username: str,
email: Optional[str] = None,
display_name: Optional[str] = None,
password_hash: Optional[str] = None,
oidc_subject: Optional[str] = None,
role: str = "user",
) -> Dict[str, Any]:
"""Create a new user. Raises ValueError if username or oidc_subject already exists."""
with self._lock:
conn = self._connect()
try:
cursor = conn.execute(
"""INSERT INTO users (username, email, display_name, password_hash, oidc_subject, role)
VALUES (?, ?, ?, ?, ?, ?)""",
(username, email, display_name, password_hash, oidc_subject, role),
)
conn.commit()
user_id = cursor.lastrowid
return self._get_user_by_id(conn, user_id)
except sqlite3.IntegrityError as e:
raise ValueError(f"User already exists: {e}")
finally:
conn.close()
def get_user(
self,
user_id: Optional[int] = None,
username: Optional[str] = None,
oidc_subject: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Get a user by id, username, or oidc_subject. Returns None if not found."""
conn = self._connect()
try:
if user_id is not None:
return self._get_user_by_id(conn, user_id)
elif username is not None:
row = conn.execute(
"SELECT * FROM users WHERE username = ?", (username,)
).fetchone()
elif oidc_subject is not None:
row = conn.execute(
"SELECT * FROM users WHERE oidc_subject = ?", (oidc_subject,)
).fetchone()
else:
return None
return dict(row) if row else None
finally:
conn.close()
def _get_user_by_id(self, conn: sqlite3.Connection, user_id: int) -> Optional[Dict[str, Any]]:
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
return dict(row) if row else None
_ALLOWED_UPDATE_COLUMNS = {"email", "display_name", "password_hash", "oidc_subject", "role"}
def update_user(self, user_id: int, **kwargs) -> None:
"""Update user fields. Raises ValueError if user not found or invalid column."""
if not kwargs:
return
for k in kwargs:
if k not in self._ALLOWED_UPDATE_COLUMNS:
raise ValueError(f"Invalid column: {k}")
with self._lock:
conn = self._connect()
try:
# Verify user exists
if not self._get_user_by_id(conn, user_id):
raise ValueError(f"User {user_id} not found")
sets = ", ".join(f"{k} = ?" for k in kwargs)
values = list(kwargs.values()) + [user_id]
conn.execute(f"UPDATE users SET {sets} WHERE id = ?", values)
conn.commit()
finally:
conn.close()
def delete_user(self, user_id: int) -> None:
"""Delete a user and their settings."""
with self._lock:
conn = self._connect()
try:
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
conn.commit()
finally:
conn.close()
def list_users(self) -> List[Dict[str, Any]]:
"""List all users."""
conn = self._connect()
try:
rows = conn.execute("SELECT * FROM users ORDER BY id").fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
def get_user_settings(self, user_id: int) -> Dict[str, Any]:
"""Get per-user settings. Returns empty dict if none set."""
conn = self._connect()
try:
row = conn.execute(
"SELECT settings_json FROM user_settings WHERE user_id = ?", (user_id,)
).fetchone()
if row:
return json.loads(row["settings_json"])
return {}
finally:
conn.close()
def set_user_settings(self, user_id: int, settings: Dict[str, Any]) -> None:
"""Merge settings into user's existing settings."""
with self._lock:
conn = self._connect()
try:
existing = {}
row = conn.execute(
"SELECT settings_json FROM user_settings WHERE user_id = ?", (user_id,)
).fetchone()
if row:
existing = json.loads(row["settings_json"])
existing.update(settings)
# Remove keys set to None (meaning "clear this override")
existing = {k: v for k, v in existing.items() if v is not None}
settings_json = json.dumps(existing)
conn.execute(
"""INSERT INTO user_settings (user_id, settings_json) VALUES (?, ?)
ON CONFLICT(user_id) DO UPDATE SET settings_json = ?""",
(user_id, settings_json, settings_json),
)
conn.commit()
finally:
conn.close()
+59 -8
View File
@@ -93,9 +93,14 @@ def _normalize_email_recipients(value: Any) -> List[Dict[str, str]]:
return recipients
def _resolve_email_recipient(nickname: Optional[str]) -> Tuple[Optional[str], Optional[str], Optional[str]]:
def _resolve_email_recipient(
nickname: Optional[str],
user_recipients: Optional[List[Dict[str, str]]] = None,
) -> Tuple[Optional[str], Optional[str], Optional[str]]:
"""Resolve a configured email recipient nickname to an email address.
Checks user-specific recipients first, then global config.
Returns:
(email_to, label, error_message)
"""
@@ -104,6 +109,13 @@ def _resolve_email_recipient(nickname: Optional[str]) -> Tuple[Optional[str], Op
if not label:
return None, None, None
# Check per-user recipients first
if user_recipients:
for entry in _normalize_email_recipients(user_recipients):
if entry["nickname"].strip().lower() == label.lower():
return entry["email"], entry["nickname"], None
# Fall back to global recipients
recipients = _normalize_email_recipients(config.get("EMAIL_RECIPIENTS", []))
for entry in recipients:
if entry["nickname"].strip().lower() == label.lower():
@@ -117,6 +129,9 @@ def queue_book(
priority: int = 0,
source: str = "direct_download",
email_recipient: Optional[str] = None,
user_id: Optional[int] = None,
username: Optional[str] = None,
user_overrides: Optional[Dict[str, Any]] = None,
) -> Tuple[bool, Optional[str]]:
"""Add a book to the download queue. Returns (success, error_message)."""
try:
@@ -133,11 +148,17 @@ def queue_book(
output_mode = "folder" if is_audiobook else books_output_mode
output_args: Dict[str, Any] = {}
# Extract per-user email recipients for resolution (if any)
_user_email_recipients = (user_overrides or {}).get("email_recipients") if user_overrides else None
if output_mode == "email" and not is_audiobook:
if not _normalize_email_recipients(config.get("EMAIL_RECIPIENTS", [])):
all_recipients = _user_email_recipients or config.get("EMAIL_RECIPIENTS", [])
if not _normalize_email_recipients(all_recipients):
return False, "No email recipients configured"
email_to, email_label, email_error = _resolve_email_recipient(email_recipient)
email_to, email_label, email_error = _resolve_email_recipient(
email_recipient, user_recipients=_user_email_recipients,
)
if email_error:
return False, email_error
if not email_to:
@@ -145,6 +166,13 @@ def queue_book(
output_args = {"to": email_to, "label": email_label}
# Merge per-user overrides into output_args (only known keys)
_ALLOWED_OVERRIDE_KEYS = {"destination", "booklore_library_id", "booklore_path_id"}
if user_overrides:
for k, v in user_overrides.items():
if k in _ALLOWED_OVERRIDE_KEYS and v is not None and k not in output_args:
output_args[k] = v
# Create a source-agnostic download task
task = DownloadTask(
task_id=book_id,
@@ -159,6 +187,8 @@ def queue_book(
output_mode=output_mode,
output_args=output_args,
priority=priority,
user_id=user_id,
username=username,
)
if not book_queue.add(task):
@@ -186,6 +216,9 @@ def queue_release(
release_data: dict,
priority: int = 0,
email_recipient: Optional[str] = None,
user_id: Optional[int] = None,
username: Optional[str] = None,
user_overrides: Optional[Dict[str, Any]] = None,
) -> Tuple[bool, Optional[str]]:
"""Add a release to the download queue. Returns (success, error_message)."""
try:
@@ -209,11 +242,17 @@ def queue_release(
output_mode = "folder" if is_audiobook else books_output_mode
output_args: Dict[str, Any] = {}
# Extract per-user email recipients for resolution (if any)
_user_email_recipients = (user_overrides or {}).get("email_recipients") if user_overrides else None
if output_mode == "email" and not is_audiobook:
if not _normalize_email_recipients(config.get("EMAIL_RECIPIENTS", [])):
all_recipients = _user_email_recipients or config.get("EMAIL_RECIPIENTS", [])
if not _normalize_email_recipients(all_recipients):
return False, "No email recipients configured"
email_to, email_label, email_error = _resolve_email_recipient(email_recipient)
email_to, email_label, email_error = _resolve_email_recipient(
email_recipient, user_recipients=_user_email_recipients,
)
if email_error:
return False, email_error
if not email_to:
@@ -221,6 +260,13 @@ def queue_release(
output_args = {"to": email_to, "label": email_label}
# Merge per-user overrides into output_args (only known keys)
_ALLOWED_OVERRIDE_KEYS = {"destination", "booklore_library_id", "booklore_path_id"}
if user_overrides:
for k, v in user_overrides.items():
if k in _ALLOWED_OVERRIDE_KEYS and v is not None and k not in output_args:
output_args[k] = v
# Create a source-agnostic download task from release data
task = DownloadTask(
task_id=release_data['source_id'],
@@ -239,6 +285,8 @@ def queue_release(
output_mode=output_mode,
output_args=output_args,
priority=priority,
user_id=user_id,
username=username,
)
if not book_queue.add(task):
@@ -267,9 +315,9 @@ def queue_release(
logger.error_trace(error_msg)
return False, error_msg
def queue_status() -> Dict[str, Dict[str, Any]]:
def queue_status(user_id: Optional[int] = None) -> Dict[str, Dict[str, Any]]:
"""Get current status of the download queue."""
status = book_queue.get_status()
status = book_queue.get_status(user_id=user_id)
for _, tasks in status.items():
for _, task in tasks.items():
if task.download_path and not run_blocking_io(os.path.exists, task.download_path):
@@ -339,6 +387,7 @@ def _task_to_dict(task: DownloadTask) -> Dict[str, Any]:
'status': task.status,
'status_message': task.status_message,
'download_path': task.download_path,
'username': task.username,
}
@@ -472,7 +521,9 @@ def update_download_progress(book_id: str, progress: float) -> None:
_progress_last_broadcast[f"{book_id}_progress"] = progress
if should_broadcast:
ws_manager.broadcast_download_progress(book_id, progress, 'downloading')
task = book_queue.get_task(book_id)
task_user_id = task.user_id if task else None
ws_manager.broadcast_download_progress(book_id, progress, 'downloading', user_id=task_user_id)
def update_download_status(book_id: str, status: str, message: Optional[str] = None) -> None:
"""Update download status with optional message for UI display."""
+17 -4
View File
@@ -48,7 +48,10 @@ def _parse_int(value: Any, label: str) -> int:
raise BookloreError(f"{label} must be a number") from exc
def build_booklore_config(values: Mapping[str, Any]) -> BookloreConfig:
def build_booklore_config(
values: Mapping[str, Any],
user_overrides: Optional[Dict[str, Any]] = None,
) -> BookloreConfig:
base_url = str(values.get("BOOKLORE_HOST", "")).strip()
username = str(values.get("BOOKLORE_USERNAME", "")).strip()
password = values.get("BOOKLORE_PASSWORD", "") or ""
@@ -60,8 +63,15 @@ def build_booklore_config(values: Mapping[str, Any]) -> BookloreConfig:
if not password:
raise BookloreError("Booklore password is required")
library_id = _parse_int(values.get("BOOKLORE_LIBRARY_ID"), "Booklore library ID")
path_id = _parse_int(values.get("BOOKLORE_PATH_ID"), "Booklore path ID")
# Per-user library/path overrides (auth stays global)
overrides = user_overrides or {}
_lib_override = overrides.get("booklore_library_id")
library_id_val = _lib_override if _lib_override is not None else values.get("BOOKLORE_LIBRARY_ID")
_path_override = overrides.get("booklore_path_id")
path_id_val = _path_override if _path_override is not None else values.get("BOOKLORE_PATH_ID")
library_id = _parse_int(library_id_val, "Booklore library ID")
path_id = _parse_int(path_id_val, "Booklore path ID")
return BookloreConfig(
base_url=base_url.rstrip("/"),
@@ -209,7 +219,10 @@ def _post_process_booklore(
return None
try:
booklore_config = build_booklore_config(_get_booklore_settings())
booklore_config = build_booklore_config(
_get_booklore_settings(),
user_overrides=task.output_args if task.output_args else None,
)
except BookloreError as e:
logger.warning("Task %s: Booklore configuration error: %s", task.task_id, e)
status_callback("error", str(e))
@@ -59,7 +59,12 @@ def validate_destination(destination: Path, status_callback) -> bool:
def get_final_destination(task: DownloadTask) -> Path:
"""Get final destination directory, with content-type routing support."""
"""Get final destination directory, with content-type routing and per-user override support."""
# Per-user destination override (set by admin in user settings)
user_dest = task.output_args.get("destination", "")
if user_dest:
return Path(user_dest)
is_audiobook = check_audiobook(task.content_type)
@@ -53,6 +53,7 @@ def build_metadata_dict(task: DownloadTask) -> dict:
"Year": task.year,
"Series": task.series_name,
"SeriesPosition": task.series_position,
"User": task.username,
}
+119 -26
View File
@@ -72,6 +72,7 @@ socketio = SocketIO(
# Initialize WebSocket manager
ws_manager.init_app(app, socketio)
ws_manager.set_queue_status_fn(backend.queue_status)
logger.info(f"Flask-SocketIO initialized with async_mode='{async_mode}'")
# Ensure all plugins are loaded before starting the download coordinator.
@@ -88,6 +89,28 @@ except ImportError as e:
from shelfmark.config.security import _migrate_security_settings
_migrate_security_settings()
# Initialize user database and register multi-user routes
# If CONFIG_DIR doesn't exist or is read-only, multi-user features will be disabled
import os as _os
from shelfmark.core.user_db import UserDB
_user_db_path = _os.path.join(_os.environ.get("CONFIG_DIR", "/config"), "users.db")
user_db: UserDB | None = None
try:
user_db = UserDB(_user_db_path)
user_db.initialize()
import shelfmark.config.users_settings as _ # noqa: F401 - registers users tab
from shelfmark.core.oidc_routes import register_oidc_routes
from shelfmark.core.admin_routes import register_admin_routes
register_oidc_routes(app, user_db)
register_admin_routes(app, user_db)
except (sqlite3.OperationalError, OSError) as e:
logger.warning(
f"User database initialization failed: {e}. "
f"Multi-user authentication features will be disabled. "
f"Ensure CONFIG_DIR ({_os.environ.get('CONFIG_DIR', '/config')}) exists and is writable."
)
user_db = None
# Start download coordinator
backend.start()
@@ -174,6 +197,8 @@ def get_auth_mode() -> str:
return "builtin"
if auth_mode == "proxy" and security_config.get("PROXY_AUTH_USER_HEADER"):
return "proxy"
if auth_mode == "oidc" and security_config.get("OIDC_DISCOVERY_URL") and security_config.get("OIDC_CLIENT_ID"):
return "oidc"
except Exception:
pass
@@ -346,17 +371,22 @@ def login_required(f):
if 'user_id' not in session:
return jsonify({"error": "Unauthorized"}), 401
# Check admin access for settings endpoints (proxy and CWA modes)
if auth_mode in ("proxy", "cwa") and (request.path.startswith('/api/settings') or request.path.startswith('/api/onboarding')):
# Check admin access for settings endpoints (proxy, CWA, OIDC, and builtin modes)
if auth_mode in ("proxy", "cwa", "oidc", "builtin") and (request.path.startswith('/api/settings') or request.path.startswith('/api/onboarding')):
from shelfmark.core.settings_registry import load_config_file
try:
security_config = load_config_file("security")
if auth_mode == "proxy":
if auth_mode == "builtin":
# Builtin multi-user: settings are always admin-only
restrict_to_admin = 'db_user_id' in session
elif auth_mode == "proxy":
restrict_to_admin = security_config.get("PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN", False)
else:
restrict_to_admin = security_config.get("CWA_RESTRICT_SETTINGS_TO_ADMIN", False)
# For OIDC and CWA, settings are always admin-only
# The user's admin status comes from their group or database role
restrict_to_admin = True
if restrict_to_admin and not session.get('is_admin', False):
return jsonify({"error": "Admin access required"}), 403
@@ -563,7 +593,14 @@ def api_download() -> Union[Response, Tuple[Response, int]]:
try:
priority = int(request.args.get('priority', 0))
email_recipient = request.args.get('email_recipient')
success, error_msg = backend.queue_book(book_id, priority, email_recipient=email_recipient)
# Per-user download overrides
db_user_id = session.get('db_user_id')
_username = session.get('user_id')
_user_overrides = user_db.get_user_settings(db_user_id) if (user_db and db_user_id) else {}
success, error_msg = backend.queue_book(
book_id, priority, email_recipient=email_recipient,
user_id=db_user_id, username=_username, user_overrides=_user_overrides,
)
if success:
return jsonify({"status": "queued", "priority": priority})
return jsonify({"error": error_msg or "Failed to queue book"}), 500
@@ -602,7 +639,14 @@ def api_download_release() -> Union[Response, Tuple[Response, int]]:
priority = data.get('priority', 0)
email_recipient = data.get('email_recipient')
success, error_msg = backend.queue_release(data, priority, email_recipient=email_recipient)
# Per-user download overrides
db_user_id = session.get('db_user_id')
_username = session.get('user_id')
_user_overrides = user_db.get_user_settings(db_user_id) if (user_db and db_user_id) else {}
success, error_msg = backend.queue_release(
data, priority, email_recipient=email_recipient,
user_id=db_user_id, username=_username, user_overrides=_user_overrides,
)
if success:
return jsonify({"status": "queued", "priority": priority})
@@ -704,7 +748,11 @@ def api_status() -> Union[Response, Tuple[Response, int]]:
flask.Response: JSON object with queue status.
"""
try:
status = backend.queue_status()
# Non-admin users only see their own downloads
user_id = None
if not session.get('is_admin', True):
user_id = session.get('db_user_id')
status = backend.queue_status(user_id=user_id)
return jsonify(status)
except Exception as e:
logger.error_trace(f"Status error: {e}")
@@ -1054,22 +1102,47 @@ def api_login() -> Union[Response, Tuple[Response, int]]:
logger.info(f"Login successful for user '{username}' from IP {ip_address} (no auth configured)")
return jsonify({"success": True})
# Built-in authentication mode
if auth_mode == "builtin":
# Password authentication (builtin and OIDC modes)
# OIDC mode also allows password login as a fallback so admins don't get locked out
if auth_mode in ("builtin", "oidc"):
if user_db is None:
logger.error(f"User database not available for {auth_mode} auth")
return jsonify({"error": "Authentication service unavailable"}), 503
try:
security_config = load_config_file("security")
stored_username = security_config.get("BUILTIN_USERNAME", "")
stored_hash = security_config.get("BUILTIN_PASSWORD_HASH", "")
db_user = user_db.get_user(username=username)
# Check credentials
if username == stored_username and check_password_hash(stored_hash, password):
# If user not in DB, try legacy config credentials and auto-migrate
if not db_user:
security_config = load_config_file("security")
stored_username = security_config.get("BUILTIN_USERNAME", "")
stored_hash = security_config.get("BUILTIN_PASSWORD_HASH", "")
if username == stored_username and stored_hash and check_password_hash(stored_hash, password):
# Auto-migrate: create admin user in DB from config
db_user = user_db.create_user(
username=stored_username,
password_hash=stored_hash,
role="admin",
)
logger.info(f"Migrated builtin admin '{stored_username}' to users database")
else:
return _failed_login_response(username, ip_address)
# Authenticate against DB user
if db_user:
if not db_user.get("password_hash") or not check_password_hash(db_user["password_hash"], password):
return _failed_login_response(username, ip_address)
is_admin = db_user["role"] == "admin"
session['user_id'] = username
session['db_user_id'] = db_user["id"]
session['is_admin'] = is_admin
session.permanent = remember_me
clear_failed_logins(username)
logger.info(f"Login successful for user '{username}' from IP {ip_address} (builtin auth, remember_me={remember_me})")
logger.info(f"Login successful for user '{username}' from IP {ip_address} ({auth_mode} auth, is_admin={is_admin}, remember_me={remember_me})")
return jsonify({"success": True})
else:
return _failed_login_response(username, ip_address)
return _failed_login_response(username, ip_address)
except Exception as e:
logger.error_trace(f"Built-in auth error: {e}")
@@ -1176,11 +1249,11 @@ def api_auth_check() -> Union[Response, Tuple[Response, int]]:
is_authenticated = 'user_id' in session
# Determine admin status for settings access
# - Built-in auth: single user is always admin
# - Built-in auth: check DB user role (legacy single-user is always admin)
# - CWA auth: check RESTRICT_SETTINGS_TO_ADMIN setting
# - Proxy auth: check PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN setting
if auth_mode == "builtin":
is_admin = True
is_admin = session.get('is_admin', True)
elif auth_mode == "cwa":
restrict_to_admin = security_config.get("CWA_RESTRICT_SETTINGS_TO_ADMIN", False)
if restrict_to_admin:
@@ -1191,6 +1264,10 @@ def api_auth_check() -> Union[Response, Tuple[Response, int]]:
elif auth_mode == "proxy":
restrict_to_admin = security_config.get("PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN", False)
is_admin = session.get('is_admin', not restrict_to_admin)
elif auth_mode == "oidc":
# OIDC admin status is determined by group membership during login
# and stored in session['is_admin'] - use it directly
is_admin = session.get('is_admin', False)
else:
is_admin = False
@@ -1846,22 +1923,35 @@ def catch_all(path: str) -> Response:
def handle_connect():
"""Handle client connection."""
logger.info("WebSocket client connected")
# Track the connection (triggers warmup callbacks on first connect)
ws_manager.client_connected()
# Send initial status to the newly connected client
# Join appropriate room based on user session
is_admin = session.get('is_admin', True)
db_user_id = session.get('db_user_id')
ws_manager.join_user_room(request.sid, is_admin, db_user_id)
# Send initial status to the newly connected client (filtered)
try:
status = backend.queue_status()
user_id = None
if not is_admin:
user_id = db_user_id
status = backend.queue_status(user_id=user_id)
emit('status_update', status)
except Exception as e:
logger.error(f"Error sending initial status: {e}")
@socketio.on('disconnect')
def handle_disconnect():
"""Handle client disconnection."""
"""Handle client disconnection."""
logger.info("WebSocket client disconnected")
# Leave room
is_admin = session.get('is_admin', True)
db_user_id = session.get('db_user_id')
ws_manager.leave_user_room(request.sid, is_admin, db_user_id)
# Track the disconnection
ws_manager.client_disconnected()
@@ -1869,7 +1959,10 @@ def handle_disconnect():
def handle_status_request():
"""Handle manual status request from client."""
try:
status = backend.queue_status()
user_id = None
if not session.get('is_admin', True):
user_id = session.get('db_user_id')
status = backend.queue_status(user_id=user_id)
emit('status_update', status)
except Exception as e:
logger.error(f"Error handling status request: {e}")
-9
View File
@@ -69,7 +69,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1217,7 +1216,6 @@
"integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -1235,7 +1233,6 @@
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -1437,7 +1434,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -2001,7 +1997,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -2322,7 +2317,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -2492,7 +2486,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -2505,7 +2498,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -3090,7 +3082,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
+2
View File
@@ -79,6 +79,7 @@ function App() {
authRequired,
authChecked,
isAdmin,
authMode,
loginError,
isLoggingIn,
setIsAuthenticated,
@@ -854,6 +855,7 @@ function App() {
onLogin={handleLogin}
error={loginError}
isLoading={isLoggingIn}
authMode={authMode}
/>
)
}
@@ -241,6 +241,12 @@ export const DownloadsSidebar = ({
<span>{book.source_display_name}</span>
</>
)}
{book.username && (
<>
<span> • </span>
<span>{book.username}</span>
</>
)}
</div>
{/* Status Badge */}
+22
View File
@@ -7,6 +7,7 @@ interface LoginFormProps {
error?: string | null;
isLoading?: boolean;
autoFocus?: boolean;
authMode?: string;
}
const EyeIcon = () => (
@@ -53,6 +54,7 @@ export const LoginForm = ({
error = null,
isLoading = false,
autoFocus = true,
authMode,
}: LoginFormProps) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
@@ -225,6 +227,26 @@ export const LoginForm = ({
)}
</button>
</form>
{authMode === 'oidc' && (
<>
<div className="flex items-center my-4">
<div className="flex-1 border-t" style={{ borderColor: 'var(--border-color)' }} />
<span className="px-3 text-sm opacity-60">or</span>
<div className="flex-1 border-t" style={{ borderColor: 'var(--border-color)' }} />
</div>
<a
href={withBasePath('/api/auth/oidc/login')}
className="w-full py-2.5 px-4 rounded-lg font-medium text-center transition-colors border block"
style={{
borderColor: 'var(--border-color)',
color: 'var(--text-color)',
}}
>
Sign in with OIDC
</a>
</>
)}
</div>
);
};
@@ -4,6 +4,7 @@ import { useSearchMode } from '../../contexts/SearchModeContext';
import { SettingsHeader } from './SettingsHeader';
import { SettingsSidebar } from './SettingsSidebar';
import { SettingsContent } from './SettingsContent';
import { UsersPanel } from './UsersPanel';
interface SettingsModalProps {
isOpen: boolean;
@@ -149,11 +150,37 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
// Memoize the field change handler to prevent creating new functions on every render
const handleFieldChange = useCallback(
(key: string, value: unknown) => {
if (selectedTab) {
updateValue(selectedTab, key, value);
if (!selectedTab) return;
updateValue(selectedTab, key, value);
// Auto-manage OIDC scopes when admin group settings change
if (selectedTab === 'security') {
const tabValues = values[selectedTab] || {};
const currentScopes = (tabValues['OIDC_SCOPES'] as string[]) || [];
if (key === 'OIDC_USE_ADMIN_GROUP') {
const groupClaim = (tabValues['OIDC_GROUP_CLAIM'] as string) || 'groups';
if (value === true && !currentScopes.includes(groupClaim)) {
updateValue(selectedTab, 'OIDC_SCOPES', [...currentScopes, groupClaim]);
} else if (value === false && currentScopes.includes(groupClaim)) {
updateValue(selectedTab, 'OIDC_SCOPES', currentScopes.filter(s => s !== groupClaim));
}
}
if (key === 'OIDC_GROUP_CLAIM' && typeof value === 'string') {
const useAdminGroup = tabValues['OIDC_USE_ADMIN_GROUP'] as boolean;
if (useAdminGroup) {
const oldClaim = (tabValues['OIDC_GROUP_CLAIM'] as string) || 'groups';
const newScopes = currentScopes.filter(s => s !== oldClaim);
if (value && !newScopes.includes(value)) {
newScopes.push(value);
}
updateValue(selectedTab, 'OIDC_SCOPES', newScopes);
}
}
}
},
[selectedTab, updateValue]
[selectedTab, updateValue, values]
);
// Memoize hasChanges to avoid expensive JSON.stringify comparisons on every render
@@ -280,16 +307,20 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
onClose={handleClose}
/>
{currentTab && (
<SettingsContent
tab={currentTab}
values={values[currentTab.name] || {}}
onChange={handleFieldChange}
onSave={handleSave}
onAction={handleAction}
isSaving={isSaving}
hasChanges={currentTabHasChanges}
isUniversalMode={isUniversalMode}
/>
selectedTab === 'users' ? (
<UsersPanel onShowToast={onShowToast} />
) : (
<SettingsContent
tab={currentTab}
values={values[currentTab.name] || {}}
onChange={handleFieldChange}
onSave={handleSave}
onAction={handleAction}
isSaving={isSaving}
hasChanges={currentTabHasChanges}
isUniversalMode={isUniversalMode}
/>
)
)}
</>
)}
@@ -331,16 +362,20 @@ export const SettingsModal = ({ isOpen, onClose, onShowToast, onSettingsSaved }:
/>
{currentTab ? (
<SettingsContent
tab={currentTab}
values={values[currentTab.name] || {}}
onChange={handleFieldChange}
onSave={handleSave}
onAction={handleAction}
isSaving={isSaving}
hasChanges={currentTabHasChanges}
isUniversalMode={isUniversalMode}
/>
selectedTab === 'users' ? (
<UsersPanel onShowToast={onShowToast} />
) : (
<SettingsContent
tab={currentTab}
values={values[currentTab.name] || {}}
onChange={handleFieldChange}
onSave={handleSave}
onAction={handleAction}
isSaving={isSaving}
hasChanges={currentTabHasChanges}
isUniversalMode={isUniversalMode}
/>
)
) : (
<div className="flex-1 flex items-center justify-center text-sm opacity-60">
Select a category to configure
@@ -62,6 +62,12 @@ const getIcon = (iconName?: string) => {
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
</svg>
);
case 'users':
return (
<svg className="w-5 h-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
);
case 'beaker':
case 'wrench':
return (
@@ -0,0 +1,743 @@
import { useCallback, useEffect, useState } from 'react';
import {
AdminUser,
BookloreOption,
DownloadDefaults,
getAdminUsers,
getAdminUser,
getBookloreOptions,
getDownloadDefaults,
createAdminUser,
updateAdminUser,
deleteAdminUser,
} from '../../services/api';
interface UsersPanelProps {
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
const inputClasses =
'w-full px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] text-sm focus:outline-none focus:ring-2 focus:ring-sky-500/50 focus:border-sky-500 transition-colors';
const disabledInputClasses =
'w-full px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] text-sm opacity-50 cursor-not-allowed';
interface PerUserSettings {
destination?: string;
booklore_library_id?: string;
booklore_path_id?: string;
email_recipients?: Array<{ nickname: string; email: string }>;
}
export const UsersPanel = ({ onShowToast }: UsersPanelProps) => {
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false);
const [createForm, setCreateForm] = useState({ username: '', email: '', password: '', display_name: '', role: 'user' });
const [creating, setCreating] = useState(false);
// Edit view state
const [editPassword, setEditPassword] = useState('');
const [editPasswordConfirm, setEditPasswordConfirm] = useState('');
const [downloadDefaults, setDownloadDefaults] = useState<DownloadDefaults | null>(null);
const [userSettings, setUserSettings] = useState<PerUserSettings>({});
const [overrides, setOverrides] = useState<Record<string, boolean>>({});
const [bookloreLibraries, setBookloreLibraries] = useState<BookloreOption[]>([]);
const [booklorePaths, setBooklorePaths] = useState<BookloreOption[]>([]);
const fetchUsers = useCallback(async () => {
try {
setLoading(true);
setLoadError(null);
const data = await getAdminUsers();
setUsers(data);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to load users';
setLoadError(msg);
onShowToast?.(msg, 'error');
} finally {
setLoading(false);
}
}, [onShowToast]);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
const startEditing = useCallback(async (user: AdminUser) => {
setEditingUser({ ...user });
setEditPassword('');
setEditPasswordConfirm('');
// Fetch full user data (with settings) and download defaults in parallel
try {
const [fullUser, defaults] = await Promise.all([
getAdminUser(user.id),
getDownloadDefaults(),
]);
setDownloadDefaults(defaults);
const settings = (fullUser.settings || {}) as PerUserSettings;
setUserSettings(settings);
// Fetch BookLore options if in booklore mode
if (defaults.BOOKS_OUTPUT_MODE === 'booklore') {
try {
const blOptions = await getBookloreOptions();
setBookloreLibraries(blOptions.libraries || []);
setBooklorePaths(blOptions.paths || []);
} catch {
setBookloreLibraries([]);
setBooklorePaths([]);
}
}
// Set override toggles based on which settings exist
setOverrides({
destination: !!settings.destination,
booklore_library_id: !!settings.booklore_library_id,
booklore_path_id: !!settings.booklore_path_id,
email_recipients: !!settings.email_recipients?.length,
});
} catch {
setDownloadDefaults(null);
setUserSettings({});
setOverrides({});
}
}, []);
const handleDelete = async (userId: number) => {
try {
await deleteAdminUser(userId);
setConfirmDelete(null);
onShowToast?.('User deleted', 'success');
fetchUsers();
} catch {
onShowToast?.('Failed to delete user', 'error');
}
};
const handleSaveEdit = async () => {
if (!editingUser) return;
// Validate password if provided
if (editPassword) {
if (editPassword.length < 4) {
onShowToast?.('Password must be at least 4 characters', 'error');
return;
}
if (editPassword !== editPasswordConfirm) {
onShowToast?.('Passwords do not match', 'error');
return;
}
}
// Build settings payload: include overridden values, null out cleared overrides
const settingsPayload: Record<string, unknown> = {};
if (overrides.destination) {
settingsPayload.destination = userSettings.destination || '';
} else {
settingsPayload.destination = null;
}
if (overrides.booklore_library_id) {
settingsPayload.booklore_library_id = userSettings.booklore_library_id || '';
} else {
settingsPayload.booklore_library_id = null;
}
if (overrides.booklore_path_id) {
settingsPayload.booklore_path_id = userSettings.booklore_path_id || '';
} else {
settingsPayload.booklore_path_id = null;
}
if (overrides.email_recipients) {
settingsPayload.email_recipients = userSettings.email_recipients || [];
} else {
settingsPayload.email_recipients = null;
}
// Skip sending role when it's managed by OIDC group auth
const roleManaged = !!editingUser.oidc_subject && downloadDefaults?.OIDC_USE_ADMIN_GROUP === true;
try {
await updateAdminUser(editingUser.id, {
email: editingUser.email,
display_name: editingUser.display_name,
...(!roleManaged ? { role: editingUser.role } : {}),
...(editPassword ? { password: editPassword } : {}),
...(Object.keys(settingsPayload).length ? { settings: settingsPayload } : {}),
});
setEditingUser(null);
onShowToast?.('User updated', 'success');
fetchUsers();
} catch {
onShowToast?.('Failed to update user', 'error');
}
};
const handleCreate = async () => {
if (!createForm.username || !createForm.password) {
onShowToast?.('Username and password are required', 'error');
return;
}
if (createForm.password.length < 4) {
onShowToast?.('Password must be at least 4 characters', 'error');
return;
}
setCreating(true);
try {
const data = await createAdminUser(createForm as { username: string; password: string; email?: string; display_name?: string; role?: string });
setShowCreateForm(false);
setCreateForm({ username: '', email: '', password: '', display_name: '', role: 'user' });
onShowToast?.(`User ${data.username} created`, 'success');
fetchUsers();
} catch (err) {
onShowToast?.((err as Error).message || 'Failed to create user', 'error');
} finally {
setCreating(false);
}
};
const toggleOverride = (key: string, enabled: boolean) => {
setOverrides((prev) => ({ ...prev, [key]: enabled }));
if (!enabled) {
setUserSettings((prev) => {
const next = { ...prev };
(next as Record<string, unknown>)[key] = null;
return next;
});
}
};
if (loading) {
return (
<div className="flex-1 flex items-center justify-center text-sm opacity-60 p-8">
Loading users...
</div>
);
}
if (loadError) {
return (
<div className="flex-1 flex flex-col items-center justify-center p-8 gap-3">
<p className="text-sm opacity-60">{loadError}</p>
<button
onClick={fetchUsers}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg-soft)] hover:bg-[var(--hover-surface)] transition-colors"
>
Retry
</button>
</div>
);
}
// Edit view
if (editingUser) {
const outputMode = downloadDefaults?.BOOKS_OUTPUT_MODE || 'folder';
return (
<div className="flex-1 overflow-y-auto p-6">
<div className="flex items-center gap-3 mb-6">
<button
onClick={() => setEditingUser(null)}
className="text-sm opacity-60 hover:opacity-100 transition-opacity"
>
&larr; Back
</button>
<h3 className="text-sm font-medium">Edit {editingUser.username}</h3>
</div>
<div className="space-y-5 max-w-lg">
{editingUser.oidc_subject && (
<div className="space-y-2">
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs bg-sky-500/10 text-sky-400">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4 shrink-0">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clipRule="evenodd" />
</svg>
This user authenticates via SSO. Password is managed by the identity provider.
</div>
{downloadDefaults?.OIDC_USE_ADMIN_GROUP === true && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs bg-sky-500/10 text-sky-400">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-4 h-4 shrink-0">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clipRule="evenodd" />
</svg>
{downloadDefaults?.OIDC_ADMIN_GROUP
? `Admin role is managed by the ${downloadDefaults.OIDC_ADMIN_GROUP} group in your identity provider.`
: 'Admin group authorization is enabled but no group name is configured.'}
</div>
)}
</div>
)}
<div className="space-y-1.5">
<label className="text-sm font-medium">Display Name</label>
<input
type="text"
value={editingUser.display_name || ''}
onChange={(e) => setEditingUser({ ...editingUser, display_name: e.target.value || null })}
className={inputClasses}
placeholder="Display name"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Email</label>
<input
type="email"
value={editingUser.email || ''}
onChange={(e) => setEditingUser({ ...editingUser, email: e.target.value || null })}
className={inputClasses}
placeholder="user@example.com"
/>
</div>
{/* Hide role dropdown for OIDC users when admin group auth is on (like password) */}
{!(!!editingUser.oidc_subject && downloadDefaults?.OIDC_USE_ADMIN_GROUP === true) && (
<div className="space-y-1.5">
<label className="text-sm font-medium">Role</label>
<select
value={editingUser.role}
onChange={(e) => setEditingUser({ ...editingUser, role: e.target.value })}
className={inputClasses}
>
<option value="admin">Admin</option>
<option value="user">User</option>
</select>
</div>
)}
{/* Password section */}
{!editingUser.oidc_subject && (
<>
<div className="border-t border-[var(--border-muted)] pt-4">
<p className="text-xs font-medium opacity-60 mb-3">Change Password</p>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">New Password</label>
<input
type="password"
value={editPassword}
onChange={(e) => setEditPassword(e.target.value)}
className={inputClasses}
placeholder="Leave empty to keep current"
/>
</div>
{editPassword && (
<div className="space-y-1.5">
<label className="text-sm font-medium">Confirm Password</label>
<input
type="password"
value={editPasswordConfirm}
onChange={(e) => setEditPasswordConfirm(e.target.value)}
className={inputClasses}
placeholder="Confirm new password"
/>
</div>
)}
</>
)}
{/* Per-user download settings overrides */}
{downloadDefaults && (
<>
<div className="border-t border-[var(--border-muted)] pt-4">
<p className="text-xs font-medium opacity-60 mb-1">Download Settings Overrides</p>
<p className="text-xs opacity-40 mb-3">Override global defaults for this user.</p>
</div>
{/* Destination override (shown for folder mode) */}
{(outputMode === 'folder' || outputMode === 'booklore') && (
<OverrideField
label="Destination Folder"
enabled={overrides.destination || false}
onToggle={(v) => toggleOverride('destination', v)}
globalValue={downloadDefaults.DESTINATION || '/books'}
>
<input
type="text"
value={userSettings.destination || ''}
onChange={(e) => setUserSettings((s) => ({ ...s, destination: e.target.value }))}
className={overrides.destination ? inputClasses : disabledInputClasses}
disabled={!overrides.destination}
placeholder={downloadDefaults.DESTINATION || '/books'}
/>
</OverrideField>
)}
{/* BookLore overrides */}
{outputMode === 'booklore' && (
<>
<OverrideField
label="BookLore Library"
enabled={overrides.booklore_library_id || false}
onToggle={(v) => toggleOverride('booklore_library_id', v)}
globalValue={
bookloreLibraries.find((l) => l.value === downloadDefaults.BOOKLORE_LIBRARY_ID)?.label
|| downloadDefaults.BOOKLORE_LIBRARY_ID
|| 'Not set'
}
>
<select
value={userSettings.booklore_library_id || ''}
onChange={(e) => {
setUserSettings((s) => ({ ...s, booklore_library_id: e.target.value, booklore_path_id: '' }));
// Reset path override when library changes
if (overrides.booklore_path_id) {
setOverrides((o) => ({ ...o, booklore_path_id: true }));
}
}}
className={overrides.booklore_library_id ? inputClasses : disabledInputClasses}
disabled={!overrides.booklore_library_id}
>
<option value="">Select library...</option>
{bookloreLibraries.map((lib) => (
<option key={lib.value} value={lib.value}>{lib.label}</option>
))}
</select>
</OverrideField>
<OverrideField
label="BookLore Path"
enabled={overrides.booklore_path_id || false}
onToggle={(v) => toggleOverride('booklore_path_id', v)}
globalValue={
booklorePaths.find((p) => p.value === downloadDefaults.BOOKLORE_PATH_ID)?.label
|| downloadDefaults.BOOKLORE_PATH_ID
|| 'Not set'
}
>
<select
value={userSettings.booklore_path_id || ''}
onChange={(e) => setUserSettings((s) => ({ ...s, booklore_path_id: e.target.value }))}
className={overrides.booklore_path_id ? inputClasses : disabledInputClasses}
disabled={!overrides.booklore_path_id}
>
<option value="">Select path...</option>
{booklorePaths
.filter((p) => {
const selectedLib = userSettings.booklore_library_id || downloadDefaults.BOOKLORE_LIBRARY_ID;
return !p.childOf || p.childOf === selectedLib;
})
.map((path) => (
<option key={path.value} value={path.value}>{path.label}</option>
))}
</select>
</OverrideField>
</>
)}
{/* Email recipients override */}
{outputMode === 'email' && (
<OverrideField
label="Email Recipients"
enabled={overrides.email_recipients || false}
onToggle={(v) => toggleOverride('email_recipients', v)}
globalValue={
downloadDefaults.EMAIL_RECIPIENTS?.length
? downloadDefaults.EMAIL_RECIPIENTS.map((r) => r.nickname || r.email).join(', ')
: 'None configured'
}
>
{overrides.email_recipients && (
<EmailRecipientsEditor
recipients={userSettings.email_recipients || []}
onChange={(r) => setUserSettings((s) => ({ ...s, email_recipients: r }))}
/>
)}
</OverrideField>
)}
</>
)}
<div className="flex gap-2 pt-2">
<button
onClick={handleSaveEdit}
className="px-4 py-2.5 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors"
>
Save Changes
</button>
<button
onClick={() => setEditingUser(null)}
className="px-4 py-2.5 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg-soft)] hover:bg-[var(--hover-surface)] transition-colors"
>
Cancel
</button>
</div>
</div>
</div>
);
}
// List view
return (
<div className="flex-1 overflow-y-auto p-6">
<div className="flex items-center justify-between mb-4">
<p className="text-xs opacity-60">
Users are created automatically via OIDC login, or manually below.
</p>
<button
onClick={() => setShowCreateForm(!showCreateForm)}
className="px-3 py-1.5 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors shrink-0"
>
{showCreateForm ? 'Cancel' : 'Create User'}
</button>
</div>
{showCreateForm && (
<div className="mb-4 p-4 rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] space-y-3">
{users.length === 0 && (
<p className="text-xs opacity-60 pb-1">
This will be the first account and will be created as admin.
</p>
)}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<label className="text-sm font-medium">Username <span className="text-red-500">*</span></label>
<input
type="text"
value={createForm.username}
onChange={(e) => setCreateForm({ ...createForm, username: e.target.value })}
className={inputClasses}
placeholder="username"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Display Name</label>
<input
type="text"
value={createForm.display_name}
onChange={(e) => setCreateForm({ ...createForm, display_name: e.target.value })}
className={inputClasses}
placeholder="Display Name"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Email</label>
<input
type="email"
value={createForm.email}
onChange={(e) => setCreateForm({ ...createForm, email: e.target.value })}
className={inputClasses}
placeholder="user@example.com"
/>
</div>
<div className="space-y-1.5">
<label className="text-sm font-medium">Password <span className="text-red-500">*</span></label>
<input
type="password"
value={createForm.password}
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
className={inputClasses}
placeholder="Min 4 characters"
/>
</div>
</div>
<div className="flex items-center gap-3">
<select
value={createForm.role}
onChange={(e) => setCreateForm({ ...createForm, role: e.target.value })}
className="px-3 py-2 rounded-lg border border-[var(--border-muted)] bg-[var(--bg-soft)] text-sm transition-colors"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<button
onClick={handleCreate}
disabled={creating}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{creating ? 'Creating...' : 'Create'}
</button>
</div>
</div>
)}
{users.length === 0 ? (
<div className="text-center py-8 space-y-2">
<p className="text-sm opacity-50">No users yet.</p>
<p className="text-xs opacity-40">
Create a local admin account before enabling OIDC to avoid getting locked out.
</p>
</div>
) : (
<div className="space-y-2">
{users.map((user) => (
<div
key={user.id}
className="flex items-center justify-between p-3 rounded-lg border border-[var(--border-muted)]
bg-[var(--bg-soft)] transition-colors"
>
<div className="flex items-center gap-3 min-w-0 flex-1">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium shrink-0
${user.role === 'admin' ? 'bg-sky-500/20 text-sky-400' : 'bg-zinc-500/20'}`}
>
{user.username.charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">
{user.display_name || user.username}
</span>
{user.display_name && (
<span className="text-xs opacity-40 truncate">@{user.username}</span>
)}
<span
className={`text-[10px] px-1.5 py-0.5 rounded font-medium
${user.oidc_subject
? 'bg-sky-500/15 text-sky-400'
: 'bg-zinc-500/15 opacity-70'}`}
>
{user.oidc_subject ? 'OIDC' : 'Password'}
</span>
</div>
<div className="text-xs opacity-50 truncate">
{user.email || 'No email'}
</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span
className={`text-xs px-2 py-0.5 rounded font-medium
${user.role === 'admin' ? 'bg-sky-500/15 text-sky-400' : 'bg-zinc-500/10 opacity-70'}`}
>
{user.role}
</span>
<button
onClick={() => startEditing(user)}
className="text-xs px-2 py-1 rounded border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors"
>
Edit
</button>
{confirmDelete === user.id ? (
<div className="flex items-center gap-1">
<button
onClick={() => handleDelete(user.id)}
className="text-xs px-2 py-1 rounded bg-red-600 text-white hover:bg-red-700 transition-colors"
>
Confirm
</button>
<button
onClick={() => setConfirmDelete(null)}
className="text-xs px-2 py-1 rounded border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setConfirmDelete(user.id)}
className="text-xs px-2 py-1 rounded border border-[var(--border-muted)] text-red-400
hover:bg-red-600 hover:text-white hover:border-red-600 transition-colors"
>
Delete
</button>
)}
</div>
</div>
))}
</div>
)}
</div>
);
};
// ---------------------------------------------------------------------------
// Sub-components
// ---------------------------------------------------------------------------
interface OverrideFieldProps {
label: string;
enabled: boolean;
onToggle: (enabled: boolean) => void;
globalValue: string;
children: React.ReactNode;
}
const OverrideField = ({ label, enabled, onToggle, globalValue, children }: OverrideFieldProps) => (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-sm font-medium">{label}</label>
<button
type="button"
onClick={() => onToggle(!enabled)}
className={`text-[10px] px-2 py-0.5 rounded font-medium transition-colors
${enabled
? 'bg-sky-500/15 text-sky-400 hover:bg-sky-500/25'
: 'bg-zinc-500/10 opacity-60 hover:opacity-80'}`}
>
{enabled ? 'Custom' : 'Global'}
</button>
</div>
{!enabled && (
<p className="text-xs opacity-40">Using global: {globalValue}</p>
)}
{children}
</div>
);
interface EmailRecipientsEditorProps {
recipients: Array<{ nickname: string; email: string }>;
onChange: (recipients: Array<{ nickname: string; email: string }>) => void;
}
const EmailRecipientsEditor = ({ recipients, onChange }: EmailRecipientsEditorProps) => {
const addRecipient = () => {
onChange([...recipients, { nickname: '', email: '' }]);
};
const removeRecipient = (index: number) => {
onChange(recipients.filter((_, i) => i !== index));
};
const updateRecipient = (index: number, field: 'nickname' | 'email', value: string) => {
const updated = [...recipients];
updated[index] = { ...updated[index], [field]: value };
onChange(updated);
};
return (
<div className="space-y-2">
{recipients.map((r, i) => (
<div key={i} className="flex items-center gap-2">
<input
type="text"
value={r.nickname}
onChange={(e) => updateRecipient(i, 'nickname', e.target.value)}
className={inputClasses}
placeholder="Nickname"
/>
<input
type="email"
value={r.email}
onChange={(e) => updateRecipient(i, 'email', e.target.value)}
className={inputClasses}
placeholder="email@example.com"
/>
<button
type="button"
onClick={() => removeRecipient(i)}
className="text-xs px-2 py-1 rounded text-red-400 hover:bg-red-600 hover:text-white transition-colors shrink-0"
>
Remove
</button>
</div>
))}
<button
type="button"
onClick={addRecipient}
className="text-xs px-2 py-1 rounded border border-[var(--border-muted)]
hover:bg-[var(--hover-surface)] transition-colors"
>
+ Add Recipient
</button>
</div>
);
};
@@ -6,6 +6,7 @@ interface TagListFieldProps {
value: string[];
onChange: (value: string[]) => void;
disabled?: boolean;
requiredTags?: string[]; // Tags that cannot be removed
}
function normalizeTag(raw: string): string {
@@ -24,15 +25,18 @@ function normalizeTag(raw: string): string {
if (s.toLowerCase() === 'auto') return '';
// Basic URL normalization to keep UX friendly; backend also normalizes on save.
if (!s.includes('://') && !s.startsWith('/')) {
// Only add https:// if it looks like a domain (contains a dot) and has no protocol.
// This avoids adding prefixes to non-URL values like OIDC scopes (openid, email, etc.)
if (!s.includes('://') && !s.startsWith('/') && s.includes('.')) {
s = `https://${s}`;
}
s = s.replace(/\/+$/, '');
return s.trim();
}
export const TagListField = ({ field, value, onChange, disabled }: TagListFieldProps) => {
export const TagListField = ({ field, value, onChange, disabled, requiredTags }: TagListFieldProps) => {
const isDisabled = disabled ?? false;
const required = requiredTags ?? [];
const inputRef = useRef<HTMLInputElement>(null);
const [draft, setDraft] = useState('');
@@ -59,8 +63,10 @@ export const TagListField = ({ field, value, onChange, disabled }: TagListFieldP
}
};
const isRequired = (tag: string) => required.includes(tag);
const removeAt = (idx: number) => {
if (isDisabled) return;
if (isDisabled || isRequired(tags[idx])) return;
onChange(tags.filter((_, i) => i !== idx));
};
@@ -91,7 +97,7 @@ export const TagListField = ({ field, value, onChange, disabled }: TagListFieldP
title={tag}
>
<span className="truncate max-w-[22rem]">{tag}</span>
{!isDisabled && (
{!isDisabled && !isRequired(tag) && (
<button
type="button"
onClick={(e) => {
+5
View File
@@ -13,6 +13,7 @@ interface UseAuthReturn {
authRequired: boolean;
authChecked: boolean;
isAdmin: boolean;
authMode: string;
loginError: string | null;
isLoggingIn: boolean;
setIsAuthenticated: (value: boolean) => void;
@@ -28,6 +29,7 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
const [authRequired, setAuthRequired] = useState<boolean>(true);
const [authChecked, setAuthChecked] = useState<boolean>(false);
const [isAdmin, setIsAdmin] = useState<boolean>(false);
const [authMode, setAuthMode] = useState<string>('none');
const [loginError, setLoginError] = useState<string | null>(null);
const [isLoggingIn, setIsLoggingIn] = useState<boolean>(false);
@@ -39,10 +41,12 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
const authenticated = response.authenticated || false;
const authIsRequired = response.auth_required !== false;
const admin = response.is_admin || false;
const mode = response.auth_mode || 'none';
setAuthRequired(authIsRequired);
setIsAuthenticated(authenticated);
setIsAdmin(admin);
setAuthMode(mode);
} catch (error) {
console.error('Auth check failed:', error);
setAuthRequired(true);
@@ -102,6 +106,7 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
authRequired,
authChecked,
isAdmin,
authMode,
loginError,
isLoggingIn,
setIsAuthenticated,
+11 -2
View File
@@ -108,6 +108,7 @@ export function useSettings(): UseSettingsReturn {
}
});
});
setValues(initialValues);
setOriginalValues(JSON.parse(JSON.stringify(initialValues)));
@@ -239,7 +240,15 @@ export function useSettings(): UseSettingsReturn {
try {
// Pass current form values so action can use unsaved values
const currentValues = values[tabName] || {};
return await executeSettingsAction(tabName, actionKey, currentValues);
const result = await executeSettingsAction(tabName, actionKey, currentValues);
// Re-fetch settings after successful action to pick up updated options
// (e.g., BookLore "Test Connection" refreshes library/path lists)
if (result.success) {
fetchSettings(true);
}
return result;
} catch (err) {
console.error('Action execution failed:', tabName, actionKey, err);
return {
@@ -248,7 +257,7 @@ export function useSettings(): UseSettingsReturn {
};
}
},
[values]
[values, fetchSettings]
);
return {
+3 -2
View File
@@ -6,9 +6,10 @@ interface LoginPageProps {
onLogin: (credentials: LoginCredentials) => void;
error: string | null;
isLoading: boolean;
authMode?: string;
}
export const LoginPage = ({ onLogin, error, isLoading }: LoginPageProps) => {
export const LoginPage = ({ onLogin, error, isLoading, authMode }: LoginPageProps) => {
const logoUrl = withBasePath('/logo.png');
return (
@@ -29,7 +30,7 @@ export const LoginPage = ({ onLogin, error, isLoading }: LoginPageProps) => {
color: 'var(--text-color)',
}}
>
<LoginForm onSubmit={onLogin} error={error} isLoading={isLoading} />
<LoginForm onSubmit={onLogin} error={error} isLoading={isLoading} authMode={authMode} />
</div>
</div>
</div>
+79
View File
@@ -379,3 +379,82 @@ export const getReleases = async (
// Let the backend control timeouts for release searches (can be long-running).
return fetchJSON<ReleasesResponse>(`${API_BASE}/releases?${params.toString()}`, {}, null);
};
// Admin user management API
export interface AdminUser {
id: number;
username: string;
email: string | null;
display_name: string | null;
role: string;
oidc_subject: string | null;
created_at: string;
settings?: Record<string, unknown>;
}
export const getAdminUsers = async (): Promise<AdminUser[]> => {
return fetchJSON<AdminUser[]>(`${API_BASE}/admin/users`);
};
export const getAdminUser = async (userId: number): Promise<AdminUser> => {
return fetchJSON<AdminUser>(`${API_BASE}/admin/users/${userId}`);
};
export const createAdminUser = async (
data: { username: string; password: string; email?: string; display_name?: string; role?: string }
): Promise<AdminUser> => {
return fetchJSON<AdminUser>(`${API_BASE}/admin/users`, {
method: 'POST',
body: JSON.stringify(data),
});
};
export const updateAdminUser = async (
userId: number,
data: Partial<Pick<AdminUser, 'role' | 'email' | 'display_name'>> & {
password?: string;
settings?: Record<string, unknown>;
}
): Promise<AdminUser> => {
return fetchJSON<AdminUser>(`${API_BASE}/admin/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
};
export const deleteAdminUser = async (userId: number): Promise<{ success: boolean }> => {
return fetchJSON<{ success: boolean }>(`${API_BASE}/admin/users/${userId}`, {
method: 'DELETE',
});
};
export interface DownloadDefaults {
BOOKS_OUTPUT_MODE: string;
DESTINATION: string;
BOOKLORE_LIBRARY_ID: string;
BOOKLORE_PATH_ID: string;
EMAIL_RECIPIENTS: Array<{ nickname: string; email: string }>;
OIDC_ADMIN_GROUP: string;
OIDC_USE_ADMIN_GROUP: boolean;
OIDC_AUTO_PROVISION: boolean;
}
export const getDownloadDefaults = async (): Promise<DownloadDefaults> => {
return fetchJSON<DownloadDefaults>(`${API_BASE}/admin/download-defaults`);
};
export interface BookloreOption {
value: string;
label: string;
childOf?: string;
}
export interface BookloreOptions {
libraries: BookloreOption[];
paths: BookloreOption[];
}
export const getBookloreOptions = async (): Promise<BookloreOptions> => {
return fetchJSON<BookloreOptions>(`${API_BASE}/admin/booklore-options`);
};
+2
View File
@@ -48,6 +48,7 @@ export interface Book {
subtitle?: string;
search_title?: string;
search_author?: string;
username?: string;
}
// Status response types
@@ -191,6 +192,7 @@ export interface AuthResponse {
success?: boolean;
authenticated?: boolean;
auth_required?: boolean;
auth_mode?: string;
is_admin?: boolean;
error?: string;
logout_url?: string;
+34
View File
@@ -19,6 +19,35 @@ docker exec test-cwabd python3 -m pytest tests/ -v -m "not integration"
```
tests/
├── config/ # Settings & configuration tests
│ ├── test_docker_volumes.py # Docker volume mapping
│ ├── test_environment.py # Environment variable handling
│ ├── test_mirror_settings_live_apply.py # Mirror settings live reload
│ ├── test_mirror_settings_options.py # Mirror settings options
│ ├── test_security.py # Security settings
│ └── test_oidc_settings.py # OIDC settings fields & show_when conditions
│
├── core/ # Core application logic tests
│ ├── test_admin_users_api.py # Admin user CRUD API endpoints
│ ├── test_booklore_multiuser.py # BookLore per-user override merging
│ ├── test_builtin_multiuser.py # Builtin auth multi-user migration
│ ├── test_download_processing.py # Download file processing
│ ├── test_hardlink.py # Hardlink/copy operations
│ ├── test_library_processing.py # Library file processing
│ ├── test_manual_query.py # Manual search query handling
│ ├── test_mirrors_config.py # Mirror configuration
│ ├── test_naming.py # File naming templates
│ ├── test_oidc_auth.py # OIDC auth helpers (group claims, user provisioning)
│ ├── test_oidc_integration.py # OIDC integration into auth system (logic mirror)
│ ├── test_oidc_routes.py # OIDC Flask route handlers
│ ├── test_part_number_extraction.py # Part number extraction
│ ├── test_per_user_downloads.py # Per-user download queue filtering
│ ├── test_permission_handling.py # File permission handling
│ ├── test_processing_integration.py # Processing integration
│ ├── test_search_plan.py # Search plan logic
│ ├── test_user_db.py # UserDB CRUD operations
│ └── test_user_template_variable.py # {User} template variable in naming
│
├── e2e/ # End-to-end API tests
│ ├── conftest.py # Fixtures (APIClient, DownloadTracker)
│ ├── test_api.py # Core API endpoint tests
@@ -59,6 +88,11 @@ docker exec test-cwabd python3 -m pytest tests/prowlarr/ -v -m "not integration"
- Protocol detection (torrent vs usenet)
- Release cache operations
- Handler download flow logic
- User database (CRUD, settings, OIDC subject linking)
- OIDC authentication (group claims, user provisioning, route handlers)
- Admin user management API (create, update, delete, password, per-user settings)
- Multi-user download queue filtering and per-user overrides
- Settings configuration (OIDC fields, show_when conditions)
### E2E Tests
Test the full application through its HTTP API. Require the app to be running.
+160
View File
@@ -0,0 +1,160 @@
"""
Tests for OIDC settings fields in security configuration.
Tests that OIDC fields are registered correctly with proper
show_when conditions, defaults, and field types.
"""
from shelfmark.core.settings_registry import (
TextField,
PasswordField,
CheckboxField,
TagListField,
)
def _reload_security_module():
"""Reload security module to pick up patched values."""
import importlib
import shelfmark.config.security
importlib.reload(shelfmark.config.security)
return shelfmark.config.security.security_settings()
def _get_field(fields, key):
"""Find a field by key."""
return next((f for f in fields if f.key == key), None)
class TestOIDCAuthMethodOption:
"""Tests that OIDC appears as an auth method option."""
def test_oidc_option_available(self):
fields = _reload_security_module()
auth_field = _get_field(fields, "AUTH_METHOD")
option_values = [opt["value"] for opt in auth_field.options]
assert "oidc" in option_values
def test_oidc_option_label(self):
fields = _reload_security_module()
auth_field = _get_field(fields, "AUTH_METHOD")
oidc_option = next(o for o in auth_field.options if o["value"] == "oidc")
assert "OIDC" in oidc_option["label"]
class TestOIDCFieldsPresent:
"""Tests that all OIDC configuration fields are registered."""
def test_discovery_url_field_exists(self):
fields = _reload_security_module()
field = _get_field(fields, "OIDC_DISCOVERY_URL")
assert field is not None
assert isinstance(field, TextField)
def test_client_id_field_exists(self):
fields = _reload_security_module()
field = _get_field(fields, "OIDC_CLIENT_ID")
assert field is not None
assert isinstance(field, TextField)
def test_client_secret_field_exists(self):
fields = _reload_security_module()
field = _get_field(fields, "OIDC_CLIENT_SECRET")
assert field is not None
assert isinstance(field, PasswordField)
def test_scopes_field_exists(self):
fields = _reload_security_module()
field = _get_field(fields, "OIDC_SCOPES")
assert field is not None
assert isinstance(field, TagListField)
def test_scopes_default_includes_essentials(self):
fields = _reload_security_module()
field = _get_field(fields, "OIDC_SCOPES")
assert "openid" in field.default
assert "email" in field.default
assert "profile" in field.default
def test_use_admin_group_field_exists(self):
fields = _reload_security_module()
field = _get_field(fields, "OIDC_USE_ADMIN_GROUP")
assert field is not None
assert isinstance(field, CheckboxField)
assert field.default is True
def test_group_claim_field_exists(self):
fields = _reload_security_module()
field = _get_field(fields, "OIDC_GROUP_CLAIM")
assert field is not None
assert field.default == "groups"
def test_admin_group_field_exists(self):
fields = _reload_security_module()
field = _get_field(fields, "OIDC_ADMIN_GROUP")
assert field is not None
def test_auto_provision_field_exists(self):
fields = _reload_security_module()
field = _get_field(fields, "OIDC_AUTO_PROVISION")
assert field is not None
assert isinstance(field, CheckboxField)
assert field.default is True
def test_test_connection_button_exists(self):
fields = _reload_security_module()
field = _get_field(fields, "test_oidc")
assert field is not None
class TestOIDCFieldShowWhen:
"""Tests that OIDC fields are conditionally shown."""
def test_oidc_fields_show_when_oidc_selected(self):
fields = _reload_security_module()
oidc_keys = [
"OIDC_DISCOVERY_URL",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_SCOPES",
"OIDC_USE_ADMIN_GROUP",
"OIDC_AUTO_PROVISION",
"OIDC_GROUP_CLAIM",
"OIDC_ADMIN_GROUP",
]
for key in oidc_keys:
field = _get_field(fields, key)
assert field is not None, f"Field {key} not found"
show_when = field.show_when
# show_when can be a dict or list of dicts
if isinstance(show_when, list):
conditions = show_when
else:
conditions = [show_when]
# At least one condition should reference AUTH_METHOD=oidc
has_oidc_condition = any(
c.get("field") == "AUTH_METHOD" and c.get("value") == "oidc"
for c in conditions
)
assert has_oidc_condition, f"Field {key} missing AUTH_METHOD=oidc show_when"
class TestOIDCFieldsEnvSupport:
"""Tests that OIDC fields are UI-only (no env var support)."""
def test_oidc_fields_not_env_supported(self):
fields = _reload_security_module()
oidc_keys = [
"OIDC_DISCOVERY_URL",
"OIDC_CLIENT_ID",
"OIDC_CLIENT_SECRET",
"OIDC_SCOPES",
"OIDC_USE_ADMIN_GROUP",
"OIDC_GROUP_CLAIM",
"OIDC_ADMIN_GROUP",
"OIDC_AUTO_PROVISION",
]
for key in oidc_keys:
field = _get_field(fields, key)
assert field is not None, f"Field {key} not found"
assert field.env_supported is False, f"Field {key} should not support env vars"
+705
View File
@@ -0,0 +1,705 @@
"""
Tests for admin user management API routes.
Tests CRUD endpoints for managing users from the admin panel.
"""
import os
import tempfile
from unittest.mock import patch
import pytest
from flask import Flask
from shelfmark.core.user_db import UserDB
@pytest.fixture
def db_path():
with tempfile.TemporaryDirectory() as tmpdir:
yield os.path.join(tmpdir, "shelfmark.db")
@pytest.fixture
def user_db(db_path):
db = UserDB(db_path)
db.initialize()
return db
@pytest.fixture
def app(user_db):
from shelfmark.core.admin_routes import register_admin_routes
test_app = Flask(__name__)
test_app.config["SECRET_KEY"] = "test-secret"
test_app.config["TESTING"] = True
register_admin_routes(test_app, user_db)
return test_app
@pytest.fixture
def admin_client(app):
client = app.test_client()
with client.session_transaction() as sess:
sess["user_id"] = "admin"
sess["is_admin"] = True
return client
@pytest.fixture
def regular_client(app):
"""Non-admin client with auth mode set to builtin (auth-required)."""
client = app.test_client()
with client.session_transaction() as sess:
sess["user_id"] = "user"
sess["is_admin"] = False
with patch("shelfmark.core.admin_routes._get_auth_mode", return_value="builtin"):
yield client
@pytest.fixture
def no_session_client(app):
"""Client with no session at all (unauthenticated, no-auth mode)."""
return app.test_client()
@pytest.fixture
def no_session_auth_client(app):
"""Client with no session but auth mode enabled (should be rejected)."""
client = app.test_client()
with patch("shelfmark.core.admin_routes._get_auth_mode", return_value="builtin"):
yield client
# ---------------------------------------------------------------------------
# GET /api/admin/users
# ---------------------------------------------------------------------------
class TestAdminUsersListEndpoint:
"""Tests for GET /api/admin/users."""
def test_list_users_empty(self, admin_client):
resp = admin_client.get("/api/admin/users")
assert resp.status_code == 200
assert resp.json == []
def test_list_users_returns_all(self, admin_client, user_db):
user_db.create_user(username="alice", email="alice@example.com")
user_db.create_user(username="bob", email="bob@example.com")
resp = admin_client.get("/api/admin/users")
assert resp.status_code == 200
assert len(resp.json) == 2
usernames = [u["username"] for u in resp.json]
assert "alice" in usernames
assert "bob" in usernames
def test_list_users_excludes_password_hash(self, admin_client, user_db):
user_db.create_user(username="alice", password_hash="secret_hash")
resp = admin_client.get("/api/admin/users")
users = resp.json
assert "password_hash" not in users[0]
def test_list_users_requires_admin(self, regular_client):
resp = regular_client.get("/api/admin/users")
assert resp.status_code == 403
def test_list_users_no_session_allows_access_in_no_auth(self, no_session_client):
"""No session + no-auth mode = admin access allowed."""
resp = no_session_client.get("/api/admin/users")
assert resp.status_code == 200
def test_list_users_no_session_rejected_when_auth_enabled(self, no_session_auth_client):
"""No session + auth enabled = 401."""
resp = no_session_auth_client.get("/api/admin/users")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# POST /api/admin/users
# ---------------------------------------------------------------------------
class TestAdminUserCreateEndpoint:
"""Tests for POST /api/admin/users."""
def test_create_user(self, admin_client, user_db):
# Seed an existing user so alice doesn't get auto-promoted to admin
user_db.create_user(username="seed_admin", role="admin")
resp = admin_client.post(
"/api/admin/users",
json={"username": "alice", "password": "pass1234"},
)
assert resp.status_code == 201
assert resp.json["username"] == "alice"
assert resp.json["role"] == "user"
assert "password_hash" not in resp.json
def test_create_user_with_all_fields(self, admin_client):
resp = admin_client.post(
"/api/admin/users",
json={
"username": "alice",
"password": "pass1234",
"email": "alice@example.com",
"display_name": "Alice W",
"role": "admin",
},
)
assert resp.status_code == 201
data = resp.json
assert data["username"] == "alice"
assert data["email"] == "alice@example.com"
assert data["display_name"] == "Alice W"
assert data["role"] == "admin"
def test_create_user_password_is_hashed(self, admin_client, user_db):
admin_client.post(
"/api/admin/users",
json={"username": "alice", "password": "pass1234"},
)
user = user_db.get_user(username="alice")
assert user["password_hash"] is not None
assert user["password_hash"] != "pass1234"
assert user["password_hash"].startswith("scrypt:") or user["password_hash"].startswith("pbkdf2:")
def test_create_user_requires_admin(self, regular_client):
resp = regular_client.post(
"/api/admin/users",
json={"username": "alice", "password": "pass1234"},
)
assert resp.status_code == 403
def test_create_user_missing_username(self, admin_client):
resp = admin_client.post(
"/api/admin/users",
json={"password": "pass1234"},
)
assert resp.status_code == 400
assert "Username" in resp.json["error"]
def test_create_user_empty_username(self, admin_client):
resp = admin_client.post(
"/api/admin/users",
json={"username": " ", "password": "pass1234"},
)
assert resp.status_code == 400
def test_create_user_missing_password(self, admin_client):
resp = admin_client.post(
"/api/admin/users",
json={"username": "alice"},
)
assert resp.status_code == 400
assert "Password" in resp.json["error"]
def test_create_user_short_password(self, admin_client):
resp = admin_client.post(
"/api/admin/users",
json={"username": "alice", "password": "abc"},
)
assert resp.status_code == 400
assert "4 characters" in resp.json["error"]
def test_create_user_invalid_role(self, admin_client):
resp = admin_client.post(
"/api/admin/users",
json={"username": "alice", "password": "pass1234", "role": "superadmin"},
)
assert resp.status_code == 400
assert "Role" in resp.json["error"]
def test_create_user_duplicate_username(self, admin_client, user_db):
user_db.create_user(username="alice")
resp = admin_client.post(
"/api/admin/users",
json={"username": "alice", "password": "pass1234"},
)
assert resp.status_code == 409
assert "already exists" in resp.json["error"]
def test_first_user_is_always_admin(self, admin_client, user_db):
"""First user created should be promoted to admin even if role=user."""
assert len(user_db.list_users()) == 0
resp = admin_client.post(
"/api/admin/users",
json={"username": "alice", "password": "pass1234", "role": "user"},
)
assert resp.status_code == 201
assert resp.json["role"] == "admin"
def test_second_user_keeps_requested_role(self, admin_client, user_db):
"""After the first user, role should be respected."""
user_db.create_user(username="admin_user", role="admin")
resp = admin_client.post(
"/api/admin/users",
json={"username": "bob", "password": "pass1234", "role": "user"},
)
assert resp.status_code == 201
assert resp.json["role"] == "user"
def test_create_user_trims_whitespace(self, admin_client):
resp = admin_client.post(
"/api/admin/users",
json={
"username": " alice ",
"password": "pass1234",
"email": " alice@example.com ",
"display_name": " Alice ",
},
)
assert resp.status_code == 201
assert resp.json["username"] == "alice"
assert resp.json["email"] == "alice@example.com"
assert resp.json["display_name"] == "Alice"
def test_create_user_default_role_is_user(self, admin_client, user_db):
"""When role is omitted and DB already has users, default to 'user'."""
user_db.create_user(username="existing", role="admin")
resp = admin_client.post(
"/api/admin/users",
json={"username": "bob", "password": "pass1234"},
)
assert resp.status_code == 201
assert resp.json["role"] == "user"
# ---------------------------------------------------------------------------
# GET /api/admin/users/<id>
# ---------------------------------------------------------------------------
class TestAdminUserGetEndpoint:
"""Tests for GET /api/admin/users/<id>."""
def test_get_user(self, admin_client, user_db):
user = user_db.create_user(username="alice", email="alice@example.com")
resp = admin_client.get(f"/api/admin/users/{user['id']}")
assert resp.status_code == 200
assert resp.json["username"] == "alice"
assert resp.json["email"] == "alice@example.com"
def test_get_user_includes_settings(self, admin_client, user_db):
user = user_db.create_user(username="alice")
user_db.set_user_settings(user["id"], {"booklore_library_id": 5})
resp = admin_client.get(f"/api/admin/users/{user['id']}")
assert resp.json["settings"]["booklore_library_id"] == 5
def test_get_user_empty_settings(self, admin_client, user_db):
user = user_db.create_user(username="alice")
resp = admin_client.get(f"/api/admin/users/{user['id']}")
assert resp.json["settings"] == {}
def test_get_user_excludes_password_hash(self, admin_client, user_db):
user = user_db.create_user(username="alice", password_hash="secret_hash")
resp = admin_client.get(f"/api/admin/users/{user['id']}")
assert "password_hash" not in resp.json
def test_get_nonexistent_user(self, admin_client):
resp = admin_client.get("/api/admin/users/9999")
assert resp.status_code == 404
def test_get_user_requires_admin(self, regular_client, user_db):
user = user_db.create_user(username="alice")
resp = regular_client.get(f"/api/admin/users/{user['id']}")
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# PUT /api/admin/users/<id>
# ---------------------------------------------------------------------------
class TestAdminUserUpdateEndpoint:
"""Tests for PUT /api/admin/users/<id>."""
def test_update_user_role(self, admin_client, user_db):
user = user_db.create_user(username="alice", role="user")
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={"role": "admin"},
)
assert resp.status_code == 200
updated = user_db.get_user(user_id=user["id"])
assert updated["role"] == "admin"
def test_update_user_email(self, admin_client, user_db):
user = user_db.create_user(username="alice")
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={"email": "alice@new.com"},
)
assert resp.status_code == 200
assert resp.json["email"] == "alice@new.com"
def test_update_user_display_name(self, admin_client, user_db):
user = user_db.create_user(username="alice")
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={"display_name": "Alice Wonderland"},
)
assert resp.status_code == 200
assert resp.json["display_name"] == "Alice Wonderland"
def test_update_multiple_fields(self, admin_client, user_db):
user = user_db.create_user(username="alice", role="user")
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={"role": "admin", "email": "alice@admin.com", "display_name": "Admin Alice"},
)
assert resp.status_code == 200
assert resp.json["role"] == "admin"
assert resp.json["email"] == "alice@admin.com"
assert resp.json["display_name"] == "Admin Alice"
def test_update_user_settings(self, admin_client, user_db):
user = user_db.create_user(username="alice")
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={"settings": {"booklore_library_id": 3}},
)
assert resp.status_code == 200
settings = user_db.get_user_settings(user["id"])
assert settings["booklore_library_id"] == 3
def test_update_settings_merges(self, admin_client, user_db):
user = user_db.create_user(username="alice")
user_db.set_user_settings(user["id"], {"existing_key": "keep"})
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={"settings": {"new_key": "added"}},
)
assert resp.status_code == 200
assert resp.json["settings"]["existing_key"] == "keep"
assert resp.json["settings"]["new_key"] == "added"
def test_update_response_includes_settings(self, admin_client, user_db):
user = user_db.create_user(username="alice")
user_db.set_user_settings(user["id"], {"theme": "dark"})
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={"role": "admin"},
)
assert resp.status_code == 200
assert "settings" in resp.json
assert resp.json["settings"]["theme"] == "dark"
def test_update_response_excludes_password_hash(self, admin_client, user_db):
user = user_db.create_user(username="alice", password_hash="secret")
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={"role": "admin"},
)
assert "password_hash" not in resp.json
def test_update_nonexistent_user(self, admin_client):
resp = admin_client.put(
"/api/admin/users/9999",
json={"role": "admin"},
)
assert resp.status_code == 404
def test_update_requires_admin(self, regular_client, user_db):
user = user_db.create_user(username="alice", role="user")
resp = regular_client.put(
f"/api/admin/users/{user['id']}",
json={"role": "admin"},
)
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# PUT /api/admin/users/<id> — password update
# ---------------------------------------------------------------------------
class TestAdminUserPasswordUpdate:
"""Tests for password update via PUT /api/admin/users/<id>."""
def test_update_password(self, admin_client, user_db):
"""Setting a new password should hash and store it."""
user = user_db.create_user(username="alice", password_hash="old_hash")
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={"password": "newpass99"},
)
assert resp.status_code == 200
updated = user_db.get_user(user_id=user["id"])
assert updated["password_hash"] != "old_hash"
assert updated["password_hash"].startswith("scrypt:") or updated["password_hash"].startswith("pbkdf2:")
def test_update_password_too_short(self, admin_client, user_db):
"""Password shorter than 4 characters should be rejected."""
user = user_db.create_user(username="alice", password_hash="old_hash")
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={"password": "ab"},
)
assert resp.status_code == 400
assert "4 characters" in resp.json["error"]
def test_update_password_empty_string_ignored(self, admin_client, user_db):
"""Empty password string should not change existing hash."""
user = user_db.create_user(username="alice", password_hash="original_hash")
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={"password": ""},
)
assert resp.status_code == 200
updated = user_db.get_user(user_id=user["id"])
assert updated["password_hash"] == "original_hash"
def test_update_password_with_other_fields(self, admin_client, user_db):
"""Password update should work alongside other field updates."""
user = user_db.create_user(username="alice", role="user", password_hash="old")
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={"password": "newpass99", "role": "admin"},
)
assert resp.status_code == 200
assert resp.json["role"] == "admin"
updated = user_db.get_user(user_id=user["id"])
assert updated["password_hash"] != "old"
def test_update_password_hash_not_in_response(self, admin_client, user_db):
"""Response should never contain password_hash."""
user = user_db.create_user(username="alice", password_hash="old")
resp = admin_client.put(
f"/api/admin/users/{user['id']}",
json={"password": "newpass99"},
)
assert resp.status_code == 200
assert "password_hash" not in resp.json
assert "password" not in resp.json
# ---------------------------------------------------------------------------
# GET /api/admin/download-defaults
# ---------------------------------------------------------------------------
class TestAdminDownloadDefaults:
"""Tests for GET /api/admin/download-defaults."""
@pytest.fixture(autouse=True)
def setup_config(self, tmp_path, monkeypatch):
"""Create a temporary downloads config file."""
import json
from pathlib import Path
config_dir = str(tmp_path)
monkeypatch.setenv("CONFIG_DIR", config_dir)
monkeypatch.setattr("shelfmark.config.env.CONFIG_DIR", Path(config_dir))
plugins_dir = tmp_path / "plugins"
plugins_dir.mkdir()
config = {
"BOOKS_OUTPUT_MODE": "folder",
"DESTINATION": "/books",
"BOOKLORE_LIBRARY_ID": "2",
"BOOKLORE_PATH_ID": "5",
"EMAIL_RECIPIENTS": [{"nickname": "kindle", "email": "me@kindle.com"}],
}
(plugins_dir / "downloads.json").write_text(json.dumps(config))
def test_returns_download_defaults(self, admin_client):
resp = admin_client.get("/api/admin/download-defaults")
assert resp.status_code == 200
data = resp.json
assert data["BOOKS_OUTPUT_MODE"] == "folder"
assert data["DESTINATION"] == "/books"
assert data["BOOKLORE_LIBRARY_ID"] == "2"
assert data["BOOKLORE_PATH_ID"] == "5"
assert data["EMAIL_RECIPIENTS"] == [{"nickname": "kindle", "email": "me@kindle.com"}]
def test_returns_defaults_when_no_config(self, admin_client, tmp_path):
"""If no downloads config file exists, return sensible defaults."""
config_path = tmp_path / "plugins" / "downloads.json"
if config_path.exists():
os.remove(config_path)
resp = admin_client.get("/api/admin/download-defaults")
assert resp.status_code == 200
data = resp.json
assert "BOOKS_OUTPUT_MODE" in data
assert "DESTINATION" in data
def test_requires_admin(self, regular_client):
resp = regular_client.get("/api/admin/download-defaults")
assert resp.status_code == 403
class TestAdminBookloreOptions:
"""Tests for GET /api/admin/booklore-options."""
def test_returns_library_and_path_options(self, admin_client, monkeypatch):
mock_libraries = [{"value": "1", "label": "My Library"}]
mock_paths = [{"value": "10", "label": "My Library: /books", "childOf": "1"}]
monkeypatch.setattr(
"shelfmark.core.admin_routes.get_booklore_library_options",
lambda: mock_libraries,
)
monkeypatch.setattr(
"shelfmark.core.admin_routes.get_booklore_path_options",
lambda: mock_paths,
)
resp = admin_client.get("/api/admin/booklore-options")
assert resp.status_code == 200
data = resp.json
assert data["libraries"] == mock_libraries
assert data["paths"] == mock_paths
def test_returns_empty_when_not_configured(self, admin_client, monkeypatch):
monkeypatch.setattr(
"shelfmark.core.admin_routes.get_booklore_library_options",
lambda: [],
)
monkeypatch.setattr(
"shelfmark.core.admin_routes.get_booklore_path_options",
lambda: [],
)
resp = admin_client.get("/api/admin/booklore-options")
assert resp.status_code == 200
data = resp.json
assert data["libraries"] == []
assert data["paths"] == []
def test_requires_admin(self, regular_client):
resp = regular_client.get("/api/admin/booklore-options")
assert resp.status_code == 403
# ---------------------------------------------------------------------------
# DELETE /api/admin/users/<id>
# ---------------------------------------------------------------------------
class TestAdminUserDeleteEndpoint:
"""Tests for DELETE /api/admin/users/<id>."""
def test_delete_user(self, admin_client, user_db):
user = user_db.create_user(username="alice")
resp = admin_client.delete(f"/api/admin/users/{user['id']}")
assert resp.status_code == 200
assert resp.json["success"] is True
assert user_db.get_user(user_id=user["id"]) is None
def test_delete_nonexistent_user(self, admin_client):
resp = admin_client.delete("/api/admin/users/9999")
assert resp.status_code == 404
def test_delete_requires_admin(self, regular_client, user_db):
user = user_db.create_user(username="alice")
resp = regular_client.delete(f"/api/admin/users/{user['id']}")
assert resp.status_code == 403
def test_delete_user_removes_from_list(self, admin_client, user_db):
user = user_db.create_user(username="alice")
user_db.create_user(username="bob")
admin_client.delete(f"/api/admin/users/{user['id']}")
resp = admin_client.get("/api/admin/users")
assert len(resp.json) == 1
assert resp.json[0]["username"] == "bob"
# ---------------------------------------------------------------------------
# OIDC lockout prevention (security on_save handler)
# ---------------------------------------------------------------------------
class TestOIDCLockoutPrevention:
"""Tests for _on_save_security blocking OIDC without a local admin."""
@pytest.fixture(autouse=True)
def setup_config_dir(self, db_path, tmp_path, monkeypatch):
"""Point CONFIG_DIR to a temp dir so _on_save_security can find users.db."""
config_dir = str(tmp_path)
monkeypatch.setenv("CONFIG_DIR", config_dir)
# Create user_db at the path _on_save_security will look for
self._user_db = UserDB(os.path.join(config_dir, "users.db"))
self._user_db.initialize()
def _call_on_save(self, values):
from shelfmark.config.security import _on_save_security
return _on_save_security(values)
def test_oidc_blocked_without_local_admin(self):
"""OIDC should be blocked when no local password admin exists."""
result = self._call_on_save({"AUTH_METHOD": "oidc"})
assert result["error"] is True
assert "local admin" in result["message"].lower()
def test_oidc_blocked_with_oidc_only_admin(self):
"""OIDC admin without password should not count as local admin."""
self._user_db.create_user(
username="sso_admin",
oidc_subject="sub123",
role="admin",
)
result = self._call_on_save({"AUTH_METHOD": "oidc"})
assert result["error"] is True
def test_oidc_blocked_with_local_non_admin(self):
"""A local password user who is not admin should not unblock OIDC."""
self._user_db.create_user(
username="regular",
password_hash="hashed_pw",
role="user",
)
result = self._call_on_save({"AUTH_METHOD": "oidc"})
assert result["error"] is True
def test_oidc_allowed_with_local_admin(self):
"""OIDC should be allowed when a local password admin exists."""
self._user_db.create_user(
username="admin_user",
password_hash="hashed_pw",
role="admin",
)
result = self._call_on_save({"AUTH_METHOD": "oidc"})
assert result["error"] is False
def test_non_oidc_methods_not_blocked(self):
"""Other auth methods should not trigger the OIDC check."""
for method in ("none", "builtin", "proxy", "cwa"):
result = self._call_on_save({"AUTH_METHOD": method})
assert result["error"] is False, f"AUTH_METHOD={method} should not be blocked"
def test_oidc_check_preserves_values(self):
"""When OIDC is blocked, the original values should be returned."""
values = {"AUTH_METHOD": "oidc", "OIDC_CLIENT_ID": "myapp"}
result = self._call_on_save(values)
assert result["values"]["OIDC_CLIENT_ID"] == "myapp"
+90
View File
@@ -0,0 +1,90 @@
"""Tests for per-user BookLore library/path support."""
from shelfmark.download.outputs.booklore import build_booklore_config
class TestBuildBookloreConfigWithOverrides:
"""build_booklore_config should accept per-user library/path overrides."""
BASE_SETTINGS = {
"BOOKLORE_HOST": "http://booklore:6060",
"BOOKLORE_USERNAME": "admin",
"BOOKLORE_PASSWORD": "secret",
"BOOKLORE_LIBRARY_ID": 1,
"BOOKLORE_PATH_ID": 10,
}
def test_global_config_no_overrides(self):
config = build_booklore_config(self.BASE_SETTINGS)
assert config.library_id == 1
assert config.path_id == 10
def test_override_library_and_path(self):
overrides = {"booklore_library_id": 2, "booklore_path_id": 20}
config = build_booklore_config(self.BASE_SETTINGS, user_overrides=overrides)
assert config.library_id == 2
assert config.path_id == 20
def test_override_library_only(self):
overrides = {"booklore_library_id": 3}
config = build_booklore_config(self.BASE_SETTINGS, user_overrides=overrides)
assert config.library_id == 3
assert config.path_id == 10 # falls back to global
def test_override_path_only(self):
overrides = {"booklore_path_id": 30}
config = build_booklore_config(self.BASE_SETTINGS, user_overrides=overrides)
assert config.library_id == 1 # falls back to global
assert config.path_id == 30
def test_empty_overrides_uses_global(self):
config = build_booklore_config(self.BASE_SETTINGS, user_overrides={})
assert config.library_id == 1
assert config.path_id == 10
def test_none_overrides_uses_global(self):
config = build_booklore_config(self.BASE_SETTINGS, user_overrides=None)
assert config.library_id == 1
assert config.path_id == 10
def test_auth_fields_not_overridable(self):
"""Auth stays global - user overrides should not affect host/user/pass."""
overrides = {
"booklore_library_id": 5,
"BOOKLORE_HOST": "http://evil:6060",
"BOOKLORE_USERNAME": "hacker",
}
config = build_booklore_config(self.BASE_SETTINGS, user_overrides=overrides)
assert config.base_url == "http://booklore:6060"
assert config.username == "admin"
assert config.library_id == 5
class TestOutputArgsForBooklore:
"""Download tasks should carry per-user booklore settings in output_args."""
def test_output_args_with_booklore_settings(self):
from shelfmark.core.models import DownloadTask
task = DownloadTask(
task_id="test-1",
source="direct_download",
title="Book1",
output_mode="booklore",
output_args={"booklore_library_id": 2, "booklore_path_id": 20},
user_id=1,
)
assert task.output_args["booklore_library_id"] == 2
assert task.output_args["booklore_path_id"] == 20
def test_output_args_empty_for_global_booklore(self):
from shelfmark.core.models import DownloadTask
task = DownloadTask(
task_id="test-2",
source="direct_download",
title="Book1",
output_mode="booklore",
output_args={},
)
assert task.output_args == {}
+140
View File
@@ -0,0 +1,140 @@
"""Tests for multi-user builtin authentication via users table."""
import os
import tempfile
import pytest
from werkzeug.security import generate_password_hash
from shelfmark.core.user_db import UserDB
@pytest.fixture
def db():
with tempfile.TemporaryDirectory() as tmpdir:
db_path = os.path.join(tmpdir, "users.db")
user_db = UserDB(db_path)
user_db.initialize()
yield user_db
class TestBuiltinMultiUserLogin:
"""Builtin auth should support multiple users via users table."""
def test_create_builtin_user_with_password(self, db):
password_hash = generate_password_hash("secret123")
user = db.create_user(
username="alice",
password_hash=password_hash,
role="user",
)
assert user["username"] == "alice"
assert user["role"] == "user"
def test_create_admin_and_regular_user(self, db):
db.create_user(username="admin", password_hash=generate_password_hash("admin123"), role="admin")
db.create_user(username="user1", password_hash=generate_password_hash("user123"), role="user")
users = db.list_users()
assert len(users) == 2
roles = {u["username"]: u["role"] for u in users}
assert roles["admin"] == "admin"
assert roles["user1"] == "user"
def test_authenticate_builtin_user(self, db):
from werkzeug.security import check_password_hash
password = "mypassword"
password_hash = generate_password_hash(password)
db.create_user(username="bob", password_hash=password_hash, role="user")
user = db.get_user(username="bob")
assert user is not None
assert check_password_hash(user["password_hash"], password)
def test_authenticate_wrong_password(self, db):
from werkzeug.security import check_password_hash
password_hash = generate_password_hash("correct")
db.create_user(username="carol", password_hash=password_hash, role="user")
user = db.get_user(username="carol")
assert not check_password_hash(user["password_hash"], "wrong")
def test_user_not_found(self, db):
user = db.get_user(username="nonexistent")
assert user is None
class TestMigrateBuiltinConfig:
"""When migrating from single-user config to multi-user DB,
the existing admin credentials should be auto-imported."""
def test_migrate_existing_admin(self, db):
"""Simulate migrating BUILTIN_USERNAME/BUILTIN_PASSWORD_HASH to users table."""
existing_username = "myadmin"
existing_hash = generate_password_hash("oldpassword")
# No users yet
assert len(db.list_users()) == 0
# Migration: create admin user from config values
user = db.create_user(
username=existing_username,
password_hash=existing_hash,
role="admin",
)
assert user["username"] == "myadmin"
assert user["role"] == "admin"
assert user["password_hash"] == existing_hash
def test_skip_migration_if_users_exist(self, db):
"""Don't re-migrate if users already exist in DB."""
db.create_user(username="existing_admin", password_hash=generate_password_hash("pw"), role="admin")
# Should have 1 user already, migration should be skipped
assert len(db.list_users()) == 1
class TestBuiltinLoginLogic:
"""Test the login logic that mirrors what main.py will do for builtin multi-user."""
def _builtin_login(self, db, username, password):
"""Mirror the multi-user builtin login logic."""
from werkzeug.security import check_password_hash
user = db.get_user(username=username)
if not user:
return None
if not user.get("password_hash"):
return None
if not check_password_hash(user["password_hash"], password):
return None
return {
"user_id": username,
"db_user_id": user["id"],
"is_admin": user["role"] == "admin",
}
def test_login_admin(self, db):
db.create_user(username="admin", password_hash=generate_password_hash("admin123"), role="admin")
result = self._builtin_login(db, "admin", "admin123")
assert result is not None
assert result["is_admin"] is True
assert result["user_id"] == "admin"
def test_login_regular_user(self, db):
db.create_user(username="user1", password_hash=generate_password_hash("pass1"), role="user")
result = self._builtin_login(db, "user1", "pass1")
assert result is not None
assert result["is_admin"] is False
def test_login_wrong_password(self, db):
db.create_user(username="user1", password_hash=generate_password_hash("correct"), role="user")
result = self._builtin_login(db, "user1", "wrong")
assert result is None
def test_login_nonexistent_user(self, db):
result = self._builtin_login(db, "nobody", "pass")
assert result is None
def test_login_sets_db_user_id(self, db):
user = db.create_user(username="dave", password_hash=generate_password_hash("pw"), role="user")
result = self._builtin_login(db, "dave", "pw")
assert result["db_user_id"] == user["id"]
+258
View File
@@ -0,0 +1,258 @@
"""
Tests for OIDC authentication flow.
Tests the OIDCAuth helper: login URL generation, callback handling,
user provisioning, and group claim parsing.
"""
import os
import tempfile
import pytest
MOCK_DISCOVERY = {
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"userinfo_endpoint": "https://auth.example.com/userinfo",
"jwks_uri": "https://auth.example.com/.well-known/jwks.json",
}
MOCK_OIDC_CONFIG = {
"OIDC_DISCOVERY_URL": "https://auth.example.com/.well-known/openid-configuration",
"OIDC_CLIENT_ID": "shelfmark",
"OIDC_CLIENT_SECRET": "secret123",
"OIDC_SCOPES": ["openid", "email", "profile", "groups"],
"OIDC_GROUP_CLAIM": "groups",
"OIDC_ADMIN_GROUP": "shelfmark-admins",
"OIDC_AUTO_PROVISION": True,
"OIDC_USE_ADMIN_GROUP": True,
}
@pytest.fixture
def db_path():
with tempfile.TemporaryDirectory() as tmpdir:
yield os.path.join(tmpdir, "shelfmark.db")
@pytest.fixture
def user_db(db_path):
from shelfmark.core.user_db import UserDB
db = UserDB(db_path)
db.initialize()
return db
class TestParseGroupClaims:
"""Tests for parsing group claims from ID tokens."""
def test_parse_groups_list(self):
from shelfmark.core.oidc_auth import parse_group_claims
id_token = {"groups": ["admins", "users", "shelfmark-admins"]}
groups = parse_group_claims(id_token, "groups")
assert "shelfmark-admins" in groups
assert "admins" in groups
def test_parse_groups_comma_separated_string(self):
from shelfmark.core.oidc_auth import parse_group_claims
id_token = {"groups": "admins, users, shelfmark-admins"}
groups = parse_group_claims(id_token, "groups")
assert "shelfmark-admins" in groups
def test_parse_groups_pipe_separated_string(self):
from shelfmark.core.oidc_auth import parse_group_claims
id_token = {"groups": "admins|users|shelfmark-admins"}
groups = parse_group_claims(id_token, "groups")
assert "shelfmark-admins" in groups
def test_parse_groups_missing_claim(self):
from shelfmark.core.oidc_auth import parse_group_claims
id_token = {"email": "user@example.com"}
groups = parse_group_claims(id_token, "groups")
assert groups == []
def test_parse_groups_empty(self):
from shelfmark.core.oidc_auth import parse_group_claims
id_token = {"groups": []}
groups = parse_group_claims(id_token, "groups")
assert groups == []
class TestCheckAdminFromGroups:
"""Tests for determining admin status from group claims.
Admin check is now inline: `admin_group in groups` when use_admin_group is True.
These tests verify the logic that was previously in is_admin_from_groups().
"""
def test_admin_when_group_matches(self):
groups = ["users", "shelfmark-admins"]
admin_group = "shelfmark-admins"
assert admin_group in groups
def test_not_admin_when_group_missing(self):
groups = ["users", "editors"]
admin_group = "shelfmark-admins"
assert admin_group not in groups
def test_not_admin_when_no_groups(self):
assert "shelfmark-admins" not in []
def test_not_admin_when_admin_group_empty(self):
groups = ["users", "admins"]
# When admin_group is empty, use_admin_group check is skipped (is_admin stays None)
admin_group = ""
use_admin_group = True
is_admin = None
if admin_group and use_admin_group:
is_admin = admin_group in groups
assert is_admin is None
class TestExtractUserInfo:
"""Tests for extracting user info from OIDC claims."""
def test_extract_standard_claims(self):
from shelfmark.core.oidc_auth import extract_user_info
id_token = {
"sub": "user-123",
"email": "john@example.com",
"name": "John Doe",
"preferred_username": "john",
}
info = extract_user_info(id_token)
assert info["oidc_subject"] == "user-123"
assert info["email"] == "john@example.com"
assert info["display_name"] == "John Doe"
assert info["username"] == "john"
def test_extract_falls_back_to_email_for_username(self):
from shelfmark.core.oidc_auth import extract_user_info
id_token = {
"sub": "user-123",
"email": "john@example.com",
"name": "John Doe",
}
info = extract_user_info(id_token)
assert info["username"] == "john@example.com"
def test_extract_falls_back_to_sub_for_username(self):
from shelfmark.core.oidc_auth import extract_user_info
id_token = {
"sub": "user-123",
}
info = extract_user_info(id_token)
assert info["username"] == "user-123"
def test_extract_handles_missing_optional_fields(self):
from shelfmark.core.oidc_auth import extract_user_info
id_token = {"sub": "user-123"}
info = extract_user_info(id_token)
assert info["oidc_subject"] == "user-123"
assert info["email"] is None
assert info["display_name"] is None
class TestProvisionOIDCUser:
"""Tests for creating/updating users from OIDC claims."""
def test_provision_creates_new_user(self, user_db):
from shelfmark.core.oidc_auth import provision_oidc_user
user_info = {
"oidc_subject": "sub-123",
"username": "john",
"email": "john@example.com",
"display_name": "John Doe",
}
user = provision_oidc_user(user_db, user_info, is_admin=False)
assert user["username"] == "john"
assert user["oidc_subject"] == "sub-123"
assert user["role"] == "user"
def test_provision_creates_admin_user(self, user_db):
from shelfmark.core.oidc_auth import provision_oidc_user
user_info = {
"oidc_subject": "sub-123",
"username": "john",
"email": "john@example.com",
"display_name": "John Doe",
}
user = provision_oidc_user(user_db, user_info, is_admin=True)
assert user["role"] == "admin"
def test_provision_returns_existing_user(self, user_db):
from shelfmark.core.oidc_auth import provision_oidc_user
user_info = {
"oidc_subject": "sub-123",
"username": "john",
"email": "john@example.com",
"display_name": "John Doe",
}
user1 = provision_oidc_user(user_db, user_info, is_admin=False)
user2 = provision_oidc_user(user_db, user_info, is_admin=False)
assert user1["id"] == user2["id"]
def test_provision_updates_existing_user_info(self, user_db):
from shelfmark.core.oidc_auth import provision_oidc_user
user_info = {
"oidc_subject": "sub-123",
"username": "john",
"email": "john@example.com",
"display_name": "John Doe",
}
provision_oidc_user(user_db, user_info, is_admin=False)
user_info["email"] = "newemail@example.com"
user_info["display_name"] = "John D."
user = provision_oidc_user(user_db, user_info, is_admin=False)
assert user["email"] == "newemail@example.com"
assert user["display_name"] == "John D."
def test_provision_updates_admin_role(self, user_db):
from shelfmark.core.oidc_auth import provision_oidc_user
user_info = {
"oidc_subject": "sub-123",
"username": "john",
"email": "john@example.com",
"display_name": "John Doe",
}
user = provision_oidc_user(user_db, user_info, is_admin=False)
assert user["role"] == "user"
user = provision_oidc_user(user_db, user_info, is_admin=True)
assert user["role"] == "admin"
def test_provision_preserves_role_when_group_auth_disabled(self, user_db):
"""When is_admin=None (group auth disabled), DB role should be preserved."""
from shelfmark.core.oidc_auth import provision_oidc_user
user_info = {
"oidc_subject": "sub-123",
"username": "john",
"email": "john@example.com",
"display_name": "John Doe",
}
# Create as admin via group auth
user = provision_oidc_user(user_db, user_info, is_admin=True)
assert user["role"] == "admin"
# Login again with group auth disabled (is_admin=None) — should preserve admin
user = provision_oidc_user(user_db, user_info, is_admin=None)
assert user["role"] == "admin"
def test_provision_handles_duplicate_username(self, user_db):
"""If OIDC subject is new but username exists, append suffix."""
from shelfmark.core.oidc_auth import provision_oidc_user
# Create a local user with the same username
user_db.create_user(username="john", password_hash="hash")
user_info = {
"oidc_subject": "sub-456",
"username": "john",
"email": "john@example.com",
"display_name": "John Doe",
}
user = provision_oidc_user(user_db, user_info, is_admin=False)
assert user["username"] != "john" # Should have a suffix
assert user["oidc_subject"] == "sub-456"
+206
View File
@@ -0,0 +1,206 @@
"""
Tests for OIDC integration into existing auth system.
Tests get_auth_mode() logic with OIDC and login_required admin
restriction logic. Since main.py has heavy dependencies, we test
the logic directly rather than importing from main.
"""
class TestGetAuthModeOIDCLogic:
"""Tests that get_auth_mode logic handles OIDC correctly.
Mirrors the logic in main.py:get_auth_mode() to verify OIDC
support without importing the full app.
"""
def _get_auth_mode(self, config):
"""Replicate get_auth_mode logic with OIDC support."""
auth_mode = config.get("AUTH_METHOD", "none")
if auth_mode == "oidc":
if config.get("OIDC_DISCOVERY_URL") and config.get("OIDC_CLIENT_ID"):
return "oidc"
return "none"
if auth_mode == "builtin":
if config.get("BUILTIN_USERNAME") and config.get("BUILTIN_PASSWORD_HASH"):
return "builtin"
return "none"
if auth_mode == "proxy":
if config.get("PROXY_AUTH_USER_HEADER"):
return "proxy"
return "none"
return "none"
def test_returns_oidc_when_fully_configured(self):
config = {
"AUTH_METHOD": "oidc",
"OIDC_DISCOVERY_URL": "https://auth.example.com/.well-known/openid-configuration",
"OIDC_CLIENT_ID": "shelfmark",
}
assert self._get_auth_mode(config) == "oidc"
def test_returns_none_when_oidc_missing_client_id(self):
config = {
"AUTH_METHOD": "oidc",
"OIDC_DISCOVERY_URL": "https://auth.example.com/.well-known/openid-configuration",
}
assert self._get_auth_mode(config) == "none"
def test_returns_none_when_oidc_missing_discovery_url(self):
config = {
"AUTH_METHOD": "oidc",
"OIDC_CLIENT_ID": "shelfmark",
}
assert self._get_auth_mode(config) == "none"
def test_returns_none_when_oidc_empty_strings(self):
config = {
"AUTH_METHOD": "oidc",
"OIDC_DISCOVERY_URL": "",
"OIDC_CLIENT_ID": "",
}
assert self._get_auth_mode(config) == "none"
def test_builtin_still_works(self):
config = {
"AUTH_METHOD": "builtin",
"BUILTIN_USERNAME": "admin",
"BUILTIN_PASSWORD_HASH": "hash",
}
assert self._get_auth_mode(config) == "builtin"
def test_proxy_still_works(self):
config = {
"AUTH_METHOD": "proxy",
"PROXY_AUTH_USER_HEADER": "X-Auth-User",
}
assert self._get_auth_mode(config) == "proxy"
class TestLoginRequiredOIDCLogic:
"""Tests the OIDC admin restriction logic.
Mirrors the admin check in main.py:login_required() to verify
OIDC support without importing the full app.
"""
def _check_admin_access(self, auth_mode, config, session, path):
"""Replicate login_required admin check logic with OIDC."""
if auth_mode == "none":
return True # Allowed
if "user_id" not in session:
return 401 # Unauthorized
settings_path = path.startswith("/api/settings") or path.startswith("/api/onboarding")
if auth_mode in ("proxy", "cwa", "oidc") and settings_path:
if auth_mode == "proxy":
restrict = config.get("PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN", False)
elif auth_mode == "cwa":
restrict = config.get("CWA_RESTRICT_SETTINGS_TO_ADMIN", False)
elif auth_mode == "oidc":
restrict = config.get("OIDC_RESTRICT_SETTINGS_TO_ADMIN", False)
else:
restrict = False
if restrict and not session.get("is_admin", False):
return 403 # Forbidden
return True # Allowed
def test_oidc_unauthenticated_returns_401(self):
config = {"OIDC_RESTRICT_SETTINGS_TO_ADMIN": True}
result = self._check_admin_access("oidc", config, {}, "/api/settings/test")
assert result == 401
def test_oidc_non_admin_blocked_from_settings(self):
config = {"OIDC_RESTRICT_SETTINGS_TO_ADMIN": True}
session = {"user_id": "user", "is_admin": False}
result = self._check_admin_access("oidc", config, session, "/api/settings/test")
assert result == 403
def test_oidc_admin_can_access_settings(self):
config = {"OIDC_RESTRICT_SETTINGS_TO_ADMIN": True}
session = {"user_id": "admin", "is_admin": True}
result = self._check_admin_access("oidc", config, session, "/api/settings/test")
assert result is True
def test_oidc_non_admin_can_access_non_settings(self):
config = {"OIDC_RESTRICT_SETTINGS_TO_ADMIN": True}
session = {"user_id": "user", "is_admin": False}
result = self._check_admin_access("oidc", config, session, "/api/search")
assert result is True
def test_oidc_no_restrict_allows_non_admin_settings(self):
config = {"OIDC_RESTRICT_SETTINGS_TO_ADMIN": False}
session = {"user_id": "user", "is_admin": False}
result = self._check_admin_access("oidc", config, session, "/api/settings/test")
assert result is True
def test_oidc_non_admin_blocked_from_onboarding(self):
config = {"OIDC_RESTRICT_SETTINGS_TO_ADMIN": True}
session = {"user_id": "user", "is_admin": False}
result = self._check_admin_access("oidc", config, session, "/api/onboarding")
assert result == 403
class TestAuthCheckOIDCLogic:
"""Tests the /api/auth/check response logic for OIDC mode."""
def _build_auth_check_response(self, auth_mode, config, session):
"""Replicate auth check logic with OIDC."""
if auth_mode == "none":
return {"authenticated": True, "auth_required": False, "auth_mode": "none", "is_admin": True}
is_authenticated = "user_id" in session
if auth_mode == "builtin":
is_admin = True
elif auth_mode == "cwa":
restrict = config.get("CWA_RESTRICT_SETTINGS_TO_ADMIN", False)
is_admin = session.get("is_admin", False) if restrict else True
elif auth_mode == "proxy":
restrict = config.get("PROXY_AUTH_RESTRICT_SETTINGS_TO_ADMIN", False)
is_admin = session.get("is_admin", not restrict)
elif auth_mode == "oidc":
restrict = config.get("OIDC_RESTRICT_SETTINGS_TO_ADMIN", False)
is_admin = session.get("is_admin", False) if restrict else True
else:
is_admin = False
return {
"authenticated": is_authenticated,
"auth_required": True,
"auth_mode": auth_mode,
"is_admin": is_admin if is_authenticated else False,
"username": session.get("user_id") if is_authenticated else None,
}
def test_oidc_authenticated_admin(self):
config = {"OIDC_RESTRICT_SETTINGS_TO_ADMIN": True}
session = {"user_id": "admin", "is_admin": True}
result = self._build_auth_check_response("oidc", config, session)
assert result["authenticated"] is True
assert result["auth_mode"] == "oidc"
assert result["is_admin"] is True
assert result["username"] == "admin"
def test_oidc_authenticated_non_admin(self):
config = {"OIDC_RESTRICT_SETTINGS_TO_ADMIN": True}
session = {"user_id": "user", "is_admin": False}
result = self._build_auth_check_response("oidc", config, session)
assert result["is_admin"] is False
def test_oidc_no_restrict_all_are_admin(self):
config = {"OIDC_RESTRICT_SETTINGS_TO_ADMIN": False}
session = {"user_id": "user", "is_admin": False}
result = self._build_auth_check_response("oidc", config, session)
assert result["is_admin"] is True
def test_oidc_unauthenticated(self):
config = {"OIDC_RESTRICT_SETTINGS_TO_ADMIN": True}
result = self._build_auth_check_response("oidc", config, {})
assert result["authenticated"] is False
assert result["is_admin"] is False
assert result["auth_required"] is True
+313
View File
@@ -0,0 +1,313 @@
"""
Tests for OIDC Flask route handlers.
Tests the /api/auth/oidc/login and /api/auth/oidc/callback endpoints
using a minimal Flask test app (not the full shelfmark app).
"""
import os
import tempfile
from unittest.mock import patch
from urllib.parse import parse_qs, urlparse
import pytest
from flask import Flask
from shelfmark.core.user_db import UserDB
@pytest.fixture
def db_path():
with tempfile.TemporaryDirectory() as tmpdir:
yield os.path.join(tmpdir, "shelfmark.db")
@pytest.fixture
def user_db(db_path):
db = UserDB(db_path)
db.initialize()
return db
MOCK_OIDC_CONFIG = {
"AUTH_METHOD": "oidc",
"OIDC_DISCOVERY_URL": "https://auth.example.com/.well-known/openid-configuration",
"OIDC_CLIENT_ID": "shelfmark",
"OIDC_CLIENT_SECRET": "secret123",
"OIDC_SCOPES": ["openid", "email", "profile", "groups"],
"OIDC_GROUP_CLAIM": "groups",
"OIDC_ADMIN_GROUP": "shelfmark-admins",
"OIDC_AUTO_PROVISION": True,
"OIDC_RESTRICT_SETTINGS_TO_ADMIN": True,
}
MOCK_DISCOVERY = {
"issuer": "https://auth.example.com",
"authorization_endpoint": "https://auth.example.com/authorize",
"token_endpoint": "https://auth.example.com/token",
"userinfo_endpoint": "https://auth.example.com/userinfo",
"jwks_uri": "https://auth.example.com/.well-known/jwks.json",
}
@pytest.fixture
def app(user_db, db_path):
"""Create a minimal Flask test app with OIDC routes."""
from shelfmark.core.oidc_routes import register_oidc_routes
test_app = Flask(__name__)
test_app.config["SECRET_KEY"] = "test-secret"
test_app.config["TESTING"] = True
register_oidc_routes(test_app, user_db)
return test_app
@pytest.fixture
def client(app):
return app.test_client()
class TestOIDCLoginEndpoint:
"""Tests for GET /api/auth/oidc/login."""
@patch("shelfmark.core.oidc_routes.load_config_file", return_value=MOCK_OIDC_CONFIG)
@patch("shelfmark.core.oidc_routes._fetch_discovery", return_value=MOCK_DISCOVERY)
def test_login_redirects_to_idp(self, mock_discovery, mock_config, client):
resp = client.get("/api/auth/oidc/login")
assert resp.status_code == 302
location = resp.headers["Location"]
assert location.startswith("https://auth.example.com/authorize")
@patch("shelfmark.core.oidc_routes.load_config_file", return_value=MOCK_OIDC_CONFIG)
@patch("shelfmark.core.oidc_routes._fetch_discovery", return_value=MOCK_DISCOVERY)
def test_login_includes_required_params(self, mock_discovery, mock_config, client):
resp = client.get("/api/auth/oidc/login")
location = resp.headers["Location"]
parsed = urlparse(location)
params = parse_qs(parsed.query)
assert params["client_id"] == ["shelfmark"]
assert params["response_type"] == ["code"]
assert "state" in params
assert "code_challenge" in params
assert params["code_challenge_method"] == ["S256"]
@patch("shelfmark.core.oidc_routes.load_config_file", return_value=MOCK_OIDC_CONFIG)
@patch("shelfmark.core.oidc_routes._fetch_discovery", return_value=MOCK_DISCOVERY)
def test_login_includes_scopes(self, mock_discovery, mock_config, client):
resp = client.get("/api/auth/oidc/login")
location = resp.headers["Location"]
parsed = urlparse(location)
params = parse_qs(parsed.query)
scope = params["scope"][0]
assert "openid" in scope
assert "email" in scope
assert "profile" in scope
assert "groups" in scope
@patch("shelfmark.core.oidc_routes.load_config_file", return_value=MOCK_OIDC_CONFIG)
@patch("shelfmark.core.oidc_routes._fetch_discovery", return_value=MOCK_DISCOVERY)
def test_login_stores_state_in_session(self, mock_discovery, mock_config, client):
with client.session_transaction() as sess:
assert "oidc_state" not in sess
client.get("/api/auth/oidc/login")
with client.session_transaction() as sess:
assert "oidc_state" in sess
assert "oidc_code_verifier" in sess
class TestOIDCCallbackEndpoint:
"""Tests for GET /api/auth/oidc/callback."""
@patch("shelfmark.core.oidc_routes.load_config_file", return_value=MOCK_OIDC_CONFIG)
def test_callback_rejects_missing_state(self, mock_config, client):
resp = client.get("/api/auth/oidc/callback?code=abc123")
assert resp.status_code == 400
@patch("shelfmark.core.oidc_routes.load_config_file", return_value=MOCK_OIDC_CONFIG)
def test_callback_rejects_mismatched_state(self, mock_config, client):
with client.session_transaction() as sess:
sess["oidc_state"] = "correct-state"
sess["oidc_code_verifier"] = "verifier"
resp = client.get("/api/auth/oidc/callback?code=abc123&state=wrong-state")
assert resp.status_code == 400
@patch("shelfmark.core.oidc_routes.load_config_file", return_value=MOCK_OIDC_CONFIG)
@patch("shelfmark.core.oidc_routes._fetch_discovery", return_value=MOCK_DISCOVERY)
@patch("shelfmark.core.oidc_routes._exchange_code")
def test_callback_creates_session(self, mock_exchange, mock_discovery, mock_config, client, user_db):
mock_exchange.return_value = {
"sub": "user-123",
"email": "john@example.com",
"name": "John Doe",
"preferred_username": "john",
"groups": ["users"],
}
with client.session_transaction() as sess:
sess["oidc_state"] = "test-state"
sess["oidc_code_verifier"] = "test-verifier"
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
assert resp.status_code == 302 # Redirect to frontend
with client.session_transaction() as sess:
assert sess["user_id"] == "john"
assert "oidc_state" not in sess # Cleaned up
@patch("shelfmark.core.oidc_routes.load_config_file", return_value=MOCK_OIDC_CONFIG)
@patch("shelfmark.core.oidc_routes._fetch_discovery", return_value=MOCK_DISCOVERY)
@patch("shelfmark.core.oidc_routes._exchange_code")
def test_callback_sets_admin_from_groups(self, mock_exchange, mock_discovery, mock_config, client, user_db):
mock_exchange.return_value = {
"sub": "admin-123",
"email": "admin@example.com",
"preferred_username": "admin",
"groups": ["users", "shelfmark-admins"],
}
with client.session_transaction() as sess:
sess["oidc_state"] = "test-state"
sess["oidc_code_verifier"] = "test-verifier"
client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
with client.session_transaction() as sess:
assert sess["is_admin"] is True
@patch("shelfmark.core.oidc_routes.load_config_file", return_value=MOCK_OIDC_CONFIG)
@patch("shelfmark.core.oidc_routes._fetch_discovery", return_value=MOCK_DISCOVERY)
@patch("shelfmark.core.oidc_routes._exchange_code")
def test_callback_provisions_user_in_db(self, mock_exchange, mock_discovery, mock_config, client, user_db):
mock_exchange.return_value = {
"sub": "user-789",
"email": "new@example.com",
"name": "New User",
"preferred_username": "newuser",
"groups": [],
}
with client.session_transaction() as sess:
sess["oidc_state"] = "test-state"
sess["oidc_code_verifier"] = "test-verifier"
client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
user = user_db.get_user(oidc_subject="user-789")
assert user is not None
assert user["username"] == "newuser"
assert user["email"] == "new@example.com"
@patch("shelfmark.core.oidc_routes.load_config_file")
@patch("shelfmark.core.oidc_routes._fetch_discovery", return_value=MOCK_DISCOVERY)
@patch("shelfmark.core.oidc_routes._exchange_code")
def test_callback_rejects_when_auto_provision_disabled(self, mock_exchange, mock_discovery, mock_config, client, user_db):
config = {**MOCK_OIDC_CONFIG, "OIDC_AUTO_PROVISION": False}
mock_config.return_value = config
mock_exchange.return_value = {
"sub": "unknown-user",
"email": "unknown@example.com",
"preferred_username": "unknown",
"groups": [],
}
with client.session_transaction() as sess:
sess["oidc_state"] = "test-state"
sess["oidc_code_verifier"] = "test-verifier"
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
assert resp.status_code == 403
@patch("shelfmark.core.oidc_routes.load_config_file")
@patch("shelfmark.core.oidc_routes._fetch_discovery", return_value=MOCK_DISCOVERY)
@patch("shelfmark.core.oidc_routes._exchange_code")
def test_callback_allows_pre_created_user_by_email_when_no_provision(
self, mock_exchange, mock_discovery, mock_config, client, user_db
):
"""Pre-created user (by email) should log in even when auto-provision is off."""
config = {**MOCK_OIDC_CONFIG, "OIDC_AUTO_PROVISION": False}
mock_config.return_value = config
# Admin pre-creates a user with this email (no oidc_subject yet)
user_db.create_user(username="alice", email="alice@example.com", password_hash="hash")
mock_exchange.return_value = {
"sub": "oidc-alice-sub",
"email": "alice@example.com",
"preferred_username": "alice_oidc",
"groups": [],
}
with client.session_transaction() as sess:
sess["oidc_state"] = "test-state"
sess["oidc_code_verifier"] = "test-verifier"
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
assert resp.status_code == 302 # Success, redirects to frontend
with client.session_transaction() as sess:
assert sess["user_id"] == "alice"
assert sess.get("db_user_id") is not None
@patch("shelfmark.core.oidc_routes.load_config_file")
@patch("shelfmark.core.oidc_routes._fetch_discovery", return_value=MOCK_DISCOVERY)
@patch("shelfmark.core.oidc_routes._exchange_code")
def test_callback_links_oidc_subject_to_pre_created_user(
self, mock_exchange, mock_discovery, mock_config, client, user_db
):
"""When a pre-created user logs in via OIDC, their oidc_subject should be linked."""
config = {**MOCK_OIDC_CONFIG, "OIDC_AUTO_PROVISION": False}
mock_config.return_value = config
user = user_db.create_user(username="bob", email="bob@example.com", password_hash="hash")
mock_exchange.return_value = {
"sub": "oidc-bob-sub",
"email": "bob@example.com",
"preferred_username": "bob_oidc",
"groups": [],
}
with client.session_transaction() as sess:
sess["oidc_state"] = "test-state"
sess["oidc_code_verifier"] = "test-verifier"
client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
# The OIDC subject should now be linked to the existing user
updated_user = user_db.get_user(user_id=user["id"])
assert updated_user["oidc_subject"] == "oidc-bob-sub"
@patch("shelfmark.core.oidc_routes.load_config_file")
@patch("shelfmark.core.oidc_routes._fetch_discovery", return_value=MOCK_DISCOVERY)
@patch("shelfmark.core.oidc_routes._exchange_code")
def test_callback_rejects_unknown_email_when_no_provision(
self, mock_exchange, mock_discovery, mock_config, client, user_db
):
"""When auto-provision is off and no user matches by email, reject login."""
config = {**MOCK_OIDC_CONFIG, "OIDC_AUTO_PROVISION": False}
mock_config.return_value = config
# Pre-create a user with a different email
user_db.create_user(username="charlie", email="charlie@example.com", password_hash="hash")
mock_exchange.return_value = {
"sub": "oidc-unknown-sub",
"email": "stranger@example.com",
"preferred_username": "stranger",
"groups": [],
}
with client.session_transaction() as sess:
sess["oidc_state"] = "test-state"
sess["oidc_code_verifier"] = "test-verifier"
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
assert resp.status_code == 403
+231
View File
@@ -0,0 +1,231 @@
"""
Tests for per-user download scoping.
Tests that DownloadTask has a user_id field and that the queue
can be filtered by user.
"""
from shelfmark.core.models import DownloadTask
from shelfmark.core.queue import BookQueue
class TestDownloadTaskUserId:
"""Tests that DownloadTask supports user_id."""
def test_download_task_has_user_id_field(self):
task = DownloadTask(
task_id="test-123",
source="direct_download",
title="Test Book",
user_id=42,
)
assert task.user_id == 42
def test_download_task_user_id_defaults_to_none(self):
task = DownloadTask(
task_id="test-123",
source="direct_download",
title="Test Book",
)
assert task.user_id is None
def test_download_task_preserves_user_id_in_queue(self):
q = BookQueue()
task = DownloadTask(
task_id="test-123",
source="direct_download",
title="Test Book",
user_id=42,
)
q.add(task)
retrieved = q.get_task("test-123")
assert retrieved.user_id == 42
class TestQueueFilterByUser:
"""Tests for filtering queue status by user."""
def _make_task(self, task_id, user_id=None):
return DownloadTask(
task_id=task_id,
source="direct_download",
title=f"Book {task_id}",
user_id=user_id,
)
def test_get_status_returns_all_when_no_filter(self):
q = BookQueue()
q.add(self._make_task("book-1", user_id=1))
q.add(self._make_task("book-2", user_id=2))
q.add(self._make_task("book-3", user_id=1))
status = q.get_status()
all_tasks = {}
for tasks_by_status in status.values():
all_tasks.update(tasks_by_status)
assert len(all_tasks) == 3
def test_get_status_for_user_filters(self):
q = BookQueue()
q.add(self._make_task("book-1", user_id=1))
q.add(self._make_task("book-2", user_id=2))
q.add(self._make_task("book-3", user_id=1))
status = q.get_status(user_id=1)
all_tasks = {}
for tasks_by_status in status.values():
all_tasks.update(tasks_by_status)
assert len(all_tasks) == 2
assert "book-1" in all_tasks
assert "book-3" in all_tasks
assert "book-2" not in all_tasks
def test_get_status_for_user_returns_empty_when_none(self):
q = BookQueue()
q.add(self._make_task("book-1", user_id=1))
status = q.get_status(user_id=999)
all_tasks = {}
for tasks_by_status in status.values():
all_tasks.update(tasks_by_status)
assert len(all_tasks) == 0
def test_get_status_no_user_id_filter_includes_legacy_tasks(self):
"""Tasks without user_id (legacy) are visible to everyone."""
q = BookQueue()
q.add(self._make_task("book-1", user_id=None))
q.add(self._make_task("book-2", user_id=1))
# No filter - see all
status = q.get_status()
all_tasks = {}
for tasks_by_status in status.values():
all_tasks.update(tasks_by_status)
assert len(all_tasks) == 2
def test_get_status_user_filter_includes_legacy_tasks(self):
"""Tasks without user_id are visible to any user (backward compat)."""
q = BookQueue()
q.add(self._make_task("book-1", user_id=None))
q.add(self._make_task("book-2", user_id=1))
status = q.get_status(user_id=1)
all_tasks = {}
for tasks_by_status in status.values():
all_tasks.update(tasks_by_status)
# User 1 sees their own + legacy (no user_id)
assert len(all_tasks) == 2
# ---------------------------------------------------------------------------
# Per-user destination override in get_final_destination
# ---------------------------------------------------------------------------
class TestPerUserDestination:
"""get_final_destination should respect per-user destination override in output_args."""
def test_uses_per_user_destination(self, monkeypatch):
"""When output_args has a destination, it should be used instead of global."""
from pathlib import Path
task = DownloadTask(
task_id="book1",
source="direct_download",
title="Test Book",
output_args={"destination": "/user-books/alice"},
)
monkeypatch.setattr(
"shelfmark.download.postprocess.destination.get_destination",
lambda is_audiobook=False: Path("/global/books"),
)
monkeypatch.setattr(
"shelfmark.download.postprocess.destination.get_aa_content_type_dir",
lambda ct: None,
)
from shelfmark.download.postprocess.destination import get_final_destination
result = get_final_destination(task)
assert result == Path("/user-books/alice")
def test_falls_back_to_global_without_override(self, monkeypatch):
"""When no per-user destination, should use global destination."""
from pathlib import Path
task = DownloadTask(
task_id="book1",
source="direct_download",
title="Test Book",
output_args={},
)
monkeypatch.setattr(
"shelfmark.download.postprocess.destination.get_destination",
lambda is_audiobook=False: Path("/global/books"),
)
monkeypatch.setattr(
"shelfmark.download.postprocess.destination.get_aa_content_type_dir",
lambda ct: None,
)
from shelfmark.download.postprocess.destination import get_final_destination
result = get_final_destination(task)
assert result == Path("/global/books")
def test_per_user_destination_empty_string_falls_back_to_global(self, monkeypatch):
"""Empty string destination should fall back to global."""
from pathlib import Path
task = DownloadTask(
task_id="book1",
source="direct_download",
title="Test Book",
output_args={"destination": ""},
)
monkeypatch.setattr(
"shelfmark.download.postprocess.destination.get_destination",
lambda is_audiobook=False: Path("/global/books"),
)
monkeypatch.setattr(
"shelfmark.download.postprocess.destination.get_aa_content_type_dir",
lambda ct: None,
)
from shelfmark.download.postprocess.destination import get_final_destination
result = get_final_destination(task)
assert result == Path("/global/books")
class TestTaskToDictUsername:
"""Tests that _task_to_dict includes username for frontend display."""
def test_task_to_dict_includes_username(self):
"""Username should be included in serialized task dict."""
from shelfmark.download.orchestrator import _task_to_dict
task = DownloadTask(
task_id="book1",
source="direct_download",
title="Test Book",
user_id=5,
username="alice",
)
result = _task_to_dict(task)
assert result["username"] == "alice"
def test_task_to_dict_username_none_when_no_auth(self):
"""Username should be None when no user is set (no-auth mode)."""
from shelfmark.download.orchestrator import _task_to_dict
task = DownloadTask(
task_id="book1",
source="direct_download",
title="Test Book",
)
result = _task_to_dict(task)
assert result["username"] is None
+195
View File
@@ -0,0 +1,195 @@
"""
Tests for SQLite user database.
Tests CRUD operations on users and user_settings tables.
"""
import os
import sqlite3
import tempfile
import pytest
@pytest.fixture
def db_path():
"""Create a temporary database path."""
with tempfile.TemporaryDirectory() as tmpdir:
yield os.path.join(tmpdir, "shelfmark.db")
@pytest.fixture
def user_db(db_path):
"""Create a UserDB instance with a temporary database."""
from shelfmark.core.user_db import UserDB
db = UserDB(db_path)
db.initialize()
return db
class TestUserDBInitialization:
"""Tests for database creation and schema setup."""
def test_initialize_creates_database_file(self, db_path):
from shelfmark.core.user_db import UserDB
db = UserDB(db_path)
db.initialize()
assert os.path.exists(db_path)
def test_initialize_creates_users_table(self, user_db, db_path):
conn = sqlite3.connect(db_path)
cursor = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
)
assert cursor.fetchone() is not None
conn.close()
def test_initialize_creates_user_settings_table(self, user_db, db_path):
conn = sqlite3.connect(db_path)
cursor = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='user_settings'"
)
assert cursor.fetchone() is not None
conn.close()
def test_initialize_enables_wal_mode(self, user_db, db_path):
conn = sqlite3.connect(db_path)
cursor = conn.execute("PRAGMA journal_mode")
mode = cursor.fetchone()[0]
assert mode == "wal"
conn.close()
def test_initialize_is_idempotent(self, db_path):
from shelfmark.core.user_db import UserDB
db = UserDB(db_path)
db.initialize()
db.initialize() # Should not raise
assert os.path.exists(db_path)
class TestUserCRUD:
"""Tests for user create, read, update, delete operations."""
def test_create_user(self, user_db):
user = user_db.create_user(
username="john",
email="john@example.com",
display_name="John Doe",
)
assert user["id"] is not None
assert user["username"] == "john"
assert user["email"] == "john@example.com"
assert user["display_name"] == "John Doe"
assert user["role"] == "user"
def test_create_user_with_password(self, user_db):
user = user_db.create_user(
username="admin",
password_hash="hashed_pw",
role="admin",
)
assert user["role"] == "admin"
assert user["password_hash"] == "hashed_pw"
def test_create_user_with_oidc_subject(self, user_db):
user = user_db.create_user(
username="oidcuser",
oidc_subject="sub-12345",
email="oidc@example.com",
)
assert user["oidc_subject"] == "sub-12345"
def test_create_duplicate_username_fails(self, user_db):
user_db.create_user(username="john")
with pytest.raises(ValueError, match="already exists"):
user_db.create_user(username="john")
def test_create_duplicate_oidc_subject_fails(self, user_db):
user_db.create_user(username="user1", oidc_subject="sub-123")
with pytest.raises(ValueError, match="already exists"):
user_db.create_user(username="user2", oidc_subject="sub-123")
def test_get_user_by_id(self, user_db):
created = user_db.create_user(username="john")
fetched = user_db.get_user(user_id=created["id"])
assert fetched["username"] == "john"
def test_get_user_by_username(self, user_db):
user_db.create_user(username="john", email="john@example.com")
fetched = user_db.get_user(username="john")
assert fetched["email"] == "john@example.com"
def test_get_user_by_oidc_subject(self, user_db):
user_db.create_user(username="john", oidc_subject="sub-123")
fetched = user_db.get_user(oidc_subject="sub-123")
assert fetched["username"] == "john"
def test_get_nonexistent_user_returns_none(self, user_db):
assert user_db.get_user(username="nobody") is None
def test_update_user(self, user_db):
user = user_db.create_user(username="john", role="user")
user_db.update_user(user["id"], role="admin", email="new@example.com")
updated = user_db.get_user(user_id=user["id"])
assert updated["role"] == "admin"
assert updated["email"] == "new@example.com"
def test_update_nonexistent_user_raises(self, user_db):
with pytest.raises(ValueError, match="not found"):
user_db.update_user(9999, role="admin")
def test_delete_user(self, user_db):
user = user_db.create_user(username="john")
user_db.delete_user(user["id"])
assert user_db.get_user(user_id=user["id"]) is None
def test_delete_user_cascades_settings(self, user_db):
user = user_db.create_user(username="john")
user_db.set_user_settings(user["id"], {"booklore_library_id": 1})
user_db.delete_user(user["id"])
assert user_db.get_user_settings(user["id"]) == {}
def test_list_users(self, user_db):
user_db.create_user(username="alice")
user_db.create_user(username="bob")
user_db.create_user(username="charlie")
users = user_db.list_users()
assert len(users) == 3
usernames = [u["username"] for u in users]
assert "alice" in usernames
assert "bob" in usernames
assert "charlie" in usernames
class TestUserSettings:
"""Tests for per-user settings."""
def test_set_and_get_user_settings(self, user_db):
user = user_db.create_user(username="john")
settings = {"booklore_library_id": 5, "booklore_path_id": 2}
user_db.set_user_settings(user["id"], settings)
fetched = user_db.get_user_settings(user["id"])
assert fetched["booklore_library_id"] == 5
assert fetched["booklore_path_id"] == 2
def test_get_settings_for_user_without_settings(self, user_db):
user = user_db.create_user(username="john")
assert user_db.get_user_settings(user["id"]) == {}
def test_update_user_settings_merges(self, user_db):
user = user_db.create_user(username="john")
user_db.set_user_settings(user["id"], {"key1": "val1"})
user_db.set_user_settings(user["id"], {"key2": "val2"})
settings = user_db.get_user_settings(user["id"])
assert settings["key1"] == "val1"
assert settings["key2"] == "val2"
def test_update_user_settings_overwrites_existing_key(self, user_db):
user = user_db.create_user(username="john")
user_db.set_user_settings(user["id"], {"key1": "old"})
user_db.set_user_settings(user["id"], {"key1": "new"})
settings = user_db.get_user_settings(user["id"])
assert settings["key1"] == "new"
+84
View File
@@ -0,0 +1,84 @@
"""Tests for {User} template variable in folder destination paths."""
from shelfmark.core.naming import KNOWN_TOKENS, parse_naming_template
class TestUserInKnownTokens:
"""User should be a recognized template token."""
def test_user_in_known_tokens(self):
assert "user" in KNOWN_TOKENS
def test_user_token_parsed(self):
result = parse_naming_template("{User}", {"User": "alice"})
assert result == "alice"
def test_user_token_case_insensitive(self):
result = parse_naming_template("{user}", {"User": "alice"})
assert result == "alice"
class TestUserTemplateSubstitution:
"""User variable should work in organize templates with path separators."""
def test_user_in_organize_template(self):
metadata = {"Author": "Author1", "Title": "Book1", "Year": "2024", "User": "alice"}
result = parse_naming_template("{User}/{Author}/{Title} ({Year})", metadata)
assert result == "alice/Author1/Book1 (2024)"
def test_user_empty_when_not_set(self):
metadata = {"Author": "Author1", "Title": "Book1", "User": None}
result = parse_naming_template("{User}/{Author}/{Title}", metadata)
# Empty user should be cleaned up, no leading slash
assert result == "Author1/Book1"
def test_user_with_prefix_suffix(self):
metadata = {"Author": "Author1", "Title": "Book1", "User": "bob"}
result = parse_naming_template("{User}/books/{Author}/{Title}", metadata)
assert result == "bob/books/Author1/Book1"
def test_user_sanitized(self):
metadata = {"User": "user:with*special", "Title": "Book1"}
result = parse_naming_template("{User}/{Title}", metadata)
# Special chars should be replaced with underscores
assert ":" not in result
assert "*" not in result
def test_user_missing_from_metadata(self):
metadata = {"Author": "Author1", "Title": "Book1"}
result = parse_naming_template("{User}/{Author}/{Title}", metadata)
assert result == "Author1/Book1"
class TestBuildMetadataWithUser:
"""build_metadata_dict should include User when task has user_id."""
def test_build_metadata_includes_user(self):
from shelfmark.core.models import DownloadTask
from shelfmark.download.postprocess.transfer import build_metadata_dict
task = DownloadTask(
task_id="test-1",
source="direct_download",
title="Book1",
author="Author1",
user_id=1,
username="alice",
)
metadata = build_metadata_dict(task)
assert metadata["User"] == "alice"
def test_build_metadata_user_none_when_no_user_id(self):
from shelfmark.core.models import DownloadTask
from shelfmark.download.postprocess.transfer import build_metadata_dict
task = DownloadTask(
task_id="test-2",
source="direct_download",
title="Book1",
author="Author1",
user_id=None,
username=None,
)
metadata = build_metadata_dict(task)
assert metadata.get("User") is None
+1 -1
View File
@@ -33,7 +33,7 @@ class TestAuthenticationFlow:
data = resp.json()
assert "auth_mode" in data
# Should be one of the valid auth modes
assert data["auth_mode"] in ["none", "builtin", "cwa", "proxy"]
assert data["auth_mode"] in ["none", "builtin", "cwa", "proxy", "oidc"]
def test_auth_check_includes_admin_status(self, api_client: APIClient):
"""Test that auth check includes admin status."""