Requests: Various fixes and improvements (#617)

- Refactored activity backend for full user-level management, using the
db file
- Revamped the activity sidebar UX and categorisation
- Added download history and user filtering
- Added User Preferences modal, giving limited configuration for
non-admins - replaces the "restrict settings" config option.
- Many many bug fixes
- Many many new tests
This commit is contained in:
Alex
2026-02-14 18:24:28 +00:00
committed by GitHub
parent 68608b6162
commit b7bee132a1
42 changed files with 5118 additions and 467 deletions

View File

@@ -26,22 +26,22 @@ _REQUEST_DEFAULT_MODE_OPTIONS = [
{
"value": "download",
"label": "Download",
"description": "Allow direct downloads.",
"description": "Everything can be downloaded directly.",
},
{
"value": "request_release",
"label": "Request Release",
"description": "Block direct download; allow requesting a specific release.",
"description": "Users must request a specific release.",
},
{
"value": "request_book",
"label": "Request Book",
"description": "Block direct download; allow book-level requests only.",
"description": "Users request a book, admin picks the release.",
},
{
"value": "blocked",
"label": "Blocked",
"description": "Block both downloading and requesting.",
"description": "No downloads or requests allowed.",
},
]
@@ -179,33 +179,18 @@ def users_settings():
key="users_management",
component="users_management",
),
HeadingField(
key="users_access_heading",
title="Options",
),
CheckboxField(
key="RESTRICT_SETTINGS_TO_ADMIN",
label="Restrict Settings and Onboarding to Admins",
description=(
"When enabled, only admin users can access Settings and Onboarding. "
"When disabled, any authenticated user can access them. "
"Security and Users are always admin-only."
),
default=True,
env_supported=False,
),
HeadingField(
key="requests_heading",
title="Request Policy",
title="Requests",
description=(
"Configure when users can download directly and when they must create requests."
"Choose what users can download directly and what needs approval first."
),
),
CheckboxField(
key="REQUESTS_ENABLED",
label="Enable Request Workflow",
label="Enable Requests",
description=(
"When disabled, request actions are hidden and only direct downloads are used."
"Turn this off to let everyone download directly without needing approval."
),
default=False,
user_overridable=True,
@@ -213,9 +198,9 @@ def users_settings():
CustomComponentField(
key="request_policy_editor",
component="request_policy_grid",
label="Request Policy Rules",
label="Request Rules",
description=(
"Source/content-type rules can only restrict the content-type default ceiling."
"Fine-tune access per source. Source rules can only be the same or more restrictive than the default above."
),
show_when={"field": "REQUESTS_ENABLED", "value": True},
wrap_in_field_wrapper=True,
@@ -224,7 +209,7 @@ def users_settings():
key="REQUEST_POLICY_DEFAULT_EBOOK",
label="Default Ebook Mode",
description=(
"Global ceiling for ebook actions. Source rules can only match or restrict this mode."
"Sets the baseline for all ebook sources."
),
options=_REQUEST_DEFAULT_MODE_OPTIONS,
default="download",
@@ -234,7 +219,7 @@ def users_settings():
key="REQUEST_POLICY_DEFAULT_AUDIOBOOK",
label="Default Audiobook Mode",
description=(
"Global ceiling for audiobook actions. Source rules can only match or restrict this mode."
"Sets the baseline for all audiobook sources."
),
options=_REQUEST_DEFAULT_MODE_OPTIONS,
default="download",
@@ -242,9 +227,9 @@ def users_settings():
),
TableField(
key="REQUEST_POLICY_RULES",
label="Request Policy Rules",
label="Request Rules",
description=(
"Source/content-type rules can only restrict the content-type default ceiling."
"Fine-tune access per source. Source rules can only be the same or more restrictive than the default above."
),
columns=_get_request_policy_rule_columns,
default=[],
@@ -257,8 +242,8 @@ def users_settings():
),
NumberField(
key="MAX_PENDING_REQUESTS_PER_USER",
label="Max Pending Requests Per User",
description="Maximum number of pending requests a user can have at once.",
label="Max pending requests per user",
description="How many open requests a user can have at a time.",
default=20,
min_value=1,
max_value=1000,
@@ -267,8 +252,8 @@ def users_settings():
),
CheckboxField(
key="REQUESTS_ALLOW_NOTES",
label="Allow Request Notes",
description="Allow users to include notes when creating requests.",
label="Allow notes on requests",
description="Let users add a note when they submit a request.",
default=True,
user_overridable=True,
show_when={"field": "REQUESTS_ENABLED", "value": True},

View File

@@ -0,0 +1,486 @@
"""Activity API routes (snapshot, dismiss, history)."""
from __future__ import annotations
from typing import Any, Callable
from flask import Flask, jsonify, request, session
from shelfmark.core.activity_service import ActivityService
from shelfmark.core.logger import setup_logger
from shelfmark.core.user_db import UserDB
logger = setup_logger(__name__)
def _require_authenticated(resolve_auth_mode: Callable[[], str]):
auth_mode = resolve_auth_mode()
if auth_mode == "none":
return None
if "user_id" not in session:
return jsonify({"error": "Unauthorized"}), 401
return None
def _resolve_db_user_id(require_in_auth_mode: bool = True):
raw_db_user_id = session.get("db_user_id")
if raw_db_user_id is None:
if not require_in_auth_mode:
return None, None
return None, (
jsonify(
{
"error": "User identity unavailable for activity workflow",
"code": "user_identity_unavailable",
}
),
403,
)
try:
return int(raw_db_user_id), None
except (TypeError, ValueError):
return None, (
jsonify(
{
"error": "User identity unavailable for activity workflow",
"code": "user_identity_unavailable",
}
),
403,
)
def _emit_activity_event(ws_manager: Any | None, *, room: str, payload: dict[str, Any]) -> None:
if ws_manager is None:
return
try:
socketio = getattr(ws_manager, "socketio", None)
is_enabled = getattr(ws_manager, "is_enabled", None)
if socketio is None or not callable(is_enabled) or not is_enabled():
return
socketio.emit("activity_update", payload, to=room)
except Exception as exc:
logger.warning("Failed to emit activity_update event: %s", exc)
def _list_visible_requests(user_db: UserDB, *, is_admin: bool, db_user_id: int | None) -> list[dict[str, Any]]:
if is_admin:
request_rows = user_db.list_requests()
user_cache: dict[int, str] = {}
for row in request_rows:
requester_id = row["user_id"]
if requester_id not in user_cache:
requester = user_db.get_user(user_id=requester_id)
user_cache[requester_id] = requester.get("username", "") if requester else ""
row["username"] = user_cache[requester_id]
return request_rows
if db_user_id is None:
return []
return user_db.list_requests(user_id=db_user_id)
def _parse_download_item_key(item_key: str) -> str | None:
if not isinstance(item_key, str) or not item_key.startswith("download:"):
return None
task_id = item_key.split(":", 1)[1].strip()
return task_id or None
def _parse_request_item_key(item_key: str) -> int | None:
if not isinstance(item_key, str) or not item_key.startswith("request:"):
return None
raw_id = item_key.split(":", 1)[1].strip()
try:
parsed = int(raw_id)
except (TypeError, ValueError):
return None
return parsed if parsed > 0 else None
def _task_id_from_download_item_key(item_key: str) -> str | None:
task_id = _parse_download_item_key(item_key)
if task_id is None:
return None
return task_id
def _merge_terminal_snapshot_backfill(
*,
status: dict[str, dict[str, Any]],
terminal_rows: list[dict[str, Any]],
) -> None:
existing_task_ids: set[str] = set()
for bucket_key in ("queued", "resolving", "locating", "downloading", "complete", "error", "cancelled"):
bucket = status.get(bucket_key)
if not isinstance(bucket, dict):
continue
existing_task_ids.update(str(task_id) for task_id in bucket.keys())
for row in terminal_rows:
item_key = row.get("item_key")
if not isinstance(item_key, str):
continue
task_id = _task_id_from_download_item_key(item_key)
if not task_id or task_id in existing_task_ids:
continue
final_status = row.get("final_status")
if final_status not in {"complete", "error", "cancelled"}:
continue
snapshot = row.get("snapshot")
if not isinstance(snapshot, dict):
continue
raw_download = snapshot.get("download")
if not isinstance(raw_download, dict):
continue
download_payload = dict(raw_download)
if not isinstance(download_payload.get("id"), str):
download_payload["id"] = task_id
if final_status not in status or not isinstance(status.get(final_status), dict):
status[final_status] = {}
status[final_status][task_id] = download_payload
existing_task_ids.add(task_id)
def _collect_active_download_item_keys(status: dict[str, dict[str, Any]]) -> set[str]:
active_keys: set[str] = set()
for bucket_key in ("queued", "resolving", "locating", "downloading"):
bucket = status.get(bucket_key)
if not isinstance(bucket, dict):
continue
for task_id in bucket.keys():
normalized_task_id = str(task_id).strip()
if not normalized_task_id:
continue
active_keys.add(f"download:{normalized_task_id}")
return active_keys
def _extract_request_source_id(row: dict[str, Any]) -> str | None:
release_data = row.get("release_data")
if not isinstance(release_data, dict):
return None
source_id = release_data.get("source_id")
if not isinstance(source_id, str):
return None
normalized = source_id.strip()
return normalized or None
def _request_terminal_status(row: dict[str, Any]) -> str | None:
request_status = row.get("status")
if request_status == "pending":
return None
if request_status == "rejected":
return "rejected"
if request_status == "cancelled":
return "cancelled"
if request_status != "fulfilled":
return None
delivery_state = str(row.get("delivery_state") or "").strip().lower()
if delivery_state in {"error", "cancelled"}:
return delivery_state
return "complete"
def _minimal_request_snapshot(request_row: dict[str, Any], request_id: int) -> dict[str, Any]:
book_data = request_row.get("book_data")
release_data = request_row.get("release_data")
if not isinstance(book_data, dict):
book_data = {}
if not isinstance(release_data, dict):
release_data = {}
minimal_request = {
"id": request_id,
"user_id": request_row.get("user_id"),
"status": request_row.get("status"),
"request_level": request_row.get("request_level"),
"delivery_state": request_row.get("delivery_state"),
"book_data": book_data,
"release_data": release_data,
"note": request_row.get("note"),
"admin_note": request_row.get("admin_note"),
"created_at": request_row.get("created_at"),
"updated_at": request_row.get("updated_at"),
}
username = request_row.get("username")
if isinstance(username, str):
minimal_request["username"] = username
return {"kind": "request", "request": minimal_request}
def _get_existing_activity_log_id_for_item(
*,
activity_service: ActivityService,
user_db: UserDB,
item_type: str,
item_key: str,
) -> int | None:
if item_type not in {"request", "download"}:
return None
if not isinstance(item_key, str) or not item_key.strip():
return None
existing_log_id = activity_service.get_latest_activity_log_id(
item_type=item_type,
item_key=item_key,
)
if existing_log_id is not None or item_type != "request":
return existing_log_id
request_id = _parse_request_item_key(item_key)
if request_id is None:
return None
row = user_db.get_request(request_id)
if row is None:
return None
final_status = _request_terminal_status(row)
if final_status is None:
return None
source_id = _extract_request_source_id(row)
payload = activity_service.record_terminal_snapshot(
user_id=row.get("user_id"),
item_type="request",
item_key=item_key,
origin="request",
final_status=final_status,
snapshot=_minimal_request_snapshot(row, request_id),
request_id=request_id,
source_id=source_id,
)
return int(payload["id"])
def register_activity_routes(
app: Flask,
user_db: UserDB,
*,
activity_service: ActivityService,
resolve_auth_mode: Callable[[], str],
resolve_status_scope: Callable[[], tuple[bool, int | None, bool]],
queue_status: Callable[..., dict[str, dict[str, Any]]],
sync_request_delivery_states: Callable[..., list[dict[str, Any]]],
emit_request_updates: Callable[[list[dict[str, Any]]], None],
ws_manager: Any | None = None,
) -> None:
"""Register activity routes."""
@app.route("/api/activity/snapshot", methods=["GET"])
def api_activity_snapshot():
auth_gate = _require_authenticated(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
is_admin, db_user_id, can_access_status = resolve_status_scope()
if not can_access_status:
return (
jsonify(
{
"error": "User identity unavailable for activity workflow",
"code": "user_identity_unavailable",
}
),
403,
)
viewer_db_user_id, _ = _resolve_db_user_id(require_in_auth_mode=False)
scoped_user_id = None if is_admin else db_user_id
status = queue_status(user_id=scoped_user_id)
updated_requests = sync_request_delivery_states(
user_db,
queue_status=status,
user_id=scoped_user_id,
)
emit_request_updates(updated_requests)
request_rows = _list_visible_requests(user_db, is_admin=is_admin, db_user_id=db_user_id)
if not is_admin and db_user_id is not None:
try:
terminal_rows = activity_service.get_undismissed_terminal_downloads(db_user_id, limit=200)
_merge_terminal_snapshot_backfill(status=status, terminal_rows=terminal_rows)
except Exception as exc:
logger.warning("Failed to merge terminal snapshot backfill rows: %s", exc)
if viewer_db_user_id is not None:
active_download_keys = _collect_active_download_item_keys(status)
if active_download_keys:
try:
activity_service.clear_dismissals_for_item_keys(
user_id=viewer_db_user_id,
item_type="download",
item_keys=active_download_keys,
)
except Exception as exc:
logger.warning("Failed to clear stale download dismissals for active tasks: %s", exc)
dismissed: list[dict[str, str]] = []
# Admins can view unscoped queue status, but dismissals remain per-viewer.
if viewer_db_user_id is not None:
dismissed = activity_service.get_dismissal_set(viewer_db_user_id)
return jsonify(
{
"status": status,
"requests": request_rows,
"dismissed": dismissed,
}
)
@app.route("/api/activity/dismiss", methods=["POST"])
def api_activity_dismiss():
auth_gate = _require_authenticated(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
db_user_id, db_gate = _resolve_db_user_id()
if db_gate is not None or db_user_id is None:
return db_gate
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"error": "Invalid payload"}), 400
activity_log_id = data.get("activity_log_id")
if activity_log_id is None:
try:
activity_log_id = _get_existing_activity_log_id_for_item(
activity_service=activity_service,
user_db=user_db,
item_type=data.get("item_type"),
item_key=data.get("item_key"),
)
except Exception as exc:
logger.warning("Failed to resolve activity snapshot id for dismiss payload: %s", exc)
activity_log_id = None
try:
dismissal = activity_service.dismiss_item(
user_id=db_user_id,
item_type=data.get("item_type"),
item_key=data.get("item_key"),
activity_log_id=activity_log_id,
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
_emit_activity_event(
ws_manager,
room=f"user_{db_user_id}",
payload={
"kind": "dismiss",
"user_id": db_user_id,
"item_type": dismissal["item_type"],
"item_key": dismissal["item_key"],
},
)
return jsonify({"status": "dismissed", "item": dismissal})
@app.route("/api/activity/dismiss-many", methods=["POST"])
def api_activity_dismiss_many():
auth_gate = _require_authenticated(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
db_user_id, db_gate = _resolve_db_user_id()
if db_gate is not None or db_user_id is None:
return db_gate
data = request.get_json(silent=True)
if not isinstance(data, dict):
return jsonify({"error": "Invalid payload"}), 400
items = data.get("items")
if not isinstance(items, list):
return jsonify({"error": "items must be an array"}), 400
normalized_items: list[dict[str, Any]] = []
for item in items:
if not isinstance(item, dict):
return jsonify({"error": "items must contain objects"}), 400
activity_log_id = item.get("activity_log_id")
if activity_log_id is None:
try:
activity_log_id = _get_existing_activity_log_id_for_item(
activity_service=activity_service,
user_db=user_db,
item_type=item.get("item_type"),
item_key=item.get("item_key"),
)
except Exception as exc:
logger.warning("Failed to resolve activity snapshot id for dismiss-many item: %s", exc)
activity_log_id = None
normalized_payload = {
"item_type": item.get("item_type"),
"item_key": item.get("item_key"),
}
if activity_log_id is not None:
normalized_payload["activity_log_id"] = activity_log_id
normalized_items.append(normalized_payload)
try:
dismissed_count = activity_service.dismiss_many(user_id=db_user_id, items=normalized_items)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
_emit_activity_event(
ws_manager,
room=f"user_{db_user_id}",
payload={
"kind": "dismiss_many",
"user_id": db_user_id,
"count": dismissed_count,
},
)
return jsonify({"status": "dismissed", "count": dismissed_count})
@app.route("/api/activity/history", methods=["GET"])
def api_activity_history():
auth_gate = _require_authenticated(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
db_user_id, db_gate = _resolve_db_user_id()
if db_gate is not None or db_user_id is None:
return db_gate
limit = request.args.get("limit", type=int, default=50) or 50
offset = request.args.get("offset", type=int, default=0) or 0
try:
history = activity_service.get_history(db_user_id, limit=limit, offset=offset)
except ValueError as exc:
return jsonify({"error": str(exc)}), 400
return jsonify(history)
@app.route("/api/activity/history", methods=["DELETE"])
def api_activity_history_clear():
auth_gate = _require_authenticated(resolve_auth_mode)
if auth_gate is not None:
return auth_gate
db_user_id, db_gate = _resolve_db_user_id()
if db_gate is not None or db_user_id is None:
return db_gate
deleted_count = activity_service.clear_history(db_user_id)
_emit_activity_event(
ws_manager,
room=f"user_{db_user_id}",
payload={
"kind": "history_cleared",
"user_id": db_user_id,
"count": deleted_count,
},
)
return jsonify({"status": "cleared", "deleted_count": deleted_count})

View File

@@ -0,0 +1,618 @@
"""Persistence helpers for Activity dismissals and terminal snapshots."""
from __future__ import annotations
from datetime import datetime, timezone
import json
import sqlite3
from typing import Any, Iterable
VALID_ITEM_TYPES = frozenset({"download", "request"})
VALID_ORIGINS = frozenset({"direct", "request", "requested"})
VALID_FINAL_STATUSES = frozenset({"complete", "error", "cancelled", "rejected"})
def _now_timestamp() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def _normalize_item_type(item_type: Any) -> str:
if not isinstance(item_type, str):
raise ValueError("item_type must be a string")
normalized = item_type.strip().lower()
if normalized not in VALID_ITEM_TYPES:
raise ValueError("item_type must be one of: download, request")
return normalized
def _normalize_item_key(item_key: Any) -> str:
if not isinstance(item_key, str):
raise ValueError("item_key must be a string")
normalized = item_key.strip()
if not normalized:
raise ValueError("item_key must not be empty")
return normalized
def _normalize_origin(origin: Any) -> str:
if not isinstance(origin, str):
raise ValueError("origin must be a string")
normalized = origin.strip().lower()
if normalized not in VALID_ORIGINS:
raise ValueError("origin must be one of: direct, request, requested")
return normalized
def _normalize_final_status(final_status: Any) -> str:
if not isinstance(final_status, str):
raise ValueError("final_status must be a string")
normalized = final_status.strip().lower()
if normalized not in VALID_FINAL_STATUSES:
raise ValueError("final_status must be one of: complete, error, cancelled, rejected")
return normalized
def build_item_key(item_type: str, raw_id: Any) -> str:
"""Build a stable item key used by dismiss/history APIs."""
normalized_type = _normalize_item_type(item_type)
if normalized_type == "request":
try:
request_id = int(raw_id)
except (TypeError, ValueError) as exc:
raise ValueError("request item IDs must be integers") from exc
if request_id < 1:
raise ValueError("request item IDs must be positive integers")
return f"request:{request_id}"
if not isinstance(raw_id, str):
raise ValueError("download item IDs must be strings")
task_id = raw_id.strip()
if not task_id:
raise ValueError("download item IDs must not be empty")
return f"download:{task_id}"
def build_request_item_key(request_id: int) -> str:
"""Build a request item key."""
return build_item_key("request", request_id)
def build_download_item_key(task_id: str) -> str:
"""Build a download item key."""
return build_item_key("download", task_id)
def _parse_request_id_from_item_key(item_key: Any) -> int | None:
if not isinstance(item_key, str) or not item_key.startswith("request:"):
return None
raw_value = item_key.split(":", 1)[1].strip()
try:
parsed = int(raw_value)
except (TypeError, ValueError):
return None
return parsed if parsed > 0 else None
def _request_final_status(request_status: Any, delivery_state: Any) -> str | None:
status = str(request_status or "").strip().lower()
if status == "pending":
return None
if status == "rejected":
return "rejected"
if status == "cancelled":
return "cancelled"
if status != "fulfilled":
return None
delivery = str(delivery_state or "").strip().lower()
if delivery in {"error", "cancelled"}:
return delivery
return "complete"
class ActivityService:
"""Service for per-user activity dismissals and terminal history snapshots."""
def __init__(self, db_path: str):
self._db_path = db_path
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self._db_path)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON")
return conn
@staticmethod
def _coerce_positive_int(value: Any, field: str) -> int:
try:
parsed = int(value)
except (TypeError, ValueError) as exc:
raise ValueError(f"{field} must be an integer") from exc
if parsed < 1:
raise ValueError(f"{field} must be a positive integer")
return parsed
@staticmethod
def _row_to_dict(row: sqlite3.Row | None) -> dict[str, Any] | None:
return dict(row) if row is not None else None
@staticmethod
def _parse_json_column(value: Any) -> Any:
if not isinstance(value, str):
return None
try:
return json.loads(value)
except (ValueError, TypeError):
return None
def _build_legacy_request_snapshot(
self,
conn: sqlite3.Connection,
request_id: int,
) -> tuple[dict[str, Any] | None, str | None]:
request_row = conn.execute(
"""
SELECT
id,
user_id,
status,
delivery_state,
request_level,
book_data,
release_data,
note,
admin_note,
created_at,
reviewed_at
FROM download_requests
WHERE id = ?
""",
(request_id,),
).fetchone()
if request_row is None:
return None, None
row_dict = dict(request_row)
book_data = self._parse_json_column(row_dict.get("book_data"))
release_data = self._parse_json_column(row_dict.get("release_data"))
if not isinstance(book_data, dict):
book_data = {}
if not isinstance(release_data, dict):
release_data = {}
snapshot = {
"kind": "request",
"request": {
"id": int(row_dict["id"]),
"user_id": row_dict.get("user_id"),
"status": row_dict.get("status"),
"delivery_state": row_dict.get("delivery_state"),
"request_level": row_dict.get("request_level"),
"book_data": book_data,
"release_data": release_data,
"note": row_dict.get("note"),
"admin_note": row_dict.get("admin_note"),
"created_at": row_dict.get("created_at"),
"updated_at": row_dict.get("reviewed_at") or row_dict.get("created_at"),
},
}
final_status = _request_final_status(row_dict.get("status"), row_dict.get("delivery_state"))
return snapshot, final_status
def record_terminal_snapshot(
self,
*,
user_id: int | None,
item_type: str,
item_key: str,
origin: str,
final_status: str,
snapshot: dict[str, Any],
request_id: int | None = None,
source_id: str | None = None,
terminal_at: str | None = None,
) -> dict[str, Any]:
"""Record a durable terminal-state snapshot for an activity item."""
normalized_item_type = _normalize_item_type(item_type)
normalized_item_key = _normalize_item_key(item_key)
normalized_origin = _normalize_origin(origin)
normalized_final_status = _normalize_final_status(final_status)
if not isinstance(snapshot, dict):
raise ValueError("snapshot must be an object")
if user_id is not None:
user_id = self._coerce_positive_int(user_id, "user_id")
if request_id is not None:
request_id = self._coerce_positive_int(request_id, "request_id")
if source_id is not None and not isinstance(source_id, str):
raise ValueError("source_id must be a string when provided")
if source_id is not None:
source_id = source_id.strip() or None
effective_terminal_at = terminal_at if isinstance(terminal_at, str) and terminal_at.strip() else _now_timestamp()
serialized_snapshot = json.dumps(snapshot, separators=(",", ":"), ensure_ascii=False)
conn = self._connect()
try:
cursor = conn.execute(
"""
INSERT INTO activity_log (
user_id,
item_type,
item_key,
request_id,
source_id,
origin,
final_status,
snapshot_json,
terminal_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
user_id,
normalized_item_type,
normalized_item_key,
request_id,
source_id,
normalized_origin,
normalized_final_status,
serialized_snapshot,
effective_terminal_at,
),
)
snapshot_id = int(cursor.lastrowid)
conn.commit()
row = conn.execute(
"SELECT * FROM activity_log WHERE id = ?",
(snapshot_id,),
).fetchone()
payload = self._row_to_dict(row)
if payload is None:
raise ValueError("Failed to read back recorded activity snapshot")
return payload
finally:
conn.close()
def get_latest_activity_log_id(self, *, item_type: str, item_key: str) -> int | None:
"""Get the newest snapshot ID for an item key."""
normalized_item_type = _normalize_item_type(item_type)
normalized_item_key = _normalize_item_key(item_key)
conn = self._connect()
try:
row = conn.execute(
"""
SELECT id
FROM activity_log
WHERE item_type = ? AND item_key = ?
ORDER BY terminal_at DESC, id DESC
LIMIT 1
""",
(normalized_item_type, normalized_item_key),
).fetchone()
if row is None:
return None
return int(row["id"])
finally:
conn.close()
def dismiss_item(
self,
*,
user_id: int,
item_type: str,
item_key: str,
activity_log_id: int | None = None,
) -> dict[str, Any]:
"""Dismiss an item for a specific user (upsert)."""
normalized_user_id = self._coerce_positive_int(user_id, "user_id")
normalized_item_type = _normalize_item_type(item_type)
normalized_item_key = _normalize_item_key(item_key)
normalized_log_id = (
self._coerce_positive_int(activity_log_id, "activity_log_id")
if activity_log_id is not None
else self.get_latest_activity_log_id(
item_type=normalized_item_type,
item_key=normalized_item_key,
)
)
conn = self._connect()
try:
conn.execute(
"""
INSERT INTO activity_dismissals (
user_id,
item_type,
item_key,
activity_log_id,
dismissed_at
)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id, item_type, item_key)
DO UPDATE SET
activity_log_id = excluded.activity_log_id,
dismissed_at = excluded.dismissed_at
""",
(
normalized_user_id,
normalized_item_type,
normalized_item_key,
normalized_log_id,
_now_timestamp(),
),
)
conn.commit()
row = conn.execute(
"""
SELECT *
FROM activity_dismissals
WHERE user_id = ? AND item_type = ? AND item_key = ?
""",
(normalized_user_id, normalized_item_type, normalized_item_key),
).fetchone()
payload = self._row_to_dict(row)
if payload is None:
raise ValueError("Failed to read back dismissal row")
return payload
finally:
conn.close()
def dismiss_many(self, *, user_id: int, items: Iterable[dict[str, Any]]) -> int:
"""Dismiss many items for one user."""
normalized_user_id = self._coerce_positive_int(user_id, "user_id")
normalized_items: list[tuple[str, str, int | None]] = []
for item in items:
if not isinstance(item, dict):
raise ValueError("items must contain objects")
normalized_item_type = _normalize_item_type(item.get("item_type"))
normalized_item_key = _normalize_item_key(item.get("item_key"))
raw_log_id = item.get("activity_log_id")
normalized_log_id = (
self._coerce_positive_int(raw_log_id, "activity_log_id")
if raw_log_id is not None
else self.get_latest_activity_log_id(
item_type=normalized_item_type,
item_key=normalized_item_key,
)
)
normalized_items.append((normalized_item_type, normalized_item_key, normalized_log_id))
if not normalized_items:
return 0
conn = self._connect()
try:
timestamp = _now_timestamp()
for item_type, item_key, activity_log_id in normalized_items:
conn.execute(
"""
INSERT INTO activity_dismissals (
user_id,
item_type,
item_key,
activity_log_id,
dismissed_at
)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(user_id, item_type, item_key)
DO UPDATE SET
activity_log_id = excluded.activity_log_id,
dismissed_at = excluded.dismissed_at
""",
(
normalized_user_id,
item_type,
item_key,
activity_log_id,
timestamp,
),
)
conn.commit()
return len(normalized_items)
finally:
conn.close()
def get_dismissal_set(self, user_id: int) -> list[dict[str, str]]:
"""Return dismissed item keys for one user."""
normalized_user_id = self._coerce_positive_int(user_id, "user_id")
conn = self._connect()
try:
rows = conn.execute(
"""
SELECT item_type, item_key
FROM activity_dismissals
WHERE user_id = ?
ORDER BY dismissed_at DESC, id DESC
""",
(normalized_user_id,),
).fetchall()
return [
{
"item_type": str(row["item_type"]),
"item_key": str(row["item_key"]),
}
for row in rows
]
finally:
conn.close()
def clear_dismissals_for_item_keys(
self,
*,
user_id: int,
item_type: str,
item_keys: Iterable[str],
) -> int:
"""Clear dismissals for one user + item type + item keys."""
normalized_user_id = self._coerce_positive_int(user_id, "user_id")
normalized_item_type = _normalize_item_type(item_type)
normalized_keys = {
_normalize_item_key(item_key)
for item_key in item_keys
if isinstance(item_key, str) and item_key.strip()
}
if not normalized_keys:
return 0
conn = self._connect()
try:
cursor = conn.executemany(
"""
DELETE FROM activity_dismissals
WHERE user_id = ? AND item_type = ? AND item_key = ?
""",
(
(normalized_user_id, normalized_item_type, item_key)
for item_key in normalized_keys
),
)
conn.commit()
return int(cursor.rowcount or 0)
finally:
conn.close()
def get_history(self, user_id: int, *, limit: int = 50, offset: int = 0) -> list[dict[str, Any]]:
"""Return paged dismissal history for one user."""
normalized_user_id = self._coerce_positive_int(user_id, "user_id")
normalized_limit = max(1, min(int(limit), 200))
normalized_offset = max(0, int(offset))
conn = self._connect()
try:
rows = conn.execute(
"""
SELECT
d.id,
d.user_id,
d.item_type,
d.item_key,
d.activity_log_id,
d.dismissed_at,
l.snapshot_json,
l.origin,
l.final_status,
l.terminal_at,
l.request_id,
l.source_id
FROM activity_dismissals d
LEFT JOIN activity_log l ON l.id = d.activity_log_id
WHERE d.user_id = ?
ORDER BY d.dismissed_at DESC, d.id DESC
LIMIT ? OFFSET ?
""",
(normalized_user_id, normalized_limit, normalized_offset),
).fetchall()
payload: list[dict[str, Any]] = []
for row in rows:
row_dict = dict(row)
raw_snapshot_json = row_dict.pop("snapshot_json", None)
snapshot_payload = None
if isinstance(raw_snapshot_json, str):
try:
snapshot_payload = json.loads(raw_snapshot_json)
except (ValueError, TypeError):
snapshot_payload = None
if snapshot_payload is None and row_dict.get("item_type") == "request":
request_id = row_dict.get("request_id")
if request_id is None:
request_id = _parse_request_id_from_item_key(row_dict.get("item_key"))
try:
normalized_request_id = int(request_id) if request_id is not None else None
except (TypeError, ValueError):
normalized_request_id = None
if normalized_request_id and normalized_request_id > 0:
fallback_snapshot, fallback_final_status = self._build_legacy_request_snapshot(
conn,
normalized_request_id,
)
if fallback_snapshot is not None:
snapshot_payload = fallback_snapshot
if not row_dict.get("origin"):
row_dict["origin"] = "request"
if not row_dict.get("final_status") and fallback_final_status is not None:
row_dict["final_status"] = fallback_final_status
row_dict["snapshot"] = snapshot_payload
payload.append(row_dict)
return payload
finally:
conn.close()
def get_undismissed_terminal_downloads(self, user_id: int, *, limit: int = 200) -> list[dict[str, Any]]:
"""Return latest undismissed terminal download snapshots for one user."""
normalized_user_id = self._coerce_positive_int(user_id, "user_id")
normalized_limit = max(1, min(int(limit), 500))
conn = self._connect()
try:
rows = conn.execute(
"""
SELECT
l.id,
l.user_id,
l.item_type,
l.item_key,
l.request_id,
l.source_id,
l.origin,
l.final_status,
l.snapshot_json,
l.terminal_at
FROM activity_log l
LEFT JOIN activity_dismissals d
ON d.user_id = ?
AND d.item_type = l.item_type
AND d.item_key = l.item_key
WHERE l.user_id = ?
AND l.item_type = 'download'
AND l.final_status IN ('complete', 'error', 'cancelled')
AND d.id IS NULL
ORDER BY l.terminal_at DESC, l.id DESC
LIMIT ?
""",
(normalized_user_id, normalized_user_id, normalized_limit * 2),
).fetchall()
payload: list[dict[str, Any]] = []
seen_item_keys: set[str] = set()
for row in rows:
row_dict = dict(row)
item_key = str(row_dict.get("item_key") or "")
if not item_key or item_key in seen_item_keys:
continue
seen_item_keys.add(item_key)
raw_snapshot_json = row_dict.pop("snapshot_json", None)
snapshot_payload = None
if isinstance(raw_snapshot_json, str):
try:
snapshot_payload = json.loads(raw_snapshot_json)
except (ValueError, TypeError):
snapshot_payload = None
row_dict["snapshot"] = snapshot_payload
payload.append(row_dict)
if len(payload) >= normalized_limit:
break
return payload
finally:
conn.close()
def clear_history(self, user_id: int) -> int:
"""Delete all dismissals for a user and return deleted row count."""
normalized_user_id = self._coerce_positive_int(user_id, "user_id")
conn = self._connect()
try:
cursor = conn.execute(
"DELETE FROM activity_dismissals WHERE user_id = ?",
(normalized_user_id,),
)
conn.commit()
return int(cursor.rowcount or 0)
finally:
conn.close()

View File

@@ -96,10 +96,10 @@ def get_settings_tab_from_path(path: str) -> str | None:
def should_restrict_settings_to_admin(
users_config: Mapping[str, Any],
_users_config: Mapping[str, Any],
) -> bool:
"""Return whether settings/onboarding access is limited to admins."""
return bool(users_config.get("RESTRICT_SETTINGS_TO_ADMIN", True))
"""Settings/onboarding is always admin-only."""
return True
def requires_admin_for_settings_access(
@@ -116,14 +116,11 @@ def requires_admin_for_settings_access(
def get_auth_check_admin_status(
_auth_mode: str,
users_config: Mapping[str, Any],
_users_config: Mapping[str, Any],
session_data: Mapping[str, Any],
) -> bool:
"""Resolve /api/auth/check `is_admin` value for settings UI access control."""
"""Resolve /api/auth/check `is_admin` as the session's real admin role."""
if "user_id" not in session_data:
return False
if not should_restrict_settings_to_admin(users_config):
return True
return bool(session_data.get("is_admin", False))

View File

@@ -5,7 +5,7 @@ import time
from datetime import datetime, timedelta
from pathlib import Path
from threading import Lock, Event
from typing import Dict, List, Optional, Tuple, Any
from typing import Dict, List, Optional, Tuple, Any, Callable
from shelfmark.core.config import config as app_config
from shelfmark.core.models import QueueStatus, QueueItem, DownloadTask
@@ -22,6 +22,9 @@ class BookQueue:
self._status_timestamps: dict[str, datetime] = {} # Track when each status was last updated
self._cancel_flags: dict[str, Event] = {} # Cancellation flags for active downloads
self._active_downloads: dict[str, bool] = {} # Track currently downloading tasks
self._terminal_status_hook: Optional[
Callable[[str, QueueStatus, DownloadTask], None]
] = None
@property
def _status_timeout(self) -> timedelta:
@@ -79,16 +82,47 @@ class BookQueue:
self._status[book_id] = status
self._status_timestamps[book_id] = datetime.now()
def set_terminal_status_hook(
self,
hook: Optional[Callable[[str, QueueStatus, DownloadTask], None]],
) -> None:
"""Register a callback invoked when a task first enters a terminal status."""
with self._lock:
self._terminal_status_hook = hook
def update_status(self, book_id: str, status: QueueStatus) -> None:
"""Update status of a book in the queue."""
hook: Optional[Callable[[str, QueueStatus, DownloadTask], None]] = None
hook_task: Optional[DownloadTask] = None
with self._lock:
previous_status = self._status.get(book_id)
self._update_status(book_id, status)
terminal_statuses = {
QueueStatus.COMPLETE,
QueueStatus.AVAILABLE,
QueueStatus.ERROR,
QueueStatus.DONE,
QueueStatus.CANCELLED,
}
if (
status in terminal_statuses
and previous_status != status
and self._terminal_status_hook is not None
):
current_task = self._task_data.get(book_id)
if current_task is not None:
hook = self._terminal_status_hook
hook_task = current_task
# Clean up active download tracking when finished
if status in [QueueStatus.COMPLETE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.DONE, QueueStatus.CANCELLED]:
self._active_downloads.pop(book_id, None)
self._cancel_flags.pop(book_id, None)
if hook is not None and hook_task is not None:
hook(book_id, status, hook_task)
def update_download_path(self, task_id: str, download_path: str) -> None:
"""Update the download path of a task in the queue."""
with self._lock:
@@ -166,13 +200,7 @@ class BookQueue:
# Signal active download to stop
if task_id in self._cancel_flags:
self._cancel_flags[task_id].set()
self._update_status(task_id, QueueStatus.CANCELLED)
return True
elif current_status == QueueStatus.QUEUED:
# Remove from queue and mark as cancelled
self._update_status(task_id, QueueStatus.CANCELLED)
return True
elif current_status in [QueueStatus.COMPLETE, QueueStatus.DONE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.CANCELLED]:
if current_status in [QueueStatus.COMPLETE, QueueStatus.DONE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.CANCELLED]:
# Clear completed/errored/cancelled items from tracking
self._status.pop(task_id, None)
self._status_timestamps.pop(task_id, None)
@@ -181,7 +209,11 @@ class BookQueue:
self._active_downloads.pop(task_id, None)
return True
return False
if current_status in [QueueStatus.RESOLVING, QueueStatus.LOCATING, QueueStatus.DOWNLOADING, QueueStatus.QUEUED]:
self.update_status(task_id, QueueStatus.CANCELLED)
return True
return False
def set_priority(self, task_id: str, new_priority: int) -> bool:
"""Change the priority of a queued task (lower = higher priority)."""
@@ -253,11 +285,30 @@ class BookQueue:
return True
return any(status == QueueStatus.QUEUED for status in self._status.values())
def clear_completed(self) -> int:
"""Remove all completed, errored, or cancelled tasks from tracking."""
def clear_completed(self, user_id: Optional[int] = None) -> int:
"""Remove terminal tasks from tracking, optionally scoped to one user.
Args:
user_id: If provided, only clear tasks belonging to this user,
plus legacy tasks with no user_id. If None, clear all.
"""
terminal_statuses = {QueueStatus.COMPLETE, QueueStatus.DONE, QueueStatus.AVAILABLE, QueueStatus.ERROR, QueueStatus.CANCELLED}
with self._lock:
to_remove = [task_id for task_id, status in self._status.items() if status in terminal_statuses]
to_remove: list[str] = []
for task_id, status in self._status.items():
if status not in terminal_statuses:
continue
if user_id is None:
to_remove.append(task_id)
continue
task = self._task_data.get(task_id)
if task is None:
# Without task ownership metadata we cannot safely scope removal.
continue
if task.user_id is None or task.user_id == user_id:
to_remove.append(task_id)
for task_id in to_remove:
self._status.pop(task_id, None)

View File

@@ -24,6 +24,7 @@ from shelfmark.core.requests_service import (
fulfil_request,
reject_request,
)
from shelfmark.core.activity_service import ActivityService, build_request_item_key
from shelfmark.core.settings_registry import load_config_file
from shelfmark.core.user_db import UserDB
@@ -134,12 +135,66 @@ def _emit_request_event(
logger.warning(f"Failed to emit WebSocket event '{event_name}' to room '{room}': {exc}")
def _extract_release_source_id(release_data: Any) -> str | None:
if not isinstance(release_data, dict):
return None
source_id = release_data.get("source_id")
if not isinstance(source_id, str):
return None
normalized = source_id.strip()
return normalized or None
def _record_terminal_request_snapshot(
activity_service: ActivityService | None,
*,
request_row: dict[str, Any],
) -> None:
if activity_service is None:
return
request_status = request_row.get("status")
if request_status not in {"rejected", "cancelled"}:
return
raw_request_id = request_row.get("id")
try:
request_id = int(raw_request_id)
except (TypeError, ValueError):
return
if request_id < 1:
return
raw_user_id = request_row.get("user_id")
try:
user_id = int(raw_user_id)
except (TypeError, ValueError):
user_id = None
source_id = _extract_release_source_id(request_row.get("release_data"))
try:
activity_service.record_terminal_snapshot(
user_id=user_id,
item_type="request",
item_key=build_request_item_key(request_id),
origin="request",
final_status=request_status,
snapshot={"kind": "request", "request": request_row},
request_id=request_id,
source_id=source_id,
)
except Exception as exc:
logger.warning("Failed to record terminal request snapshot for request %s: %s", request_id, exc)
def register_request_routes(
app: Flask,
user_db: UserDB,
*,
resolve_auth_mode: Callable[[], str],
queue_release: Callable[..., tuple[bool, str | None]],
activity_service: ActivityService | None = None,
ws_manager: Any | None = None,
) -> None:
"""Register request policy and request lifecycle routes."""
@@ -408,6 +463,8 @@ def register_request_routes(
except RequestServiceError as exc:
return _error_response(str(exc), exc.status_code, code=exc.code)
_record_terminal_request_snapshot(activity_service, request_row=updated)
event_payload = {
"request_id": updated["id"],
"status": updated["status"],
@@ -557,6 +614,8 @@ def register_request_routes(
except RequestServiceError as exc:
return _error_response(str(exc), exc.status_code, code=exc.code)
_record_terminal_request_snapshot(activity_service, request_row=updated)
event_payload = {
"request_id": updated["id"],
"status": updated["status"],

View File

@@ -12,6 +12,19 @@ from shelfmark.core.request_policy import normalize_content_type, parse_policy_m
VALID_REQUEST_STATUSES = frozenset({"pending", "fulfilled", "rejected", "cancelled"})
TERMINAL_REQUEST_STATUSES = frozenset({"fulfilled", "rejected", "cancelled"})
VALID_REQUEST_LEVELS = frozenset({"book", "release"})
VALID_DELIVERY_STATES = frozenset(
{
"none",
"unknown",
"queued",
"resolving",
"locating",
"downloading",
"complete",
"error",
"cancelled",
}
)
MAX_REQUEST_NOTE_LENGTH = 1000
MAX_REQUEST_JSON_BLOB_BYTES = 10 * 1024
@@ -63,6 +76,16 @@ def normalize_request_level(request_level: Any) -> str:
return normalized
def normalize_delivery_state(state: Any) -> str:
"""Validate and normalize delivery-state values."""
if not isinstance(state, str):
raise ValueError(f"Invalid delivery_state: {state}")
normalized = state.strip().lower()
if normalized not in VALID_DELIVERY_STATES:
raise ValueError(f"Invalid delivery_state: {state}")
return normalized
def validate_request_level_payload(request_level: Any, release_data: Any) -> str:
"""Validate request_level and release_data shape coupling."""
normalized_level = normalize_request_level(request_level)
@@ -163,6 +186,68 @@ def _now_timestamp() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def _extract_release_source_id(release_data: Any) -> str | None:
if not isinstance(release_data, dict):
return None
source_id = release_data.get("source_id")
if not isinstance(source_id, str):
return None
normalized = source_id.strip()
return normalized or None
def _existing_delivery_state(request_row: dict[str, Any]) -> str:
raw_state = request_row.get("delivery_state")
if not isinstance(raw_state, str):
return "none"
normalized = raw_state.strip().lower()
return normalized if normalized in VALID_DELIVERY_STATES else "none"
def sync_delivery_states_from_queue_status(
user_db: "UserDB",
*,
queue_status: dict[str, dict[str, Any]],
user_id: int | None = None,
) -> list[dict[str, Any]]:
"""Persist delivery-state transitions for fulfilled requests based on queue status."""
source_delivery_states: dict[str, str] = {}
for status_key in ("queued", "resolving", "locating", "downloading", "complete", "error", "cancelled"):
status_bucket = queue_status.get(status_key)
if not isinstance(status_bucket, dict):
continue
for source_id in status_bucket:
source_delivery_states[source_id] = status_key
if not source_delivery_states:
return []
fulfilled_rows = user_db.list_requests(user_id=user_id, status="fulfilled")
updated: list[dict[str, Any]] = []
for row in fulfilled_rows:
source_id = _extract_release_source_id(row.get("release_data"))
if source_id is None:
continue
delivery_state = source_delivery_states.get(source_id)
if delivery_state is None:
continue
if _existing_delivery_state(row) == delivery_state:
continue
updated.append(
user_db.update_request(
row["id"],
delivery_state=delivery_state,
delivery_updated_at=_now_timestamp(),
)
)
return updated
def create_request(
user_db: "UserDB",
*,
@@ -383,6 +468,8 @@ def fulfil_request(
request_id,
status="fulfilled",
release_data=selected_release_data,
delivery_state="queued",
delivery_updated_at=_now_timestamp(),
admin_note=normalized_admin_note,
reviewed_by=admin_user_id,
reviewed_at=_now_timestamp(),

View File

@@ -0,0 +1,333 @@
"""Self-service user account routes."""
from functools import wraps
from typing import Any, Callable, Mapping
from flask import Flask, jsonify, request, session
from werkzeug.security import generate_password_hash
from shelfmark.config.env import CWA_DB_PATH
from shelfmark.core.admin_settings_routes import validate_user_settings
from shelfmark.core.auth_modes import (
AUTH_SOURCE_BUILTIN,
AUTH_SOURCE_CWA,
AUTH_SOURCE_OIDC,
AUTH_SOURCE_PROXY,
determine_auth_mode,
has_local_password_admin,
normalize_auth_source,
)
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__)
MIN_PASSWORD_LENGTH = 4
def _get_auth_mode() -> str:
"""Get current auth mode from config."""
try:
config = load_config_file("security")
return determine_auth_mode(
config,
CWA_DB_PATH,
has_local_admin=has_local_password_admin(),
)
except Exception:
return "none"
def _require_authenticated_user(f: Callable[..., Any]) -> Callable[..., Any]:
"""Decorator requiring an authenticated session linked to a local user row."""
@wraps(f)
def decorated(*args, **kwargs):
auth_mode = _get_auth_mode()
if auth_mode != "none" and "user_id" not in session:
return jsonify({"error": "Authentication required"}), 401
if "db_user_id" not in session:
return jsonify({"error": "Authenticated session is missing local user context"}), 403
return f(*args, **kwargs)
return decorated
def _get_current_user(user_db: UserDB) -> tuple[int | None, dict[str, Any] | None, tuple[Any, int] | None]:
raw_user_id = session.get("db_user_id")
try:
user_id = int(raw_user_id)
except (TypeError, ValueError):
return None, None, (jsonify({"error": "Invalid user context"}), 400)
user = user_db.get_user(user_id=user_id)
if not user:
return None, None, (jsonify({"error": "User not found"}), 404)
return user_id, user, None
def _is_user_active(user: Mapping[str, Any], auth_method: str) -> bool:
source = normalize_auth_source(user.get("auth_source"), user.get("oidc_subject"))
if source == AUTH_SOURCE_BUILTIN:
return auth_method in (AUTH_SOURCE_BUILTIN, AUTH_SOURCE_OIDC)
return source == auth_method
def _get_self_edit_capabilities(user: Mapping[str, Any]) -> dict[str, Any]:
auth_source = normalize_auth_source(
user.get("auth_source"),
user.get("oidc_subject"),
)
return {
"authSource": auth_source,
"canSetPassword": auth_source == AUTH_SOURCE_BUILTIN,
"canEditRole": False,
"canEditEmail": auth_source in {AUTH_SOURCE_BUILTIN, AUTH_SOURCE_PROXY},
"canEditDisplayName": auth_source != AUTH_SOURCE_OIDC,
}
def _serialize_self_user(user: Mapping[str, Any], auth_mode: str) -> dict[str, Any]:
payload = dict(user)
payload.pop("password_hash", None)
payload["auth_source"] = normalize_auth_source(
payload.get("auth_source"),
payload.get("oidc_subject"),
)
payload["is_active"] = _is_user_active(payload, auth_mode)
payload["edit_capabilities"] = _get_self_edit_capabilities(payload)
return payload
def _get_settings_registry():
# Ensure settings modules are loaded before reading registry metadata.
import shelfmark.config.settings # noqa: F401
import shelfmark.config.security # noqa: F401
import shelfmark.config.users_settings # noqa: F401
from shelfmark.core import settings_registry
return settings_registry
def _get_ordered_user_overridable_fields(tab_name: str) -> list[tuple[str, Any]]:
settings_registry = _get_settings_registry()
tab = settings_registry.get_settings_tab(tab_name)
if not tab:
return []
overridable_map = settings_registry.get_user_overridable_fields(tab_name=tab_name)
return [(field.key, field) for field in tab.fields if field.key in overridable_map]
def _build_delivery_preferences_payload(user_db: UserDB, user_id: int) -> dict[str, Any]:
from shelfmark.core.config import config as app_config
settings_registry = _get_settings_registry()
ordered_fields = _get_ordered_user_overridable_fields("downloads")
if not ordered_fields:
raise ValueError("Downloads settings tab not found")
download_config = load_config_file("downloads")
user_settings = user_db.get_user_settings(user_id)
ordered_keys = [key for key, _ in ordered_fields]
fields_payload: list[dict[str, Any]] = []
global_values: dict[str, Any] = {}
effective: dict[str, dict[str, Any]] = {}
for key, field in ordered_fields:
serialized = settings_registry.serialize_field(field, "downloads", include_value=False)
serialized["fromEnv"] = bool(
field.env_supported and settings_registry.is_value_from_env(field)
)
fields_payload.append(serialized)
global_values[key] = app_config.get(key, field.default)
source = "default"
value = app_config.get(key, field.default, user_id=user_id)
if field.env_supported and settings_registry.is_value_from_env(field):
source = "env_var"
elif key in user_settings and user_settings[key] is not None:
source = "user_override"
value = user_settings[key]
elif key in download_config:
source = "global_config"
effective[key] = {"value": value, "source": source}
user_overrides = {
key: user_settings[key]
for key in ordered_keys
if key in user_settings and user_settings[key] is not None
}
return {
"tab": "downloads",
"keys": ordered_keys,
"fields": fields_payload,
"globalValues": global_values,
"userOverrides": user_overrides,
"effective": effective,
}
def register_self_user_routes(app: Flask, user_db: UserDB) -> None:
"""Register self-service user endpoints."""
@app.route("/api/users/me/edit-context", methods=["GET"])
@_require_authenticated_user
def users_me_edit_context():
user_id, user, user_error = _get_current_user(user_db)
if user_error:
return user_error
auth_mode = _get_auth_mode()
serialized_user = _serialize_self_user(user, auth_mode)
serialized_user["settings"] = user_db.get_user_settings(user_id)
try:
delivery_preferences = _build_delivery_preferences_payload(user_db, user_id)
except ValueError:
return jsonify({"error": "Downloads settings tab not found"}), 500
except Exception as exc:
logger.warning(f"Failed to build user delivery preferences for user_id={user_id}: {exc}")
delivery_preferences = None
user_overridable_keys = sorted(
set(delivery_preferences.get("keys", []) if delivery_preferences else [])
)
return jsonify(
{
"user": serialized_user,
"deliveryPreferences": delivery_preferences,
"userOverridableKeys": user_overridable_keys,
}
)
@app.route("/api/users/me", methods=["PUT"])
@_require_authenticated_user
def users_me_update():
user_id, user, user_error = _get_current_user(user_db)
if user_error:
return user_error
data = request.get_json() or {}
if not isinstance(data, dict):
return jsonify({"error": "Request body must be a JSON object"}), 400
capabilities = _get_self_edit_capabilities(user)
auth_source = capabilities["authSource"]
password = data.get("password", "")
if password:
if not capabilities["canSetPassword"]:
return jsonify(
{
"error": f"Cannot set password for {auth_source.upper()} users",
"message": "Password authentication is only available for local users.",
}
), 400
if len(password) < MIN_PASSWORD_LENGTH:
return jsonify({"error": f"Password must be at least {MIN_PASSWORD_LENGTH} characters"}), 400
user_db.update_user(user_id, password_hash=generate_password_hash(password))
user_fields: dict[str, Any] = {}
if "email" in data:
incoming_email = data.get("email")
if incoming_email is None:
user_fields["email"] = None
else:
user_fields["email"] = str(incoming_email).strip() or None
if "display_name" in data:
incoming_display_name = data.get("display_name")
user_fields["display_name"] = (
str(incoming_display_name).strip() or None
if incoming_display_name is not None
else None
)
email_changed = "email" in user_fields and user_fields["email"] != user.get("email")
display_name_changed = (
"display_name" in user_fields
and user_fields["display_name"] != user.get("display_name")
)
if email_changed and not capabilities["canEditEmail"]:
if auth_source == AUTH_SOURCE_CWA:
return jsonify(
{
"error": "Cannot change email for CWA users",
"message": "Email is synced from Calibre-Web.",
}
), 400
return jsonify(
{
"error": "Cannot change email for OIDC users",
"message": "Email is managed by your identity provider.",
}
), 400
if display_name_changed and not capabilities["canEditDisplayName"]:
return jsonify(
{
"error": "Cannot change display name for OIDC users",
"message": "Display name is managed by your identity provider.",
}
), 400
for field in ("email", "display_name"):
if field in user_fields and user_fields[field] == user.get(field):
user_fields.pop(field)
if user_fields:
user_db.update_user(user_id, **user_fields)
if "settings" in data:
settings_payload = data["settings"]
if not isinstance(settings_payload, dict):
return jsonify({"error": "Settings must be an object"}), 400
allowed_user_settings_keys = {
key for key, _field in _get_ordered_user_overridable_fields("downloads")
}
disallowed_keys = sorted(
key for key in settings_payload if key not in allowed_user_settings_keys
)
if disallowed_keys:
return jsonify(
{
"error": "Some settings are admin-only",
"details": [
f"Setting not user-overridable: {key}" for key in disallowed_keys
],
}
), 400
validated_settings, validation_errors = validate_user_settings(settings_payload)
if validation_errors:
return jsonify(
{
"error": "Invalid settings payload",
"details": validation_errors,
}
), 400
user_db.set_user_settings(user_id, validated_settings)
try:
from shelfmark.core.config import config as app_config
app_config.refresh()
except Exception:
pass
updated = user_db.get_user(user_id=user_id)
if not updated:
return jsonify({"error": "User not found"}), 404
result = _serialize_self_user(updated, _get_auth_mode())
result["settings"] = user_db.get_user_settings(user_id)
logger.info(f"User {user_id} updated their own account")
return jsonify(result)

View File

@@ -9,6 +9,7 @@ from typing import Any, Dict, List, Optional
from shelfmark.core.auth_modes import AUTH_SOURCE_BUILTIN, AUTH_SOURCE_SET
from shelfmark.core.logger import setup_logger
from shelfmark.core.requests_service import (
normalize_delivery_state,
normalize_policy_mode,
normalize_request_level,
normalize_request_status,
@@ -40,6 +41,7 @@ CREATE TABLE IF NOT EXISTS download_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending',
delivery_state TEXT NOT NULL DEFAULT 'none',
source_hint TEXT,
content_type TEXT NOT NULL,
request_level TEXT NOT NULL,
@@ -50,7 +52,8 @@ CREATE TABLE IF NOT EXISTS download_requests (
admin_note TEXT,
reviewed_by INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
reviewed_at TIMESTAMP
reviewed_at TIMESTAMP,
delivery_updated_at TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_download_requests_user_status_created_at
@@ -58,6 +61,39 @@ ON download_requests (user_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_download_requests_status_created_at
ON download_requests (status, created_at DESC);
CREATE TABLE IF NOT EXISTS activity_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
item_type TEXT NOT NULL,
item_key TEXT NOT NULL,
request_id INTEGER,
source_id TEXT,
origin TEXT NOT NULL,
final_status TEXT NOT NULL,
snapshot_json TEXT NOT NULL,
terminal_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_activity_log_user_terminal
ON activity_log (user_id, terminal_at DESC);
CREATE INDEX IF NOT EXISTS idx_activity_log_lookup
ON activity_log (user_id, item_type, item_key, id DESC);
CREATE TABLE IF NOT EXISTS activity_dismissals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
item_type TEXT NOT NULL,
item_key TEXT NOT NULL,
activity_log_id INTEGER REFERENCES activity_log(id) ON DELETE SET NULL,
dismissed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, item_type, item_key)
);
CREATE INDEX IF NOT EXISTS idx_activity_dismissals_user_dismissed_at
ON activity_dismissals (user_id, dismissed_at DESC);
"""
@@ -126,6 +162,8 @@ class UserDB:
try:
conn.executescript(_CREATE_TABLES_SQL)
self._migrate_auth_source_column(conn)
self._migrate_request_delivery_columns(conn)
self._migrate_activity_tables(conn)
conn.commit()
# WAL mode must be changed outside an open transaction.
conn.execute("PRAGMA journal_mode=WAL")
@@ -152,6 +190,91 @@ class UserDB:
"UPDATE users SET auth_source = 'builtin' WHERE auth_source IS NULL OR auth_source = ''"
)
def _migrate_request_delivery_columns(self, conn: sqlite3.Connection) -> None:
"""Ensure request delivery-state columns exist and backfill historical rows."""
columns = conn.execute("PRAGMA table_info(download_requests)").fetchall()
column_names = {str(col["name"]) for col in columns}
if "delivery_state" not in column_names:
conn.execute(
"ALTER TABLE download_requests ADD COLUMN delivery_state TEXT NOT NULL DEFAULT 'none'"
)
if "delivery_updated_at" not in column_names:
conn.execute("ALTER TABLE download_requests ADD COLUMN delivery_updated_at TIMESTAMP")
conn.execute(
"""
UPDATE download_requests
SET delivery_state = 'unknown'
WHERE status = 'fulfilled' AND (delivery_state IS NULL OR TRIM(delivery_state) = '' OR delivery_state = 'none')
"""
)
conn.execute(
"""
UPDATE download_requests
SET delivery_state = 'none'
WHERE status != 'fulfilled' AND (delivery_state IS NULL OR TRIM(delivery_state) = '')
"""
)
conn.execute(
"""
UPDATE download_requests
SET delivery_updated_at = COALESCE(delivery_updated_at, reviewed_at, created_at)
WHERE delivery_state != 'none' AND delivery_updated_at IS NULL
"""
)
conn.execute(
"""
UPDATE download_requests
SET delivery_state = 'complete'
WHERE delivery_state = 'cleared'
"""
)
def _migrate_activity_tables(self, conn: sqlite3.Connection) -> None:
"""Ensure activity log and dismissal tables exist with current columns/indexes."""
conn.executescript(
"""
CREATE TABLE IF NOT EXISTS activity_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
item_type TEXT NOT NULL,
item_key TEXT NOT NULL,
request_id INTEGER,
source_id TEXT,
origin TEXT NOT NULL,
final_status TEXT NOT NULL,
snapshot_json TEXT NOT NULL,
terminal_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_activity_log_user_terminal
ON activity_log (user_id, terminal_at DESC);
CREATE INDEX IF NOT EXISTS idx_activity_log_lookup
ON activity_log (user_id, item_type, item_key, id DESC);
CREATE TABLE IF NOT EXISTS activity_dismissals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
item_type TEXT NOT NULL,
item_key TEXT NOT NULL,
activity_log_id INTEGER REFERENCES activity_log(id) ON DELETE SET NULL,
dismissed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, item_type, item_key)
);
CREATE INDEX IF NOT EXISTS idx_activity_dismissals_user_dismissed_at
ON activity_dismissals (user_id, dismissed_at DESC);
"""
)
dismissal_columns = conn.execute("PRAGMA table_info(activity_dismissals)").fetchall()
dismissal_column_names = {str(col["name"]) for col in dismissal_columns}
if "activity_log_id" not in dismissal_column_names:
conn.execute("ALTER TABLE activity_dismissals ADD COLUMN activity_log_id INTEGER")
def create_user(
self,
username: str,
@@ -350,6 +473,8 @@ class UserDB:
admin_note: Optional[str] = None,
reviewed_by: Optional[int] = None,
reviewed_at: Optional[str] = None,
delivery_state: str = "none",
delivery_updated_at: Optional[str] = None,
) -> Dict[str, Any]:
"""Create a download request row and return the created record."""
if not isinstance(book_data, dict):
@@ -360,6 +485,7 @@ class UserDB:
raise ValueError("content_type is required")
normalized_status = normalize_request_status(status)
normalized_delivery_state = normalize_delivery_state(delivery_state)
normalized_policy_mode = normalize_policy_mode(policy_mode)
normalized_request_level = validate_request_level_payload(request_level, release_data)
@@ -371,6 +497,7 @@ class UserDB:
INSERT INTO download_requests (
user_id,
status,
delivery_state,
source_hint,
content_type,
request_level,
@@ -380,13 +507,15 @@ class UserDB:
note,
admin_note,
reviewed_by,
reviewed_at
reviewed_at,
delivery_updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
user_id,
normalized_status,
normalized_delivery_state,
source_hint,
content_type,
normalized_request_level,
@@ -397,6 +526,7 @@ class UserDB:
admin_note,
reviewed_by,
reviewed_at,
delivery_updated_at,
),
)
conn.commit()
@@ -483,6 +613,8 @@ class UserDB:
"admin_note",
"reviewed_by",
"reviewed_at",
"delivery_state",
"delivery_updated_at",
}
def update_request(self, request_id: int, **kwargs) -> Dict[str, Any]:
@@ -520,6 +652,14 @@ class UserDB:
if "policy_mode" in updates:
updates["policy_mode"] = normalize_policy_mode(updates["policy_mode"])
if "delivery_state" in updates:
updates["delivery_state"] = normalize_delivery_state(updates["delivery_state"])
if "delivery_updated_at" in updates:
delivery_updated_at = updates["delivery_updated_at"]
if delivery_updated_at is not None and not isinstance(delivery_updated_at, str):
raise ValueError("delivery_updated_at must be a string when provided")
if "content_type" in updates and not updates["content_type"]:
raise ValueError("content_type is required")

View File

@@ -325,6 +325,7 @@ def _task_to_dict(task: DownloadTask) -> Dict[str, Any]:
'status': task.status,
'status_message': task.status_message,
'download_path': task.download_path,
'user_id': task.user_id,
'username': task.username,
}
@@ -492,12 +493,13 @@ def update_download_status(book_id: str, status: str, message: Optional[str] = N
return
_last_status_event[book_id] = status_event
book_queue.update_status(book_id, queue_status_enum)
# Update status message if provided (empty string clears the message)
# Update status message first so terminal snapshots capture the final message
# (for example, "Complete" or "Sent to ...") instead of a stale in-progress one.
if message is not None:
book_queue.update_status_message(book_id, message)
book_queue.update_status(book_id, queue_status_enum)
# Broadcast status update via WebSocket
if ws_manager:
ws_manager.broadcast_status_update(queue_status())
@@ -528,9 +530,9 @@ def get_active_downloads() -> List[str]:
"""Get list of currently active downloads."""
return book_queue.get_active_downloads()
def clear_completed() -> int:
"""Clear all completed downloads from tracking."""
return book_queue.clear_completed()
def clear_completed(user_id: Optional[int] = None) -> int:
"""Clear completed downloads from tracking (optionally user-scoped)."""
return book_queue.clear_completed(user_id=user_id)
def _cleanup_progress_tracking(task_id: str) -> None:
"""Clean up progress tracking data for a completed/cancelled download."""

View File

@@ -26,7 +26,7 @@ from shelfmark.config.env import (
)
from shelfmark.core.config import config as app_config
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import SearchFilters
from shelfmark.core.models import SearchFilters, QueueStatus
from shelfmark.core.prefix_middleware import PrefixMiddleware
from shelfmark.core.auth_modes import (
determine_auth_mode,
@@ -45,6 +45,10 @@ from shelfmark.core.request_policy import (
normalize_source,
resolve_policy_mode,
)
from shelfmark.core.requests_service import (
sync_delivery_states_from_queue_status,
)
from shelfmark.core.activity_service import ActivityService, build_download_item_key
from shelfmark.core.utils import normalize_base_path
from shelfmark.api.websocket import ws_manager
@@ -113,14 +117,18 @@ 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
activity_service: ActivityService | None = None
try:
user_db = UserDB(_user_db_path)
user_db.initialize()
activity_service = ActivityService(_user_db_path)
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
from shelfmark.core.self_user_routes import register_self_user_routes
register_oidc_routes(app, user_db)
register_admin_routes(app, user_db)
register_self_user_routes(app, user_db)
except (sqlite3.OperationalError, OSError) as e:
logger.warning(
f"User database initialization failed: {e}. "
@@ -380,14 +388,28 @@ def _policy_block_response(mode: PolicyMode):
if user_db is not None:
try:
from shelfmark.core.request_routes import register_request_routes
from shelfmark.core.activity_routes import register_activity_routes
register_request_routes(
app,
user_db,
resolve_auth_mode=lambda: get_auth_mode(),
queue_release=lambda *args, **kwargs: backend.queue_release(*args, **kwargs),
activity_service=activity_service,
ws_manager=ws_manager,
)
if activity_service is not None:
register_activity_routes(
app,
user_db,
activity_service=activity_service,
resolve_auth_mode=lambda: get_auth_mode(),
resolve_status_scope=lambda: _resolve_status_scope(),
queue_status=lambda user_id=None: backend.queue_status(user_id=user_id),
sync_request_delivery_states=sync_delivery_states_from_queue_status,
emit_request_updates=lambda rows: _emit_request_update_events(rows),
ws_manager=ws_manager,
)
except Exception as e:
logger.warning(f"Failed to register request routes: {e}")
@@ -970,6 +992,148 @@ def _resolve_status_scope(*, require_authenticated: bool = True) -> tuple[bool,
return False, db_user_id, True
def _extract_release_source_id(release_data: Any) -> str | None:
if not isinstance(release_data, dict):
return None
source_id = release_data.get("source_id")
if not isinstance(source_id, str):
return None
normalized = source_id.strip()
return normalized or None
def _queue_status_to_final_activity_status(status: QueueStatus) -> str | None:
if status == QueueStatus.COMPLETE:
return "complete"
if status == QueueStatus.ERROR:
return "error"
if status == QueueStatus.CANCELLED:
return "cancelled"
return None
def _record_download_terminal_snapshot(task_id: str, status: QueueStatus, task: Any) -> None:
if activity_service is None:
return
final_status = _queue_status_to_final_activity_status(status)
if final_status is None:
return
raw_owner_user_id = getattr(task, "user_id", None)
try:
owner_user_id = int(raw_owner_user_id) if raw_owner_user_id is not None else None
except (TypeError, ValueError):
owner_user_id = None
linked_request: dict[str, Any] | None = None
request_id: int | None = None
origin = "direct"
if user_db is not None and owner_user_id is not None:
fulfilled_rows = user_db.list_requests(user_id=owner_user_id, status="fulfilled")
for row in fulfilled_rows:
source_id = _extract_release_source_id(row.get("release_data"))
if source_id == task_id:
linked_request = row
origin = "requested"
try:
request_id = int(row.get("id"))
except (TypeError, ValueError):
request_id = None
break
try:
download_payload = backend._task_to_dict(task)
except Exception as exc:
logger.warning("Failed to serialize task payload for terminal snapshot: %s", exc)
download_payload = {
"id": task_id,
"title": getattr(task, "title", "Unknown title"),
"author": getattr(task, "author", "Unknown author"),
"source": getattr(task, "source", "direct_download"),
"added_time": getattr(task, "added_time", 0),
"status_message": getattr(task, "status_message", None),
"download_path": getattr(task, "download_path", None),
"user_id": getattr(task, "user_id", None),
"username": getattr(task, "username", None),
}
snapshot: dict[str, Any] = {"kind": "download", "download": download_payload}
if linked_request is not None:
snapshot["request"] = linked_request
try:
activity_service.record_terminal_snapshot(
user_id=owner_user_id,
item_type="download",
item_key=build_download_item_key(task_id),
origin=origin,
final_status=final_status,
snapshot=snapshot,
request_id=request_id,
source_id=task_id,
)
except Exception as exc:
logger.warning("Failed to record terminal download snapshot for task %s: %s", task_id, exc)
def _task_owned_by_actor(task: Any, *, actor_user_id: int | None, actor_username: str | None) -> bool:
raw_task_user_id = getattr(task, "user_id", None)
try:
task_user_id = int(raw_task_user_id) if raw_task_user_id is not None else None
except (TypeError, ValueError):
task_user_id = None
if actor_user_id is not None and task_user_id is not None:
return task_user_id == actor_user_id
task_username = getattr(task, "username", None)
if isinstance(task_username, str) and task_username.strip() and isinstance(actor_username, str):
return task_username.strip() == actor_username.strip()
return False
def _is_graduated_request_download(task_id: str, *, user_id: int) -> bool:
if user_db is None:
return False
fulfilled_rows = user_db.list_requests(user_id=user_id, status="fulfilled")
for row in fulfilled_rows:
source_id = _extract_release_source_id(row.get("release_data"))
if source_id == task_id:
return True
return False
if activity_service is not None:
backend.book_queue.set_terminal_status_hook(_record_download_terminal_snapshot)
def _emit_request_update_events(updated_requests: list[dict[str, Any]]) -> None:
"""Broadcast request_update events for rows changed by delivery-state sync."""
if not updated_requests or ws_manager is None:
return
try:
socketio_ref = getattr(ws_manager, "socketio", None)
is_enabled = getattr(ws_manager, "is_enabled", None)
if socketio_ref is None or not callable(is_enabled) or not is_enabled():
return
for updated in updated_requests:
payload = {
"request_id": updated["id"],
"status": updated["status"],
"delivery_state": updated.get("delivery_state"),
"title": (updated.get("book_data") or {}).get("title") or "Unknown title",
}
socketio_ref.emit("request_update", payload, to=f"user_{updated['user_id']}")
socketio_ref.emit("request_update", payload, to="admins")
except Exception as exc:
logger.warning(f"Failed to emit delivery request_update events: {exc}")
@app.route('/api/status', methods=['GET'])
@login_required
def api_status() -> Union[Response, Tuple[Response, int]]:
@@ -986,6 +1150,13 @@ def api_status() -> Union[Response, Tuple[Response, int]]:
user_id = None if is_admin else db_user_id
status = backend.queue_status(user_id=user_id)
if user_db is not None:
updated_requests = sync_delivery_states_from_queue_status(
user_db,
queue_status=status,
user_id=user_id,
)
_emit_request_update_events(updated_requests)
return jsonify(status)
except Exception as e:
logger.error_trace(f"Status error: {e}")
@@ -1110,6 +1281,27 @@ def api_cancel_download(book_id: str) -> Union[Response, Tuple[Response, int]]:
flask.Response: JSON status indicating success or failure.
"""
try:
task = backend.book_queue.get_task(book_id)
if task is None:
return jsonify({"error": "Failed to cancel download or book not found"}), 404
is_admin, db_user_id, can_access_status = _resolve_status_scope()
if not is_admin:
if not can_access_status or db_user_id is None:
return jsonify({"error": "User identity unavailable", "code": "user_identity_unavailable"}), 403
actor_username = session.get("user_id")
normalized_actor_username = actor_username if isinstance(actor_username, str) else None
if not _task_owned_by_actor(
task,
actor_user_id=db_user_id,
actor_username=normalized_actor_username,
):
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
if _is_graduated_request_download(book_id, user_id=db_user_id):
return jsonify({"error": "Forbidden", "code": "requested_download_cancel_forbidden"}), 403
success = backend.cancel_download(book_id)
if success:
return jsonify({"status": "cancelled", "book_id": book_id})
@@ -1227,12 +1419,17 @@ def api_clear_completed() -> Union[Response, Tuple[Response, int]]:
flask.Response: JSON with count of removed books.
"""
try:
removed_count = backend.clear_completed()
is_admin, db_user_id, can_access_status = _resolve_status_scope()
if not can_access_status:
return jsonify({"error": "User identity unavailable", "code": "user_identity_unavailable"}), 403
scoped_user_id = None if is_admin else db_user_id
removed_count = backend.clear_completed(user_id=scoped_user_id)
# Broadcast status update after clearing
if ws_manager:
ws_manager.broadcast_status_update(backend.queue_status())
return jsonify({"status": "cleared", "removed_count": removed_count})
except Exception as e:
logger.error_trace(f"Clear completed error: {e}")

View File

@@ -17,7 +17,6 @@ import {
downloadBook,
downloadRelease,
cancelDownload,
clearCompleted,
getConfig,
createRequest,
isApiResponseError,
@@ -31,6 +30,7 @@ import { useDownloadTracking } from './hooks/useDownloadTracking';
import { useRequestPolicy } from './hooks/useRequestPolicy';
import { resolveDefaultModeFromPolicy, resolveSourceModeFromPolicy } from './hooks/requestPolicyCore';
import { useRequests } from './hooks/useRequests';
import { useActivity } from './hooks/useActivity';
import { Header } from './components/Header';
import { SearchSection } from './components/SearchSection';
import { AdvancedFilters } from './components/AdvancedFilters';
@@ -40,9 +40,9 @@ import { ReleaseModal } from './components/ReleaseModal';
import { RequestConfirmationModal } from './components/RequestConfirmationModal';
import { ToastContainer } from './components/ToastContainer';
import { Footer } from './components/Footer';
import { ActivitySidebar, requestToActivityItem } from './components/activity';
import { ActivitySidebar } from './components/activity';
import { LoginPage } from './pages/LoginPage';
import { SettingsModal } from './components/settings';
import { SelfSettingsModal, SettingsModal } from './components/settings';
import { ConfigSetupBanner } from './components/ConfigSetupBanner';
import { OnboardingModal } from './components/OnboardingModal';
import { DEFAULT_LANGUAGES, DEFAULT_SUPPORTED_FORMATS } from './data/languages';
@@ -62,6 +62,7 @@ import {
import { bookFromRequestData } from './utils/requestFulfil';
import { policyTrace } from './utils/policyTrace';
import { SearchModeProvider } from './contexts/SearchModeContext';
import { useSocket } from './contexts/SocketContext';
import './styles.css';
const CONTENT_TYPE_STORAGE_KEY = 'preferred-content-type';
@@ -79,7 +80,6 @@ const getInitialContentType = (): ContentType => {
};
const POLICY_GUARD_ERROR_CODES = new Set(['policy_requires_request', 'policy_blocked']);
const isPolicyGuardError = (error: unknown): boolean => {
return (
isApiResponseError(error) &&
@@ -117,6 +117,7 @@ const getErrorMessage = (error: unknown, fallback: string): string => {
function App() {
const { toasts, showToast, removeToast } = useToast();
const { socket } = useSocket();
// Realtime status with WebSocket and polling fallback
// Socket connection is managed by SocketProvider in main.tsx
@@ -144,7 +145,7 @@ function App() {
isAuthenticated,
authRequired,
authChecked,
isAdmin: authCanAccessSettings,
isAdmin: authIsAdmin,
authMode,
username,
displayName,
@@ -163,9 +164,9 @@ function App() {
if (!authChecked || !isAuthenticated) {
return;
}
policyTrace('auth.status', { authChecked, isAuthenticated, isAdmin: authCanAccessSettings, username });
policyTrace('auth.status', { authChecked, isAuthenticated, isAdmin: authIsAdmin, username });
void fetchStatus();
}, [authChecked, isAuthenticated, authCanAccessSettings, username, fetchStatus]);
}, [authChecked, isAuthenticated, authIsAdmin, username, fetchStatus]);
// Content type state (ebook vs audiobook) - defined before useSearch since it's passed to it
const [contentType, setContentType] = useState<ContentType>(() => getInitialContentType());
@@ -187,14 +188,12 @@ function App() {
refresh: refreshRequestPolicy,
} = useRequestPolicy({
enabled: isAuthenticated,
isAdmin: authCanAccessSettings,
isAdmin: authIsAdmin,
});
const requestRoleIsAdmin = requestPolicy ? Boolean(requestPolicy.is_admin) : false;
const {
requests,
pendingCount: pendingRequestCount,
isLoading: isRequestsLoading,
cancelRequest: cancelUserRequest,
fulfilRequest: fulfilSidebarRequest,
@@ -204,58 +203,28 @@ function App() {
enabled: isAuthenticated,
});
const dismissedRequestStorageKey = useMemo(() => {
const roleScope = requestRoleIsAdmin ? 'admin' : 'user';
const userScope = username?.trim().toLowerCase() || 'anonymous';
return `activity-dismissed-requests:${roleScope}:${userScope}`;
}, [requestRoleIsAdmin, username]);
const [dismissedRequestIds, setDismissedRequestIds] = useState<number[]>([]);
useEffect(() => {
if (!isAuthenticated) {
setDismissedRequestIds([]);
return;
}
try {
const raw = window.localStorage.getItem(dismissedRequestStorageKey);
if (!raw) {
setDismissedRequestIds([]);
return;
}
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) {
setDismissedRequestIds([]);
return;
}
const ids = parsed.filter((value): value is number => typeof value === 'number' && Number.isFinite(value));
setDismissedRequestIds(ids);
} catch {
setDismissedRequestIds([]);
}
}, [dismissedRequestStorageKey, isAuthenticated]);
useEffect(() => {
if (!isAuthenticated) {
return;
}
try {
window.localStorage.setItem(dismissedRequestStorageKey, JSON.stringify(dismissedRequestIds));
} catch {
// Ignore storage failures in restricted/private contexts.
}
}, [dismissedRequestIds, dismissedRequestStorageKey, isAuthenticated]);
const requestItems = useMemo(
() =>
requests
.filter((record) => !dismissedRequestIds.includes(record.id))
.map((record) => requestToActivityItem(record, requestRoleIsAdmin ? 'admin' : 'user'))
.sort((left, right) => right.timestamp - left.timestamp),
[requests, requestRoleIsAdmin, dismissedRequestIds]
);
const {
requestItems,
dismissedActivityKeys,
historyItems,
pendingRequestCount,
isActivitySnapshotLoading,
activityHistoryLoading,
activityHistoryHasMore,
refreshActivitySnapshot,
resetActivity,
handleActivityTabChange,
handleActivityHistoryLoadMore,
handleRequestDismiss,
handleDownloadDismiss,
handleClearCompleted,
handleClearHistory,
} = useActivity({
isAuthenticated,
isAdmin: requestRoleIsAdmin,
showToast,
socket,
});
const showRequestsTab = useMemo(() => {
if (requestRoleIsAdmin) {
@@ -317,7 +286,10 @@ function App() {
clearTracking();
setPendingRequestPayload(null);
setFulfillingRequest(null);
}, [handleLogout, setBooks, clearTracking]);
resetActivity();
setSettingsOpen(false);
setSelfSettingsOpen(false);
}, [handleLogout, setBooks, clearTracking, resetActivity]);
// UI state
const [selectedBook, setSelectedBook] = useState<Book | null>(null);
@@ -341,6 +313,7 @@ function App() {
headerObserverRef.current = observer;
}, []);
const [settingsOpen, setSettingsOpen] = useState(false);
const [selfSettingsOpen, setSelfSettingsOpen] = useState(false);
const [configBannerOpen, setConfigBannerOpen] = useState(false);
const [onboardingOpen, setOnboardingOpen] = useState(false);
@@ -363,26 +336,45 @@ function App() {
// Calculate status counts for header badges (memoized)
const statusCounts = useMemo(() => {
const dismissedKeySet = new Set(dismissedActivityKeys);
const countVisibleDownloads = (
bucket: Record<string, Book> | undefined,
options: { filterDismissed: boolean }
): number => {
const { filterDismissed } = options;
if (!bucket) {
return 0;
}
if (!filterDismissed) {
return Object.keys(bucket).length;
}
return Object.keys(bucket).filter((taskId) => !dismissedKeySet.has(`download:${taskId}`)).length;
};
const ongoing = [
currentStatus.queued,
currentStatus.resolving,
currentStatus.locating,
currentStatus.downloading,
].reduce((sum, status) => sum + (status ? Object.keys(status).length : 0), 0);
].reduce((sum, status) => sum + countVisibleDownloads(status, { filterDismissed: false }), 0);
const completed = currentStatus.complete
? Object.keys(currentStatus.complete).length
: 0;
const errored = currentStatus.error ? Object.keys(currentStatus.error).length : 0;
const completed = countVisibleDownloads(currentStatus.complete, { filterDismissed: true });
const errored = countVisibleDownloads(currentStatus.error, { filterDismissed: true });
const pendingVisibleRequests = requestItems.filter((item) => {
const requestId = item.requestId;
if (!requestId || item.requestRecord?.status !== 'pending') {
return false;
}
return !dismissedKeySet.has(`request:${requestId}`);
}).length;
return {
ongoing,
completed,
errored,
pendingRequests: pendingRequestCount,
pendingRequests: pendingVisibleRequests,
};
}, [currentStatus, pendingRequestCount]);
}, [currentStatus, dismissedActivityKeys, requestItems]);
// Compute visibility states
@@ -651,6 +643,7 @@ function App() {
async (payload: CreateRequestPayload, successMessage: string): Promise<boolean> => {
try {
await createRequest(payload);
await refreshActivitySnapshot();
showToast(successMessage, 'success');
await refreshRequestPolicy({ force: true });
return true;
@@ -663,7 +656,7 @@ function App() {
return false;
}
},
[showToast, refreshRequestPolicy]
[showToast, refreshRequestPolicy, refreshActivitySnapshot]
);
const openRequestConfirmation = useCallback((payload: CreateRequestPayload) => {
@@ -768,17 +761,6 @@ function App() {
}
};
// Clear completed
const handleClearCompleted = async () => {
try {
await clearCompleted();
await fetchStatus();
} catch (error) {
console.error('Clear completed failed:', error);
showToast('Failed to clear finished downloads', 'error');
}
};
// Universal-mode "Get" action (open releases, request-book, or block by policy).
const handleGetReleases = async (book: Book) => {
let mode = getUniversalDefaultPolicyMode();
@@ -969,20 +951,15 @@ function App() {
async (requestId: number) => {
try {
await cancelUserRequest(requestId);
await refreshActivitySnapshot();
showToast('Request cancelled', 'success');
} catch (error) {
showToast(getErrorMessage(error, 'Failed to cancel request'), 'error');
}
},
[cancelUserRequest, showToast]
[cancelUserRequest, refreshActivitySnapshot, showToast]
);
const handleRequestDismiss = useCallback((requestId: number) => {
setDismissedRequestIds((previous) =>
previous.includes(requestId) ? previous : [...previous, requestId]
);
}, []);
const handleRequestReject = useCallback(
async (requestId: number, adminNote?: string) => {
if (!requestRoleIsAdmin) {
@@ -991,12 +968,13 @@ function App() {
try {
await rejectSidebarRequest(requestId, adminNote);
await refreshActivitySnapshot();
showToast('Request rejected', 'success');
} catch (error) {
showToast(getErrorMessage(error, 'Failed to reject request'), 'error');
}
},
[requestRoleIsAdmin, rejectSidebarRequest, showToast]
[refreshActivitySnapshot, requestRoleIsAdmin, rejectSidebarRequest, showToast]
);
const handleRequestApprove = useCallback(
@@ -1008,6 +986,7 @@ function App() {
if (record.request_level === 'release') {
try {
await fulfilSidebarRequest(requestId, record.release_data || undefined);
await refreshActivitySnapshot();
showToast('Request approved', 'success');
await fetchStatus();
} catch (error) {
@@ -1023,7 +1002,7 @@ function App() {
contentType: record.content_type,
});
},
[requestRoleIsAdmin, fulfilSidebarRequest, showToast, fetchStatus]
[requestRoleIsAdmin, fulfilSidebarRequest, showToast, fetchStatus, refreshActivitySnapshot]
);
const handleBrowseFulfilDownload = useCallback(
@@ -1037,6 +1016,7 @@ function App() {
fulfillingRequest.requestId,
buildReleaseDataFromMetadataRelease(book, release, toContentType(releaseContentType))
);
await refreshActivitySnapshot();
showToast(`Request approved: ${book.title || 'Untitled'}`, 'success');
setFulfillingRequest(null);
await fetchStatus();
@@ -1046,7 +1026,7 @@ function App() {
throw error;
}
},
[fulfillingRequest, fulfilSidebarRequest, showToast, fetchStatus]
[fulfillingRequest, fulfilSidebarRequest, showToast, fetchStatus, refreshActivitySnapshot]
);
const getDirectActionButtonState = useCallback(
@@ -1128,13 +1108,17 @@ function App() {
onDownloadsClick={() => setDownloadsSidebarOpen((prev) => !prev)}
onSettingsClick={() => {
if (config?.settings_enabled) {
setSettingsOpen(true);
if (authIsAdmin) {
setSettingsOpen(true);
} else {
setSelfSettingsOpen(true);
}
} else {
setConfigBannerOpen(true);
}
}}
isAdmin={requestRoleIsAdmin}
canAccessSettings={authCanAccessSettings}
canAccessSettings={isAuthenticated}
username={username}
displayName={displayName}
statusCounts={statusCounts}
@@ -1176,6 +1160,7 @@ function App() {
bottom: 0,
left: 0,
right: '25rem',
zIndex: 40,
}
: { paddingTop: `${headerHeight}px` }
}
@@ -1306,10 +1291,18 @@ function App() {
isAdmin={requestRoleIsAdmin}
onClearCompleted={handleClearCompleted}
onCancel={handleCancel}
onDownloadDismiss={handleDownloadDismiss}
requestItems={requestItems}
dismissedItemKeys={dismissedActivityKeys}
historyItems={historyItems}
historyHasMore={activityHistoryHasMore}
historyLoading={activityHistoryLoading}
onHistoryLoadMore={handleActivityHistoryLoadMore}
onClearHistory={handleClearHistory}
onActiveTabChange={handleActivityTabChange}
pendingRequestCount={pendingRequestCount}
showRequestsTab={showRequestsTab}
isRequestsLoading={isRequestsLoading}
isRequestsLoading={isRequestsLoading || isActivitySnapshotLoading}
onRequestCancel={showRequestsTab ? handleRequestCancel : undefined}
onRequestApprove={requestRoleIsAdmin ? handleRequestApprove : undefined}
onRequestReject={requestRoleIsAdmin ? handleRequestReject : undefined}
@@ -1328,6 +1321,12 @@ function App() {
onSettingsSaved={handleSettingsSaved}
/>
<SelfSettingsModal
isOpen={selfSettingsOpen}
onClose={() => setSelfSettingsOpen(false)}
onShowToast={showToast}
/>
{/* Auto-show banner on startup for users without config */}
{config && (
<ConfigSetupBanner settingsEnabled={config.settings_enabled} />
@@ -1339,7 +1338,11 @@ function App() {
onClose={() => setConfigBannerOpen(false)}
onContinue={() => {
setConfigBannerOpen(false);
setSettingsOpen(true);
if (authIsAdmin) {
setSettingsOpen(true);
} else {
setSelfSettingsOpen(true);
}
}}
/>

View File

@@ -14,6 +14,7 @@ interface ActivityCardProps {
item: ActivityItem;
isAdmin: boolean;
onDownloadCancel?: (bookId: string) => void;
onDownloadDismiss?: (bookId: string, linkedRequestId?: number) => void;
onRequestCancel?: (requestId: number) => void;
onRequestApprove?: (requestId: number, record: RequestRecord) => void;
onRequestReject?: (requestId: number) => void;
@@ -145,6 +146,7 @@ export const ActivityCard = ({
item,
isAdmin,
onDownloadCancel,
onDownloadDismiss,
onRequestCancel,
onRequestApprove,
onRequestReject,
@@ -205,10 +207,7 @@ export const ActivityCard = ({
onDownloadCancel?.(action.bookId);
break;
case 'download-dismiss':
onDownloadCancel?.(action.bookId);
if (action.linkedRequestId) {
onRequestDismiss?.(action.linkedRequestId);
}
onDownloadDismiss?.(action.bookId, action.linkedRequestId);
break;
case 'request-approve':
onRequestApprove?.(action.requestId, action.record);
@@ -231,8 +230,9 @@ export const ActivityCard = ({
switch (action.kind) {
case 'download-remove':
case 'download-stop':
case 'download-dismiss':
return Boolean(onDownloadCancel);
case 'download-dismiss':
return Boolean(onDownloadDismiss);
case 'request-approve':
return Boolean(onRequestApprove);
case 'request-reject':
@@ -309,7 +309,7 @@ export const ActivityCard = ({
</div>
</div>
<p className="text-xs opacity-60 truncate mt-0.5" title={item.metaLine}>
<p className="text-[11px] leading-tight opacity-60 truncate mt-0.5" title={item.metaLine}>
{item.metaLine}
</p>

View File

@@ -4,15 +4,24 @@ import { downloadToActivityItem, DownloadStatusKey } from './activityMappers';
import { ActivityItem } from './activityTypes';
import { ActivityCard } from './ActivityCard';
import { RejectDialog } from './RejectDialog';
import { Dropdown } from '../Dropdown';
interface ActivitySidebarProps {
isOpen: boolean;
onClose: () => void;
status: StatusData;
isAdmin: boolean;
onClearCompleted: () => void;
onClearCompleted: (items: ActivityDismissTarget[]) => void;
onCancel: (id: string) => void;
onDownloadDismiss?: (bookId: string, linkedRequestId?: number) => void;
requestItems: ActivityItem[];
dismissedItemKeys?: string[];
historyItems?: ActivityItem[];
historyHasMore?: boolean;
historyLoading?: boolean;
onHistoryLoadMore?: () => void;
onClearHistory?: () => void;
onActiveTabChange?: (tab: ActivityTabKey) => void;
pendingRequestCount: number;
showRequestsTab: boolean;
isRequestsLoading?: boolean;
@@ -24,6 +33,11 @@ interface ActivitySidebarProps {
pinnedTopOffset?: number;
}
export interface ActivityDismissTarget {
itemType: 'download' | 'request';
itemKey: string;
}
export const ACTIVITY_SIDEBAR_PINNED_STORAGE_KEY = 'activity-sidebar-pinned';
const DOWNLOAD_STATUS_KEYS: DownloadStatusKey[] = [
@@ -37,53 +51,95 @@ const DOWNLOAD_STATUS_KEYS: DownloadStatusKey[] = [
];
type ActivityCategoryKey =
| 'downloads'
| 'pending_requests'
| 'fulfilled_requests'
| 'other_requests';
| 'needs_review'
| 'in_progress'
| 'complete'
| 'failed';
type ActivityTabKey = 'all' | 'downloads' | 'requests' | 'history';
const ALL_USERS_FILTER = '__all_users__';
const getCategoryLabel = (
key: ActivityCategoryKey,
isAdmin: boolean
): string => {
if (key === 'downloads') {
return 'Downloads';
if (key === 'needs_review') {
return isAdmin ? 'Needs Review' : 'Waiting';
}
if (key === 'pending_requests') {
return 'Pending Requests';
if (key === 'in_progress') {
return 'In Progress';
}
if (key === 'fulfilled_requests') {
return isAdmin ? 'Fulfilled Requests' : 'Completed Requests';
if (key === 'complete') {
return 'Complete';
}
return 'Other Requests';
return 'Failed';
};
const getVisibleCategoryOrder = (
tab: 'all' | 'downloads' | 'requests'
tab: ActivityTabKey
): ActivityCategoryKey[] => {
if (tab === 'downloads') {
return ['downloads'];
return ['in_progress', 'complete', 'failed'];
}
if (tab === 'requests') {
return ['pending_requests', 'fulfilled_requests', 'other_requests'];
return ['needs_review', 'in_progress', 'complete', 'failed'];
}
return ['downloads', 'pending_requests', 'fulfilled_requests', 'other_requests'];
if (tab === 'history') {
return [];
}
return ['needs_review', 'in_progress', 'complete', 'failed'];
};
const getActivityCategory = (item: ActivityItem): ActivityCategoryKey => {
if (!item.requestId) {
return 'downloads';
if (item.kind === 'download') {
if (
item.visualStatus === 'queued' ||
item.visualStatus === 'resolving' ||
item.visualStatus === 'locating' ||
item.visualStatus === 'downloading'
) {
return 'in_progress';
}
if (item.visualStatus === 'complete') {
return 'complete';
}
return 'failed';
}
if (item.requestRecord?.status === 'pending' || item.visualStatus === 'pending') {
return 'pending_requests';
const requestStatus = item.requestRecord?.status;
if (requestStatus === 'pending' || item.visualStatus === 'pending') {
return 'needs_review';
}
if (item.requestRecord?.status === 'fulfilled' || item.visualStatus === 'fulfilled') {
return 'fulfilled_requests';
if (requestStatus === 'rejected' || requestStatus === 'cancelled') {
return 'failed';
}
return 'other_requests';
const deliveryState = item.requestRecord?.delivery_state;
if (requestStatus === 'fulfilled' || item.visualStatus === 'fulfilled') {
if (
deliveryState === 'queued' ||
deliveryState === 'resolving' ||
deliveryState === 'locating' ||
deliveryState === 'downloading'
) {
return 'in_progress';
}
if (deliveryState === 'error' || deliveryState === 'cancelled') {
return 'failed';
}
// Legacy fulfilled requests often have unknown/none delivery state because the
// pre-refactor queue state was ephemeral. Treat as completed approval, not in-progress.
return 'complete';
}
if (deliveryState === 'complete') {
return 'complete';
}
if (deliveryState === 'error' || deliveryState === 'cancelled') {
return 'failed';
}
return 'in_progress';
};
const getLinkedDownloadIdFromRequestItem = (item: ActivityItem): string | null => {
@@ -138,6 +194,15 @@ const dedupeById = (items: ActivityItem[]): ActivityItem[] => {
return Array.from(byId.values());
};
const getItemUsername = (item: ActivityItem): string | null => {
const candidate = item.username || item.requestRecord?.username;
if (typeof candidate !== 'string') {
return null;
}
const normalized = candidate.trim();
return normalized || null;
};
const parsePinned = (value: string | null): boolean => {
if (!value) {
return false;
@@ -170,7 +235,15 @@ export const ActivitySidebar = ({
isAdmin,
onClearCompleted,
onCancel,
onDownloadDismiss,
requestItems,
dismissedItemKeys = [],
historyItems = [],
historyHasMore = false,
historyLoading = false,
onHistoryLoadMore,
onClearHistory,
onActiveTabChange,
pendingRequestCount,
showRequestsTab,
isRequestsLoading = false,
@@ -183,10 +256,15 @@ export const ActivitySidebar = ({
}: ActivitySidebarProps) => {
const [isPinned, setIsPinned] = useState<boolean>(() => getInitialPinnedPreference());
const [isDesktop, setIsDesktop] = useState<boolean>(() => getInitialDesktopState());
const [activeTab, setActiveTab] = useState<'all' | 'downloads' | 'requests'>('all');
const [activeTab, setActiveTab] = useState<ActivityTabKey>('all');
const [selectedUser, setSelectedUser] = useState<string>(ALL_USERS_FILTER);
const [rejectingRequest, setRejectingRequest] = useState<{ requestId: number; bookTitle: string } | null>(null);
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
const scrollViewportRef = useRef<HTMLDivElement | null>(null);
const dismissedKeySet = useMemo(
() => new Set(dismissedItemKeys),
[dismissedItemKeys]
);
useEffect(() => {
const mediaQuery = window.matchMedia('(min-width: 1024px)');
@@ -209,6 +287,10 @@ export const ActivitySidebar = ({
}
}, [showRequestsTab, activeTab]);
useEffect(() => {
onActiveTabChange?.(activeTab);
}, [activeTab, onActiveTabChange]);
useEffect(() => {
if (activeTab === 'downloads') {
setRejectingRequest(null);
@@ -245,12 +327,29 @@ export const ActivitySidebar = ({
return;
}
Object.values(bucket).forEach((book) => {
const itemKey = `download:${book.id}`;
const isTerminalStatus =
statusKey === 'complete' || statusKey === 'error' || statusKey === 'cancelled';
if (isTerminalStatus && dismissedKeySet.has(itemKey)) {
return;
}
items.push(downloadToActivityItem(book, statusKey));
});
});
return items.sort((left, right) => right.timestamp - left.timestamp);
}, [status]);
}, [dismissedKeySet, status]);
const visibleRequestItems = useMemo(
() =>
requestItems.filter((item) => {
if (!item.requestId) {
return true;
}
return !dismissedKeySet.has(`request:${item.requestId}`);
}),
[dismissedKeySet, requestItems]
);
const { mergedRequestItems, mergedDownloadItems } = useMemo(() => {
const downloadsById = new Map<string, ActivityItem>();
@@ -261,7 +360,7 @@ export const ActivitySidebar = ({
});
const mergedByDownloadId = new Map<string, ActivityItem>();
const nextRequestItems = requestItems.map((requestItem) => {
const nextRequestItems = visibleRequestItems.map((requestItem) => {
const linkedDownloadId = getLinkedDownloadIdFromRequestItem(requestItem);
if (!linkedDownloadId) {
return requestItem;
@@ -291,7 +390,7 @@ export const ActivitySidebar = ({
mergedRequestItems: nextRequestItems,
mergedDownloadItems: nextDownloadItems,
};
}, [downloadItems, requestItems]);
}, [downloadItems, visibleRequestItems]);
const hasTerminalDownloadItems = useMemo(
() =>
@@ -307,11 +406,84 @@ export const ActivitySidebar = ({
return combined.sort((a, b) => b.timestamp - a.timestamp);
}, [mergedDownloadItems, mergedRequestItems]);
const visibleItems = activeTab === 'all'
const baseVisibleItems = activeTab === 'all'
? allItems
: activeTab === 'requests'
? mergedRequestItems
: mergedDownloadItems;
? mergedRequestItems.filter((item) => {
const requestStatus = item.requestRecord?.status;
if (requestStatus === 'pending' || requestStatus === 'rejected' || requestStatus === 'cancelled') {
return true;
}
return requestStatus === 'fulfilled' && item.kind === 'request';
})
: activeTab === 'history'
? historyItems
: mergedDownloadItems;
const availableUsers = useMemo(() => {
const userMap = new Map<string, string>();
baseVisibleItems.forEach((item) => {
const username = getItemUsername(item);
if (!username) {
return;
}
const lookupKey = username.toLowerCase();
if (!userMap.has(lookupKey)) {
userMap.set(lookupKey, username);
}
});
return Array.from(userMap.values()).sort((left, right) => left.localeCompare(right));
}, [baseVisibleItems]);
useEffect(() => {
if (selectedUser === ALL_USERS_FILTER) {
return;
}
if (!availableUsers.includes(selectedUser)) {
setSelectedUser(ALL_USERS_FILTER);
}
}, [availableUsers, selectedUser]);
const visibleItems = useMemo(() => {
if (selectedUser === ALL_USERS_FILTER) {
return baseVisibleItems;
}
return baseVisibleItems.filter((item) => getItemUsername(item) === selectedUser);
}, [baseVisibleItems, selectedUser]);
const hasUserFilter = isAdmin && availableUsers.length > 1;
const clearCompletedTargets = useMemo(() => {
const targets: ActivityDismissTarget[] = [];
const seen = new Set<string>();
visibleItems.forEach((item) => {
const isTerminalDownload =
item.kind === 'download' &&
(item.visualStatus === 'complete' || item.visualStatus === 'error' || item.visualStatus === 'cancelled');
if (!isTerminalDownload || !item.downloadBookId) {
return;
}
const downloadKey = `download:${item.downloadBookId}`;
if (!seen.has(downloadKey)) {
seen.add(downloadKey);
targets.push({ itemType: 'download', itemKey: downloadKey });
}
if (item.requestId) {
const requestKey = `request:${item.requestId}`;
if (!seen.has(requestKey)) {
seen.add(requestKey);
targets.push({ itemType: 'request', itemKey: requestKey });
}
}
});
return targets;
}, [visibleItems]);
const visibleCategoryOrder = useMemo(
() => getVisibleCategoryOrder(activeTab),
@@ -319,11 +491,15 @@ export const ActivitySidebar = ({
);
const groupedVisibleItems = useMemo(() => {
if (activeTab === 'history') {
return [];
}
const grouped = new Map<ActivityCategoryKey, ActivityItem[]>();
visibleCategoryOrder.forEach((key) => grouped.set(key, []));
visibleItems.forEach((item) => {
const category = activeTab === 'downloads' ? 'downloads' : getActivityCategory(item);
const category = getActivityCategory(item);
if (!grouped.has(category)) {
grouped.set(category, []);
}
@@ -355,31 +531,33 @@ export const ActivitySidebar = ({
useEffect(() => {
const activeButton = tabRefs.current[activeTab];
if (activeButton) {
const containerRect = activeButton.parentElement?.getBoundingClientRect();
const buttonRect = activeButton.getBoundingClientRect();
if (containerRect) {
setTabIndicatorStyle({
left: buttonRect.left - containerRect.left,
width: buttonRect.width,
});
}
if (!activeButton) {
setTabIndicatorStyle({ left: 0, width: 0 });
return;
}
const containerRect = activeButton.parentElement?.getBoundingClientRect();
const buttonRect = activeButton.getBoundingClientRect();
if (containerRect) {
setTabIndicatorStyle({
left: buttonRect.left - containerRect.left,
width: buttonRect.width,
});
}
}, [activeTab, showRequestsTab]);
const panel = (
<>
<div
className={`px-4 pt-4 ${showRequestsTab ? 'pb-0' : 'pb-4 border-b'}`}
className="px-4 pt-4 pb-0"
style={{
borderColor: 'var(--border-muted)',
paddingTop: 'calc(1rem + env(safe-area-inset-top))',
}}
>
<div className="flex items-center justify-between gap-2">
<h2 className="text-lg font-semibold">Activity</h2>
<div className="flex items-center gap-1">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">{activeTab === 'history' ? 'History' : 'Activity'}</h2>
<button
type="button"
onClick={handleTogglePinned}
@@ -392,25 +570,96 @@ export const ActivitySidebar = ({
<path d="M15.804 2.276a.75.75 0 0 0-.336.195l-2 2a.75.75 0 0 0 0 1.062l.47.469-3.572 3.571c-.83-.534-1.773-.808-2.709-.691-1.183.148-2.32.72-3.187 1.587a.75.75 0 0 0 0 1.063L7.938 15l-5.467 5.467a.75.75 0 0 0 0 1.062.75.75 0 0 0 1.062 0L9 16.062l3.468 3.468a.75.75 0 0 0 1.062 0c.868-.868 1.44-2.004 1.588-3.187.117-.935-.158-1.879-.692-2.708L18 10.063l.469.469a.75.75 0 0 0 1.062 0l2-2a.75.75 0 0 0 0-1.062l-5-4.999a.75.75 0 0 0-.726-.195z" />
</svg>
) : (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="m9 15-6 6M15 6l-1-1 2-2 5 5-2 2-1-1-4.5 4.5c1.5 1.5 1 4-.5 5.5l-8-8c1.5-1.5 4-2 5.5-.5z" />
</svg>
)}
</button>
</div>
<div className="flex items-center gap-1">
{hasUserFilter && (
<Dropdown
align="right"
widthClassName="w-auto"
panelClassName="min-w-[11rem]"
renderTrigger={({ isOpen, toggle }) => (
<button
type="button"
onClick={toggle}
className={`h-9 w-9 inline-flex items-center justify-center rounded-full hover-action transition-colors ${
isOpen || selectedUser !== ALL_USERS_FILTER ? 'text-sky-600 dark:text-sky-400' : ''
}`}
title={selectedUser === ALL_USERS_FILTER ? 'Filter by user' : `Filtered: ${selectedUser}`}
aria-label={selectedUser === ALL_USERS_FILTER ? 'Filter by user' : `Filtered by user ${selectedUser}`}
aria-expanded={isOpen}
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" strokeWidth="1.75" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 0 1-.659 1.591l-5.432 5.432a2.25 2.25 0 0 0-.659 1.591v2.927a2.25 2.25 0 0 1-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 0 0-.659-1.591L3.659 7.409A2.25 2.25 0 0 1 3 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0 1 12 3Z" />
</svg>
</button>
)}
>
{({ close }) => (
<div role="listbox">
{[ALL_USERS_FILTER, ...availableUsers].map((value) => {
const isSelected = selectedUser === value;
const label = value === ALL_USERS_FILTER ? 'All users' : value;
return (
<button
type="button"
key={value}
className={`w-full px-3 py-2 text-left text-sm hover-surface flex items-center justify-between ${
isSelected ? 'text-sky-600 dark:text-sky-400' : ''
}`}
onClick={() => {
setSelectedUser(value);
close();
}}
>
<span>{label}</span>
{isSelected && (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="m5 13 4 4L19 7" />
</svg>
)}
</button>
);
})}
</div>
)}
</Dropdown>
)}
<button
type="button"
onClick={() => setActiveTab((current) => (current === 'history' ? 'all' : 'history'))}
className={`relative h-9 w-9 inline-flex items-center justify-center rounded-full hover-action transition-colors ${
activeTab === 'history' ? 'text-sky-600 dark:text-sky-400' : ''
}`}
title={activeTab === 'history' ? 'Back to activity' : 'Open history'}
aria-label={activeTab === 'history' ? 'Back to activity' : 'Open history'}
aria-pressed={activeTab === 'history'}
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6l3.75 2.25" />
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3v4.5h4.5" />
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 12a8.25 8.25 0 1 0 3.37-6.63" />
</svg>
</button>
<button
type="button"
onClick={onClose}
className="h-9 w-9 inline-flex items-center justify-center rounded-full hover-action transition-colors"
aria-label="Close activity sidebar"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{showRequestsTab && (
{activeTab !== 'history' && (
<div className="mt-2 border-b border-[var(--border-muted)] -mx-4 px-4">
<div className="relative flex gap-1">
{/* Sliding indicator */}
@@ -452,24 +701,26 @@ export const ActivitySidebar = ({
</span>
)}
</button>
<button
type="button"
ref={(el) => { tabRefs.current.requests = el; }}
onClick={() => setActiveTab('requests')}
className={`px-4 py-2.5 text-sm font-medium border-b-2 border-transparent transition-colors whitespace-nowrap ${
activeTab === 'requests'
? 'text-sky-600 dark:text-sky-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
}`}
aria-current={activeTab === 'requests' ? 'page' : undefined}
>
Requests
{pendingRequestCount > 0 && (
<span className="ml-1.5 text-[11px] h-[18px] min-w-[18px] px-1 rounded-full bg-amber-500/15 text-amber-700 dark:text-amber-300 inline-flex items-center justify-center leading-none">
{pendingRequestCount}
</span>
)}
</button>
{showRequestsTab && (
<button
type="button"
ref={(el) => { tabRefs.current.requests = el; }}
onClick={() => setActiveTab('requests')}
className={`px-4 py-2.5 text-sm font-medium border-b-2 border-transparent transition-colors whitespace-nowrap ${
activeTab === 'requests'
? 'text-sky-600 dark:text-sky-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
}`}
aria-current={activeTab === 'requests' ? 'page' : undefined}
>
Requests
{pendingRequestCount > 0 && (
<span className="ml-1.5 text-[11px] h-[18px] min-w-[18px] px-1 rounded-full bg-amber-500/15 text-amber-700 dark:text-amber-300 inline-flex items-center justify-center leading-none">
{pendingRequestCount}
</span>
)}
</button>
)}
</div>
</div>
)}
@@ -484,11 +735,36 @@ export const ActivitySidebar = ({
<p className="text-center text-sm opacity-70 mt-8">
{activeTab === 'requests'
? isRequestsLoading ? 'Loading requests...' : 'No requests'
: activeTab === 'history'
? historyLoading ? 'Loading history...' : 'No history'
: activeTab === 'downloads'
? 'No downloads'
: 'No activity'}
</p>
) : (
activeTab === 'history' ? (
<div className="divide-y divide-[color-mix(in_srgb,var(--border-muted)_60%,transparent)]">
{visibleItems.map((item) => (
<ActivityCard
key={item.id}
item={item}
isAdmin={isAdmin}
/>
))}
{historyHasMore && (
<div className="pt-3 text-center">
<button
type="button"
onClick={onHistoryLoadMore}
disabled={historyLoading}
className="text-sm text-sky-600 dark:text-sky-400 hover:underline disabled:opacity-60"
>
{historyLoading ? 'Loading...' : 'Load more'}
</button>
</div>
)}
</div>
) : (
groupedVisibleItems.map((group) => (
<section key={group.key} className="mb-4 last:mb-0">
{activeTab !== 'downloads' && (
@@ -527,6 +803,7 @@ export const ActivitySidebar = ({
item={item}
isAdmin={isAdmin}
onDownloadCancel={onCancel}
onDownloadDismiss={onDownloadDismiss}
onRequestCancel={onRequestCancel}
onRequestApprove={onRequestApprove}
onRequestDismiss={onRequestDismiss}
@@ -554,10 +831,11 @@ export const ActivitySidebar = ({
)}
</section>
))
)
)}
</div>
{(activeTab === 'downloads' || activeTab === 'all') && hasTerminalDownloadItems && (
{(activeTab === 'downloads' || activeTab === 'all') && hasTerminalDownloadItems && clearCompletedTargets.length > 0 && (
<div
className="p-3 border-t flex items-center justify-center"
style={{
@@ -567,13 +845,31 @@ export const ActivitySidebar = ({
>
<button
type="button"
onClick={onClearCompleted}
onClick={() => onClearCompleted(clearCompletedTargets)}
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
>
Clear Completed
</button>
</div>
)}
{activeTab === 'history' && historyItems.length > 0 && (
<div
className="p-3 border-t flex items-center justify-center"
style={{
borderColor: 'var(--border-muted)',
paddingBottom: 'calc(0.75rem + env(safe-area-inset-bottom))',
}}
>
<button
type="button"
onClick={onClearHistory}
className="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
>
Clear History
</button>
</div>
)}
</>
);

View File

@@ -53,10 +53,10 @@ const toRequestVisualStatus = (status: RequestRecord['status']): ActivityVisualS
const getPendingRequestText = (item: ActivityItem, isAdmin: boolean): string => {
if (!isAdmin) {
return 'Pending';
return 'Awaiting review';
}
const username = item.username?.trim() || item.requestRecord?.username?.trim();
return username ? `Requested by ${username}` : 'Requested';
return username ? `Needs review · ${username}` : 'Needs review';
};
const getRequestBadge = (item: ActivityItem, isAdmin: boolean): ActivityCardBadge => {
@@ -72,15 +72,15 @@ const getRequestBadge = (item: ActivityItem, isAdmin: boolean): ActivityCardBadg
let text = item.statusLabel;
if (hasInFlightLinkedDownload) {
text = isAdmin ? 'Request approved' : 'Approved';
text = 'Approved';
} else if (requestVisualStatus === 'pending') {
text = getPendingRequestText(item, isAdmin);
} else if (requestVisualStatus === 'fulfilled') {
text = isAdmin ? 'Request fulfilled' : 'Approved';
text = 'Approved';
} else if (requestVisualStatus === 'rejected') {
text = 'Rejected';
text = isAdmin ? 'Declined' : 'Not approved';
} else if (requestVisualStatus === 'cancelled') {
text = 'Cancelled';
text = isAdmin ? 'Cancelled by requester' : 'Cancelled';
}
return {
@@ -109,6 +109,10 @@ const getDownloadBadge = (item: ActivityItem): ActivityCardBadge => {
};
const buildBadges = (item: ActivityItem, isAdmin: boolean): ActivityCardBadge[] => {
if (item.kind === 'download' && item.visualStatus === 'complete') {
return [getDownloadBadge(item)];
}
if (item.kind === 'download' && item.requestId && item.requestRecord) {
return [getRequestBadge(item, isAdmin), getDownloadBadge(item)];
}

View File

@@ -11,3 +11,4 @@ export {
} from './activityStyles';
export type { ActivityItem, ActivityKind, ActivityVisualStatus } from './activityTypes';
export { ACTIVITY_SIDEBAR_PINNED_STORAGE_KEY } from './ActivitySidebar';
export type { ActivityDismissTarget } from './ActivitySidebar';

View File

@@ -0,0 +1,357 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
AdminUser,
DeliveryPreferencesResponse,
getSelfUserEditContext,
updateSelfUser,
} from '../../services/api';
import { SelectField } from './fields';
import { FieldWrapper } from './shared';
import { UserAccountCardContent, UserEditActions, UserIdentityHeader } from './users/UserCard';
import { UserOverridesSection } from './users/UserOverridesSection';
import { buildUserSettingsPayload } from './users/settingsPayload';
import { PerUserSettings } from './users/types';
import { getStoredThemePreference, setThemePreference, THEME_FIELD } from '../../utils/themePreference';
interface SelfSettingsModalProps {
isOpen: boolean;
onClose: () => void;
onShowToast?: (message: string, type: 'success' | 'error' | 'info') => void;
}
const MIN_PASSWORD_LENGTH = 4;
const normalizeUserSettings = (settings: PerUserSettings): PerUserSettings => {
const normalized: PerUserSettings = {};
Object.keys(settings).sort().forEach((key) => {
const typedKey = key as keyof PerUserSettings;
const value = settings[typedKey];
if (value !== null && value !== undefined) {
normalized[typedKey] = value;
}
});
return normalized;
};
const getPasswordError = (password: string, passwordConfirm: string): string | null => {
if (!password && !passwordConfirm) {
return null;
}
if (!password) {
return 'Password is required';
}
if (password.length < MIN_PASSWORD_LENGTH) {
return `Password must be at least ${MIN_PASSWORD_LENGTH} characters`;
}
return password === passwordConfirm ? null : 'Passwords do not match';
};
const getErrorMessage = (error: unknown, fallback: string): string => {
if (error instanceof Error && error.message) {
return error.message;
}
return fallback;
};
export const SelfSettingsModal = ({ isOpen, onClose, onShowToast }: SelfSettingsModalProps) => {
const [isClosing, setIsClosing] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [editingUser, setEditingUser] = useState<AdminUser | null>(null);
const [originalUser, setOriginalUser] = useState<AdminUser | null>(null);
const [deliveryPreferences, setDeliveryPreferences] = useState<DeliveryPreferencesResponse | null>(null);
const [editPassword, setEditPassword] = useState('');
const [editPasswordConfirm, setEditPasswordConfirm] = useState('');
const [userSettings, setUserSettings] = useState<PerUserSettings>({});
const [originalUserSettings, setOriginalUserSettings] = useState<PerUserSettings>({});
const [userOverridableSettings, setUserOverridableSettings] = useState<Set<string>>(new Set());
const [themeValue, setThemeValue] = useState<string>(getStoredThemePreference());
const loadEditContext = useCallback(async () => {
setIsLoading(true);
setLoadError(null);
try {
const context = await getSelfUserEditContext();
const normalizedSettings = normalizeUserSettings((context.user.settings || {}) as PerUserSettings);
setEditingUser(context.user);
setOriginalUser(context.user);
setDeliveryPreferences(context.deliveryPreferences || null);
setUserSettings(normalizedSettings);
setOriginalUserSettings(normalizedSettings);
setUserOverridableSettings(new Set(context.userOverridableKeys || []));
setEditPassword('');
setEditPasswordConfirm('');
} catch (error) {
setLoadError(getErrorMessage(error, 'Failed to load account settings'));
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
if (!isOpen) {
return;
}
setIsClosing(false);
void loadEditContext();
}, [isOpen, loadEditContext]);
useEffect(() => {
if (!isOpen) {
return;
}
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, [isOpen]);
const handleClose = useCallback(() => {
if (isSaving) {
return;
}
setIsClosing(true);
setTimeout(() => {
onClose();
setIsClosing(false);
}, 150);
}, [isSaving, onClose]);
useEffect(() => {
if (!isOpen) {
return;
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleClose();
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [isOpen, handleClose]);
const isUserOverridable = useCallback(
(key: keyof PerUserSettings) => userOverridableSettings.has(String(key)),
[userOverridableSettings]
);
const currentSettingsPayload = useMemo(
() => buildUserSettingsPayload(userSettings, userOverridableSettings, deliveryPreferences),
[deliveryPreferences, userOverridableSettings, userSettings]
);
const originalSettingsPayload = useMemo(
() => buildUserSettingsPayload(originalUserSettings, userOverridableSettings, deliveryPreferences),
[deliveryPreferences, originalUserSettings, userOverridableSettings]
);
const hasSettingsChanges =
JSON.stringify(currentSettingsPayload) !== JSON.stringify(originalSettingsPayload);
const hasProfileChanges = Boolean(
editingUser
&& originalUser
&& (
editingUser.email !== originalUser.email
|| editingUser.display_name !== originalUser.display_name
)
);
const hasPasswordChanges = editPassword.length > 0 || editPasswordConfirm.length > 0;
const passwordError = getPasswordError(editPassword, editPasswordConfirm);
const hasChanges = hasSettingsChanges || hasProfileChanges || hasPasswordChanges;
const handleSave = useCallback(async () => {
if (!editingUser || !originalUser) {
return;
}
if (passwordError) {
onShowToast?.(passwordError, 'error');
return;
}
const payload: {
email?: string | null;
display_name?: string | null;
password?: string;
settings?: Record<string, unknown>;
} = {};
if (
editingUser.edit_capabilities.canEditEmail
&& editingUser.email !== originalUser.email
) {
payload.email = editingUser.email;
}
if (
editingUser.edit_capabilities.canEditDisplayName
&& editingUser.display_name !== originalUser.display_name
) {
payload.display_name = editingUser.display_name;
}
if (editingUser.edit_capabilities.canSetPassword && editPassword) {
payload.password = editPassword;
}
if (hasSettingsChanges) {
payload.settings = currentSettingsPayload;
}
if (Object.keys(payload).length === 0) {
return;
}
setIsSaving(true);
try {
await updateSelfUser(payload);
onShowToast?.('Account updated', 'success');
await loadEditContext();
} catch (error) {
onShowToast?.(getErrorMessage(error, 'Failed to update account'), 'error');
} finally {
setIsSaving(false);
}
}, [
currentSettingsPayload,
editingUser,
hasSettingsChanges,
loadEditContext,
onShowToast,
originalUser,
passwordError,
editPassword,
]);
if (!isOpen && !isClosing) {
return null;
}
const titleId = 'self-settings-modal-title';
const hasCachedEditContext = Boolean(editingUser);
const showInitialLoadingState = isLoading && !hasCachedEditContext;
const showInitialLoadErrorState = Boolean(loadError) && !hasCachedEditContext;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className={`absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity duration-150 ${isClosing ? 'opacity-0' : 'opacity-100'}`}
onClick={handleClose}
/>
<div
className={`relative w-full max-w-3xl h-[85vh] max-h-[750px] rounded-xl border border-[var(--border-muted)] shadow-2xl flex flex-col overflow-hidden ${isClosing ? 'settings-modal-exit' : 'settings-modal-enter'}`}
style={{ background: 'var(--bg)' }}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<header className="flex items-center justify-between border-b border-[var(--border-muted)] px-6 py-4">
<h3 id={titleId} className="sr-only">My Account</h3>
{editingUser ? (
<UserIdentityHeader
user={editingUser}
showAuthSource
showInactiveState={false}
/>
) : (
<div className="text-sm font-medium">My Account</div>
)}
<div className="flex items-center">
<button
type="button"
onClick={handleClose}
className="p-2 rounded-full hover-action transition-colors text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Close account settings"
disabled={isSaving}
>
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</header>
<div className="flex-1 overflow-y-auto px-6 py-5">
{showInitialLoadingState ? (
<div className="h-full flex items-center justify-center text-sm opacity-60">
Loading account settings...
</div>
) : showInitialLoadErrorState ? (
<div className="h-full flex flex-col items-center justify-center gap-3 text-center">
<p className="text-sm opacity-70">{loadError}</p>
<button
type="button"
onClick={() => { void loadEditContext(); }}
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>
) : editingUser ? (
<div className="space-y-5">
<FieldWrapper field={THEME_FIELD}>
<SelectField
field={THEME_FIELD}
value={themeValue}
onChange={(value) => {
setThemeValue(value);
setThemePreference(value);
}}
/>
</FieldWrapper>
<UserAccountCardContent
user={editingUser}
onUserChange={setEditingUser}
onSave={() => {}}
saving={isSaving}
onCancel={handleClose}
hideEditActions
editPassword={editPassword}
onEditPasswordChange={setEditPassword}
editPasswordConfirm={editPasswordConfirm}
onEditPasswordConfirmChange={setEditPasswordConfirm}
preferencesPlacement="after"
preferencesPanel={{
hideTitle: true,
children: (
<div className="space-y-5">
<UserOverridesSection
deliveryPreferences={deliveryPreferences}
isUserOverridable={isUserOverridable}
userSettings={userSettings}
setUserSettings={(updater) => setUserSettings(updater)}
/>
</div>
),
}}
/>
</div>
) : (
<div className="h-full flex items-center justify-center text-sm opacity-60">
Unable to load account details.
</div>
)}
</div>
<footer className="flex items-center justify-end gap-3 border-t border-[var(--border-muted)] px-6 py-4">
<UserEditActions
variant="modalFooter"
onSave={() => {
void handleSave();
}}
saving={isSaving}
saveDisabled={!hasChanges || isSaving || isLoading}
onCancel={handleClose}
cancelDisabled={isSaving}
/>
</footer>
</div>
</div>
);
};

View File

@@ -5,7 +5,7 @@ interface HeadingFieldProps {
}
export const HeadingField = ({ field }: HeadingFieldProps) => (
<div className="pb-1 [&:not(:first-child)]:pt-5 [&:not(:first-child)]:mt-1 [&:not(:first-child)]:border-t [&:not(:first-child)]:border-black/10 [&:not(:first-child)]:dark:border-white/10">
<div className="pb-1 [&:not(:first-child)]:pt-5 [&:not(:first-child)]:mt-1 [&:not(:first-child)]:border-t [&:not(:first-child)]:border-[var(--border-muted)]">
<h3 className="text-base font-semibold mb-1">{field.title}</h3>
{field.description && (
<p className="text-sm opacity-70">

View File

@@ -1,4 +1,5 @@
export { SettingsModal } from './SettingsModal';
export { SelfSettingsModal } from './SelfSettingsModal';
export { SettingsHeader } from './SettingsHeader';
export { SettingsSidebar } from './SettingsSidebar';
export { SettingsContent } from './SettingsContent';

View File

@@ -45,10 +45,10 @@ const formatSourceLabel = (source: string): string => {
const toRuleKey = (source: string, contentType: RequestPolicyContentType) => `${source}::${contentType}`;
const modeDescriptions: Record<RequestPolicyMode, string> = {
download: 'Direct downloads allowed.',
request_release: 'Specific release requests only.',
request_book: 'Book-level requests only.',
blocked: 'Unavailable.',
download: 'Users can download directly.',
request_release: 'Users pick a release and request it.',
request_book: 'Users can request a book, admin picks the release.',
blocked: 'Downloads and requests are blocked.',
};
export const RequestPolicyGrid = ({
@@ -328,7 +328,7 @@ export const RequestPolicyGrid = ({
) : (
<div className="px-3 py-3">
<p className="text-xs opacity-60">
Per-source overrides are available when a default is set to Download or Request Release.
Per-source settings become available when a default is set to Download or Request Release.
</p>
</div>
)}

View File

@@ -1,6 +1,9 @@
import { ReactNode } from 'react';
import { AdminUser } from '../../../services/api';
import { PasswordFieldConfig, SelectFieldConfig, SelectOption, TextFieldConfig } from '../../../types/settings';
import { DropdownList } from '../../DropdownList';
import { Tooltip } from '../../shared/Tooltip';
import { UserAuthSourceBadge } from './UserAuthSourceBadge';
import { PasswordField, SelectField, TextField } from '../fields';
import { FieldWrapper } from '../shared';
import { CreateUserFormState } from './types';
@@ -17,6 +20,11 @@ const CREATE_ROLE_OPTIONS: SelectOption[] = [
{ value: 'admin', label: 'Admin' },
];
const EDIT_ROLE_OPTIONS: SelectOption[] = [
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' },
];
const createTextField = (
key: string,
label: string,
@@ -89,6 +97,240 @@ const renderPasswordField = (
</FieldWrapper>
);
const getRoleLabel = (role: string) => role.charAt(0).toUpperCase() + role.slice(1);
const getRoleBadgeClassName = (role: string, disabled = false) => (
`inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium leading-none ${
disabled ? 'cursor-not-allowed' : ''
} ${role === 'admin' ? 'bg-sky-500/15 text-sky-600 dark:text-sky-400' : 'bg-zinc-500/10 opacity-70'}`
);
const getRoleDisabledReason = (user: AdminUser, oidcAdminGroup?: string): string => {
if (user.edit_capabilities.authSource === 'oidc') {
if (oidcAdminGroup) {
return `Role is managed by the ${oidcAdminGroup} group in your identity provider.`;
}
return 'Role is managed by OIDC group authorization.';
}
if (user.edit_capabilities.authSource === 'builtin') {
return 'Role can only be changed by admins.';
}
return 'Role is managed by the external authentication source.';
};
interface UserRoleControlProps {
user: AdminUser;
onUserChange?: (user: AdminUser) => void;
oidcAdminGroup?: string;
tooltipPosition?: 'top' | 'bottom' | 'left' | 'right';
}
export const UserRoleControl = ({
user,
onUserChange,
oidcAdminGroup,
tooltipPosition = 'bottom',
}: UserRoleControlProps) => {
const roleLabel = getRoleLabel(user.role);
const canEditRole = Boolean(onUserChange) && user.edit_capabilities.canEditRole;
const roleDisabledReason = !user.edit_capabilities.canEditRole
? getRoleDisabledReason(user, oidcAdminGroup)
: undefined;
if (canEditRole && onUserChange) {
return (
<DropdownList
options={EDIT_ROLE_OPTIONS}
value={user.role}
onChange={(value) => {
const nextRole = Array.isArray(value) ? value[0] ?? '' : value;
onUserChange({ ...user, role: nextRole });
}}
widthClassName="w-28"
buttonClassName={`!py-1 !px-2.5 !text-xs !font-medium ${
user.role === 'admin'
? '!bg-sky-500/15 !text-sky-600 dark:!text-sky-400 !border-sky-500/30'
: '!bg-zinc-500/10 !opacity-70'
}`}
/>
);
}
if (onUserChange && !user.edit_capabilities.canEditRole) {
return (
<Tooltip content={roleDisabledReason || 'Role cannot be changed'} position={tooltipPosition}>
<span className={getRoleBadgeClassName(user.role, true)}>
{roleLabel}
</span>
</Tooltip>
);
}
return (
<span className={getRoleBadgeClassName(user.role)}>
{roleLabel}
</span>
);
};
interface UserIdentityHeaderProps {
user: AdminUser;
showAuthSource?: boolean;
showInactiveState?: boolean;
}
export const UserIdentityHeader = ({
user,
showAuthSource = true,
showInactiveState = true,
}: UserIdentityHeaderProps) => {
const active = user.is_active !== false;
return (
<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-600 dark: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 min-w-0">
<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>
)}
{showAuthSource && <UserAuthSourceBadge user={user} showInactive={false} />}
</div>
<div className="text-xs opacity-50 truncate">
{user.email || 'No email'}
</div>
{showInactiveState && !active && (
<div className="text-[11px] opacity-60 truncate">
Inactive for current authentication mode
</div>
)}
</div>
</div>
);
};
interface UserEditActionsProps {
onSave: () => void;
saving: boolean;
saveDisabled?: boolean;
onCancel: () => void;
cancelDisabled?: boolean;
onDelete?: () => void;
onConfirmDelete?: () => void;
onCancelDelete?: () => void;
isDeletePending?: boolean;
deleting?: boolean;
variant?: 'card' | 'modalFooter';
}
export const UserEditActions = ({
onSave,
saving,
saveDisabled = false,
onCancel,
cancelDisabled = false,
onDelete,
onConfirmDelete,
onCancelDelete,
isDeletePending = false,
deleting = false,
variant = 'card',
}: UserEditActionsProps) => {
if (variant === 'modalFooter') {
return (
<div className="flex items-center justify-end gap-3">
<button
type="button"
onClick={onCancel}
disabled={cancelDisabled}
className="px-4 py-2 rounded-lg text-sm font-medium bg-[var(--bg-soft)] border border-[var(--border-muted)] hover-action transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
type="button"
onClick={onSave}
disabled={saveDisabled}
className="px-5 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed inline-flex items-center gap-2"
>
{saving ? (
<>
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Saving...
</>
) : (
'Save Changes'
)}
</button>
</div>
);
}
return (
<div className="flex flex-col gap-2 pt-3 border-t border-[var(--border-muted)] sm:flex-row sm:items-center">
<div className="flex flex-wrap gap-2">
<button
onClick={onSave}
disabled={saveDisabled}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
<button
onClick={onCancel}
disabled={cancelDisabled}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
Cancel
</button>
</div>
{onDelete && (
<div className="flex flex-wrap gap-2 sm:ml-auto">
{isDeletePending ? (
<>
<button
onClick={onConfirmDelete}
disabled={deleting}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{deleting ? 'Deleting...' : 'Confirm Delete'}
</button>
<button
onClick={onCancelDelete}
disabled={deleting}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
Cancel
</button>
</>
) : (
<button
onClick={onDelete}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors
border border-red-500/40 text-red-600 hover:bg-red-500/10"
>
Delete User
</button>
)}
</div>
)}
</div>
);
};
interface UserCreateCardProps {
form: CreateUserFormState;
onChange: (form: CreateUserFormState) => void;
@@ -168,6 +410,7 @@ interface UserEditFieldsProps {
onSave: () => void;
saving: boolean;
onCancel: () => void;
hideActions?: boolean;
editPassword: string;
onEditPasswordChange: (value: string) => void;
editPasswordConfirm: string;
@@ -185,6 +428,7 @@ export const UserEditFields = ({
onSave,
saving,
onCancel,
hideActions = false,
editPassword,
onEditPasswordChange,
editPasswordConfirm,
@@ -241,55 +485,116 @@ export const UserEditFields = ({
</>
)}
<div className="flex flex-col gap-2 pt-3 border-t border-[var(--border-muted)] sm:flex-row sm:items-center">
<div className="flex flex-wrap gap-2">
<button
onClick={onSave}
disabled={saving}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-sky-600 hover:bg-sky-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
<button
onClick={onCancel}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors"
>
Cancel
</button>
</div>
{onDelete && (
<div className="flex flex-wrap gap-2 sm:ml-auto">
{isDeletePending ? (
<>
<button
onClick={onConfirmDelete}
disabled={deleting}
className="px-4 py-2 rounded-lg text-sm font-medium text-white bg-red-600 hover:bg-red-700 transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
{deleting ? 'Deleting...' : 'Confirm Delete'}
</button>
<button
onClick={onCancelDelete}
disabled={deleting}
className="px-4 py-2 rounded-lg text-sm font-medium border border-[var(--border-muted)]
bg-[var(--bg)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-60 disabled:cursor-not-allowed"
>
Cancel
</button>
</>
) : (
<button
onClick={onDelete}
className="px-4 py-2 rounded-lg text-sm font-medium transition-colors
border border-red-500/40 text-red-600 hover:bg-red-500/10"
>
Delete User
</button>
)}
</div>
)}
</div>
{!hideActions && (
<UserEditActions
onSave={onSave}
saving={saving}
saveDisabled={saving}
onCancel={onCancel}
onDelete={onDelete}
onConfirmDelete={onConfirmDelete}
onCancelDelete={onCancelDelete}
isDeletePending={isDeletePending}
deleting={deleting}
/>
)}
</>
);
};
interface UserPreferencesPanelProps {
description?: string;
hideTitle?: boolean;
actionLabel?: string;
onAction?: () => void;
children?: ReactNode;
}
interface UserAccountCardContentProps extends Omit<UserEditFieldsProps, 'hideActions'> {
hideEditActions?: boolean;
preferencesPanel?: UserPreferencesPanelProps;
preferencesPlacement?: 'before' | 'after';
}
const renderPreferencesPanel = (panel: UserPreferencesPanelProps) => (
<div className="space-y-3">
{(!panel.hideTitle || panel.onAction) && (
<div>
{!panel.hideTitle && (
<label className="text-sm font-medium">User Preferences</label>
)}
{!panel.hideTitle && panel.description && (
<p className="text-xs opacity-60 mt-0.5">{panel.description}</p>
)}
{panel.onAction && (
<button
onClick={panel.onAction}
className="mt-2 px-4 py-2 rounded-lg text-sm font-medium text-white
bg-sky-600 hover:bg-sky-700 transition-colors"
>
{panel.actionLabel || 'Open User Preferences'}
</button>
)}
</div>
)}
{panel.children}
</div>
);
export const UserAccountCardContent = ({
user,
onUserChange,
onSave,
saving,
onCancel,
hideEditActions = false,
editPassword,
onEditPasswordChange,
editPasswordConfirm,
onEditPasswordConfirmChange,
onDelete,
onConfirmDelete,
onCancelDelete,
isDeletePending,
deleting,
preferencesPanel,
preferencesPlacement = 'before',
}: UserAccountCardContentProps) => {
const preferencesContent = preferencesPanel ? renderPreferencesPanel(preferencesPanel) : null;
return (
<div className="space-y-5">
{preferencesContent && preferencesPlacement === 'before' && (
<>
{preferencesContent}
<div className="border-t border-[var(--border-muted)]" />
</>
)}
<UserEditFields
user={user}
onUserChange={onUserChange}
onSave={onSave}
saving={saving}
onCancel={onCancel}
hideActions={hideEditActions}
editPassword={editPassword}
onEditPasswordChange={onEditPasswordChange}
editPasswordConfirm={editPasswordConfirm}
onEditPasswordConfirmChange={onEditPasswordConfirmChange}
onDelete={onDelete}
onConfirmDelete={onConfirmDelete}
onCancelDelete={onCancelDelete}
isDeletePending={isDeletePending}
deleting={deleting}
/>
{preferencesContent && preferencesPlacement === 'after' && (
<>
<div className="border-t border-[var(--border-muted)]" />
{preferencesContent}
</>
)}
</div>
);
};

View File

@@ -1,18 +1,10 @@
import { useState } from 'react';
import { AdminUser, DownloadDefaults } from '../../../services/api';
import { DropdownList } from '../../DropdownList';
import { Tooltip } from '../../shared/Tooltip';
import {
canCreateLocalUsersForAuthMode,
CreateUserFormState,
} from './types';
import { UserAuthSourceBadge } from './UserAuthSourceBadge';
import { UserCreateCard, UserEditFields } from './UserCard';
const EDIT_ROLE_OPTIONS = [
{ value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' },
];
import { UserAccountCardContent, UserCreateCard, UserIdentityHeader, UserRoleControl } from './UserCard';
interface UserListViewProps {
authMode: string;
@@ -123,7 +115,6 @@ export const UserListView = ({
const active = user.is_active !== false;
const isEditingRow = showEditForm && activeEditUserId === user.id;
const hasLoadedEditUser = isEditingRow && editingUser?.id === user.id;
const roleLabel = user.role.charAt(0).toUpperCase() + user.role.slice(1);
return (
<div
key={user.id}
@@ -132,82 +123,18 @@ export const UserListView = ({
<div
className={`flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between p-3 ${isEditingRow ? 'border-b border-[var(--border-muted)]' : ''}`}
>
<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-600 dark: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>
)}
<UserAuthSourceBadge user={user} />
</div>
<div className="text-xs opacity-50 truncate">
{user.email || 'No email'}
</div>
{!active && (
<div className="text-[11px] opacity-60 truncate">
Inactive for current authentication mode
</div>
)}
</div>
</div>
<UserIdentityHeader user={user} />
<div className="flex items-center flex-wrap gap-2 shrink-0 sm:justify-end">
{hasLoadedEditUser && editingUser ? (() => {
const caps = editingUser.edit_capabilities;
const canEditRole = caps.canEditRole;
const roleDisabledReason = !canEditRole
? (caps.authSource === 'oidc'
? (downloadDefaults?.OIDC_ADMIN_GROUP
? `Role is managed by the ${downloadDefaults.OIDC_ADMIN_GROUP} group in your identity provider.`
: 'Role is managed by OIDC group authorization.')
: 'Role is managed by the external authentication source.')
: undefined;
if (!canEditRole) {
return (
<Tooltip content={roleDisabledReason || 'Role cannot be changed'} position="bottom">
<span
className={`inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium leading-none cursor-not-allowed
${editingUser.role === 'admin' ? 'bg-sky-500/15 text-sky-600 dark:text-sky-400' : 'bg-zinc-500/10 opacity-70'}`}
>
{editingUser.role.charAt(0).toUpperCase() + editingUser.role.slice(1)}
</span>
</Tooltip>
);
}
return (
<DropdownList
options={EDIT_ROLE_OPTIONS}
value={editingUser.role}
onChange={(value) => {
const val = Array.isArray(value) ? value[0] ?? '' : value;
onEditingUserChange({ ...editingUser, role: val });
}}
widthClassName="w-28"
buttonClassName={`!py-1 !px-2.5 !text-xs !font-medium ${
editingUser.role === 'admin'
? '!bg-sky-500/15 !text-sky-600 dark:!text-sky-400 !border-sky-500/30'
: '!bg-zinc-500/10 !opacity-70'
}`}
/>
);
})() : (
<span
className={`inline-flex items-center rounded-md px-2.5 py-1 text-xs font-medium leading-none
${user.role === 'admin' ? 'bg-sky-500/15 text-sky-600 dark:text-sky-400' : 'bg-zinc-500/10 opacity-70'}`}
>
{roleLabel}
</span>
{hasLoadedEditUser && editingUser ? (
<UserRoleControl
user={editingUser}
onUserChange={onEditingUserChange}
oidcAdminGroup={downloadDefaults?.OIDC_ADMIN_GROUP}
tooltipPosition="bottom"
/>
) : (
<UserRoleControl user={user} />
)}
<button
@@ -244,38 +171,27 @@ export const UserListView = ({
{isEditingRow && (
<div className="p-4 space-y-5 bg-[var(--bg)] rounded-b-lg">
{hasLoadedEditUser && editingUser ? (
<>
<div>
<label className="text-sm font-medium">User Preferences</label>
<p className="text-xs opacity-60 mt-0.5">Override global delivery and request policy settings for this user.</p>
<button
onClick={onOpenOverrides}
className="mt-2 px-4 py-2 rounded-lg text-sm font-medium text-white
bg-sky-600 hover:bg-sky-700 transition-colors"
>
Open User Preferences
</button>
</div>
<div className="border-t border-[var(--border-muted)]" />
<UserEditFields
user={editingUser}
onUserChange={onEditingUserChange}
onSave={onEditSave}
saving={saving}
onCancel={onCancelEdit}
editPassword={editPassword}
onEditPasswordChange={onEditPasswordChange}
editPasswordConfirm={editPasswordConfirm}
onEditPasswordConfirmChange={onEditPasswordConfirmChange}
onDelete={() => setConfirmDelete(user.id)}
onConfirmDelete={() => handleDelete(user.id)}
onCancelDelete={() => setConfirmDelete(null)}
isDeletePending={confirmDelete === user.id}
deleting={deletingUserId === user.id}
/>
</>
<UserAccountCardContent
user={editingUser}
onUserChange={onEditingUserChange}
onSave={onEditSave}
saving={saving}
onCancel={onCancelEdit}
editPassword={editPassword}
onEditPasswordChange={onEditPasswordChange}
editPasswordConfirm={editPasswordConfirm}
onEditPasswordConfirmChange={onEditPasswordConfirmChange}
onDelete={() => setConfirmDelete(user.id)}
onConfirmDelete={() => handleDelete(user.id)}
onCancelDelete={() => setConfirmDelete(null)}
isDeletePending={confirmDelete === user.id}
deleting={deletingUserId === user.id}
preferencesPanel={{
description: 'Customise delivery and request settings for this user.',
actionLabel: 'Open User Preferences',
onAction: onOpenOverrides,
}}
/>
) : (
<div className="text-sm opacity-60">Loading user details...</div>
)}

View File

@@ -35,8 +35,8 @@ const REQUEST_POLICY_OVERRIDE_KEYS: Array<keyof PerUserSettings> = [
const requestPolicyHeading: HeadingFieldConfig = {
type: 'HeadingField',
key: 'request_policy_overrides_heading',
title: 'Request Policy',
description: 'User-level request policy overrides. Reset to inherit global policy values.',
title: 'Requests',
description: 'Custom request settings for this user. Reset any to fall back to the global defaults.',
};
const hasOwnNonNull = (settings: PerUserSettings, key: keyof PerUserSettings): boolean => {

View File

@@ -1,5 +1,12 @@
export { UserAuthSourceBadge } from './UserAuthSourceBadge';
export { UserCreateCard, UserEditFields } from './UserCard';
export {
UserAccountCardContent,
UserCreateCard,
UserEditActions,
UserEditFields,
UserIdentityHeader,
UserRoleControl,
} from './UserCard';
export { UserListView } from './UserListView';
export { RequestPolicyGrid } from './RequestPolicyGrid';
export { UserOverridesSection } from './UserOverridesSection';

View File

@@ -29,22 +29,22 @@ export const REQUEST_POLICY_DEFAULT_OPTIONS: Array<{
{
value: 'download',
label: 'Download',
description: 'Allow direct downloads.',
description: 'Everything can be downloaded directly.',
},
{
value: 'request_release',
label: 'Request Release',
description: 'Require requesting a specific release.',
description: 'Users must request a specific release.',
},
{
value: 'request_book',
label: 'Request Book',
description: 'Allow book-level requests only.',
description: 'Users request a book, admin picks the release.',
},
{
value: 'blocked',
label: 'Blocked',
description: 'Block downloads and requests.',
description: 'No downloads or requests allowed.',
},
];

View File

@@ -0,0 +1,417 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Socket } from 'socket.io-client';
import { Book, RequestRecord, StatusData } from '../types';
import {
ActivityHistoryItem,
ActivityDismissPayload,
clearActivityHistory,
dismissActivityItem,
dismissManyActivityItems,
getActivitySnapshot,
listActivityHistory,
} from '../services/api';
import {
ActivityDismissTarget,
ActivityItem,
downloadToActivityItem,
requestToActivityItem,
} from '../components/activity';
const HISTORY_PAGE_SIZE = 50;
const parseTimestamp = (value: string | null | undefined, fallback: number = 0): number => {
if (!value) {
return fallback;
}
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : fallback;
};
const mapHistoryRowToActivityItem = (
row: ActivityHistoryItem,
viewerRole: 'user' | 'admin'
): ActivityItem => {
const dismissedAtTs = parseTimestamp(row.dismissed_at);
const snapshot = row.snapshot;
if (snapshot && typeof snapshot === 'object') {
const payload = snapshot as Record<string, unknown>;
if (payload.kind === 'download' && payload.download && typeof payload.download === 'object') {
const statusKey = row.final_status === 'error' || row.final_status === 'cancelled'
? row.final_status
: 'complete';
const downloadItem = downloadToActivityItem(payload.download as Book, statusKey);
const requestPayload = payload.request;
if (requestPayload && typeof requestPayload === 'object') {
const requestRecord = requestPayload as RequestRecord;
return {
...downloadItem,
id: `history-${row.id}`,
timestamp: dismissedAtTs || downloadItem.timestamp,
requestId: requestRecord.id,
requestLevel: requestRecord.request_level,
requestNote: requestRecord.note || undefined,
requestRecord,
adminNote: requestRecord.admin_note || undefined,
username: requestRecord.username || downloadItem.username,
};
}
return {
...downloadItem,
id: `history-${row.id}`,
timestamp: dismissedAtTs || downloadItem.timestamp,
};
}
if (payload.kind === 'request' && payload.request && typeof payload.request === 'object') {
const requestItem = requestToActivityItem(payload.request as RequestRecord, viewerRole);
return {
...requestItem,
id: `history-${row.id}`,
timestamp: dismissedAtTs || requestItem.timestamp,
};
}
}
const visualStatus: ActivityItem['visualStatus'] =
row.final_status === 'error'
? 'error'
: row.final_status === 'cancelled'
? 'cancelled'
: row.final_status === 'rejected'
? 'rejected'
: 'complete';
const statusLabel =
visualStatus === 'error'
? 'Failed'
: visualStatus === 'cancelled'
? 'Cancelled'
: visualStatus === 'rejected'
? viewerRole === 'admin'
? 'Declined'
: 'Not approved'
: 'Complete';
return {
id: `history-${row.id}`,
kind: row.item_type === 'request' ? 'request' : 'download',
visualStatus,
title: row.item_type === 'request' ? 'Request' : 'Download',
author: '',
metaLine: row.item_key,
statusLabel,
timestamp: dismissedAtTs,
};
};
interface UseActivityParams {
isAuthenticated: boolean;
isAdmin: boolean;
showToast: (
message: string,
type?: 'info' | 'success' | 'error',
persistent?: boolean
) => string;
socket: Socket | null;
}
interface UseActivityResult {
activityStatus: StatusData;
requestItems: ActivityItem[];
dismissedActivityKeys: string[];
historyItems: ActivityItem[];
pendingRequestCount: number;
isActivitySnapshotLoading: boolean;
activityHistoryLoading: boolean;
activityHistoryHasMore: boolean;
refreshActivitySnapshot: () => Promise<void>;
handleActivityTabChange: (tab: 'all' | 'downloads' | 'requests' | 'history') => void;
resetActivity: () => void;
handleActivityHistoryLoadMore: () => void;
handleRequestDismiss: (requestId: number) => void;
handleDownloadDismiss: (bookId: string, linkedRequestId?: number) => void;
handleClearCompleted: (items: ActivityDismissTarget[]) => void;
handleClearHistory: () => void;
}
export const useActivity = ({
isAuthenticated,
isAdmin,
showToast,
socket,
}: UseActivityParams): UseActivityResult => {
const [activityStatus, setActivityStatus] = useState<StatusData>({});
const [activityRequests, setActivityRequests] = useState<RequestRecord[]>([]);
const [dismissedActivityKeys, setDismissedActivityKeys] = useState<string[]>([]);
const [isActivitySnapshotLoading, setIsActivitySnapshotLoading] = useState(false);
const [activityHistoryRows, setActivityHistoryRows] = useState<ActivityHistoryItem[]>([]);
const [activityHistoryOffset, setActivityHistoryOffset] = useState(0);
const [activityHistoryHasMore, setActivityHistoryHasMore] = useState(false);
const [activityHistoryLoading, setActivityHistoryLoading] = useState(false);
const [activityHistoryLoaded, setActivityHistoryLoaded] = useState(false);
const resetActivityHistory = useCallback(() => {
setActivityHistoryRows([]);
setActivityHistoryOffset(0);
setActivityHistoryHasMore(false);
setActivityHistoryLoaded(false);
}, []);
const resetActivity = useCallback(() => {
setActivityStatus({});
setActivityRequests([]);
setDismissedActivityKeys([]);
resetActivityHistory();
}, [resetActivityHistory]);
const refreshActivitySnapshot = useCallback(async () => {
if (!isAuthenticated) {
resetActivity();
return;
}
setIsActivitySnapshotLoading(true);
try {
const snapshot = await getActivitySnapshot();
setActivityStatus(snapshot.status || {});
setActivityRequests(Array.isArray(snapshot.requests) ? snapshot.requests : []);
const keys = Array.isArray(snapshot.dismissed)
? snapshot.dismissed
.map((entry) => entry.item_key)
.filter((key): key is string => typeof key === 'string' && key.trim().length > 0)
: [];
setDismissedActivityKeys(Array.from(new Set(keys)));
} catch (error) {
console.warn('Failed to refresh activity snapshot:', error);
} finally {
setIsActivitySnapshotLoading(false);
}
}, [isAuthenticated, resetActivity]);
const refreshActivityHistory = useCallback(async () => {
if (!isAuthenticated) {
resetActivityHistory();
return;
}
setActivityHistoryLoading(true);
try {
const rows = await listActivityHistory(HISTORY_PAGE_SIZE, 0);
const normalizedRows = Array.isArray(rows) ? rows : [];
setActivityHistoryRows(normalizedRows);
setActivityHistoryOffset(normalizedRows.length);
setActivityHistoryHasMore(normalizedRows.length === HISTORY_PAGE_SIZE);
setActivityHistoryLoaded(true);
} catch (error) {
console.warn('Failed to refresh activity history:', error);
} finally {
setActivityHistoryLoading(false);
}
}, [isAuthenticated, resetActivityHistory]);
const handleActivityTabChange = useCallback((tab: 'all' | 'downloads' | 'requests' | 'history') => {
if (tab !== 'history' || activityHistoryLoaded || activityHistoryLoading) {
return;
}
void refreshActivityHistory();
}, [activityHistoryLoaded, activityHistoryLoading, refreshActivityHistory]);
const handleActivityHistoryLoadMore = useCallback(() => {
if (!isAuthenticated || activityHistoryLoading || !activityHistoryHasMore) {
return;
}
setActivityHistoryLoading(true);
void listActivityHistory(HISTORY_PAGE_SIZE, activityHistoryOffset)
.then((rows) => {
const normalizedRows = Array.isArray(rows) ? rows : [];
setActivityHistoryRows((current) => {
const existingIds = new Set(current.map((row) => row.id));
const nextRows = normalizedRows.filter((row) => !existingIds.has(row.id));
return [...current, ...nextRows];
});
setActivityHistoryOffset((current) => current + normalizedRows.length);
setActivityHistoryHasMore(normalizedRows.length === HISTORY_PAGE_SIZE);
})
.catch((error) => {
console.warn('Failed to load more activity history:', error);
})
.finally(() => {
setActivityHistoryLoading(false);
});
}, [activityHistoryHasMore, activityHistoryLoading, activityHistoryOffset, isAuthenticated]);
useEffect(() => {
void refreshActivitySnapshot();
}, [refreshActivitySnapshot]);
useEffect(() => {
if (!socket || !isAuthenticated) {
return;
}
const refreshFromSocketEvent = () => {
void refreshActivitySnapshot();
if (activityHistoryLoaded) {
void refreshActivityHistory();
}
};
socket.on('activity_update', refreshFromSocketEvent);
socket.on('request_update', refreshFromSocketEvent);
socket.on('new_request', refreshFromSocketEvent);
return () => {
socket.off('activity_update', refreshFromSocketEvent);
socket.off('request_update', refreshFromSocketEvent);
socket.off('new_request', refreshFromSocketEvent);
};
}, [activityHistoryLoaded, isAuthenticated, refreshActivitySnapshot, refreshActivityHistory, socket]);
const requestItems = useMemo(
() =>
activityRequests
.map((record) => requestToActivityItem(record, isAdmin ? 'admin' : 'user'))
.sort((left, right) => right.timestamp - left.timestamp),
[activityRequests, isAdmin]
);
const historyItems = useMemo(
() => {
const mappedItems = activityHistoryRows
.map((row) => mapHistoryRowToActivityItem(row, isAdmin ? 'admin' : 'user'))
.sort((left, right) => right.timestamp - left.timestamp);
// Download dismissals already carry linked request context; hide redundant
// fulfilled-request history rows that would otherwise appear as "Approved".
const requestIdsWithDownloadRows = new Set<number>();
mappedItems.forEach((item) => {
if (item.kind === 'download' && typeof item.requestId === 'number') {
requestIdsWithDownloadRows.add(item.requestId);
}
});
if (!requestIdsWithDownloadRows.size) {
return mappedItems;
}
return mappedItems.filter((item) => {
if (item.kind !== 'request' || typeof item.requestId !== 'number') {
return true;
}
if (!requestIdsWithDownloadRows.has(item.requestId)) {
return true;
}
const requestStatus = item.requestRecord?.status;
return requestStatus !== 'fulfilled' && item.visualStatus !== 'fulfilled';
});
},
[activityHistoryRows, isAdmin]
);
const pendingRequestCount = useMemo(
() => activityRequests.filter((record) => record.status === 'pending').length,
[activityRequests]
);
const refreshHistoryIfLoaded = useCallback(() => {
if (!activityHistoryLoaded) {
return;
}
void refreshActivityHistory();
}, [activityHistoryLoaded, refreshActivityHistory]);
const dismissItems = useCallback((items: ActivityDismissPayload[], optimisticKeys: string[], errorMessage: string) => {
setDismissedActivityKeys((current) => Array.from(new Set([...current, ...optimisticKeys])));
void dismissManyActivityItems(items)
.then(() => {
void refreshActivitySnapshot();
refreshHistoryIfLoaded();
})
.catch((error) => {
console.error('Activity dismiss failed:', error);
void refreshActivitySnapshot();
refreshHistoryIfLoaded();
showToast(errorMessage, 'error');
});
}, [refreshActivitySnapshot, refreshHistoryIfLoaded, showToast]);
const handleRequestDismiss = useCallback((requestId: number) => {
const requestKey = `request:${requestId}`;
setDismissedActivityKeys((current) =>
current.includes(requestKey) ? current : [...current, requestKey]
);
void dismissActivityItem({
item_type: 'request',
item_key: requestKey,
}).then(() => {
void refreshActivitySnapshot();
refreshHistoryIfLoaded();
}).catch((error) => {
console.error('Request dismiss failed:', error);
void refreshActivitySnapshot();
refreshHistoryIfLoaded();
showToast('Failed to clear request', 'error');
});
}, [refreshActivitySnapshot, refreshHistoryIfLoaded, showToast]);
const handleDownloadDismiss = useCallback((bookId: string, linkedRequestId?: number) => {
const items: ActivityDismissTarget[] = [{ itemType: 'download', itemKey: `download:${bookId}` }];
if (typeof linkedRequestId === 'number' && Number.isFinite(linkedRequestId)) {
items.push({ itemType: 'request', itemKey: `request:${linkedRequestId}` });
}
dismissItems(
items.map((item) => ({
item_type: item.itemType,
item_key: item.itemKey,
})),
items.map((item) => item.itemKey),
'Failed to clear item'
);
}, [dismissItems]);
const handleClearCompleted = useCallback((items: ActivityDismissTarget[]) => {
if (!items.length) {
return;
}
dismissItems(
items.map((item) => ({
item_type: item.itemType,
item_key: item.itemKey,
})),
Array.from(new Set(items.map((item) => item.itemKey))),
'Failed to clear finished downloads'
);
}, [dismissItems]);
const handleClearHistory = useCallback(() => {
resetActivityHistory();
void clearActivityHistory().catch((error) => {
console.error('Clear history failed:', error);
void refreshActivityHistory();
showToast('Failed to clear history', 'error');
});
}, [refreshActivityHistory, resetActivityHistory, showToast]);
return {
activityStatus,
requestItems,
dismissedActivityKeys,
historyItems,
pendingRequestCount,
isActivitySnapshotLoading,
activityHistoryLoading,
activityHistoryHasMore,
refreshActivitySnapshot,
handleActivityTabChange,
resetActivity,
handleActivityHistoryLoadMore,
handleRequestDismiss,
handleDownloadDismiss,
handleClearCompleted,
handleClearHistory,
};
};

View File

@@ -4,38 +4,20 @@ import {
SettingsTab,
SettingsGroup,
SettingsField,
SelectFieldConfig,
ActionResult,
UpdateResult,
} from '../types/settings';
import {
getStoredThemePreference,
setThemePreference,
THEME_FIELD,
} from '../utils/themePreference';
type ValueBearingField = Exclude<
SettingsField,
{ type: 'ActionButton' } | { type: 'HeadingField' } | { type: 'CustomComponentField' }
>;
// Client-side only theme field that gets injected into the general tab
const THEME_FIELD: SelectFieldConfig = {
type: 'SelectField',
key: '_THEME',
label: 'Theme',
description: 'Choose your preferred color scheme.',
value: 'auto', // Placeholder, actual value comes from localStorage
options: [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'auto', label: 'Auto (System)' },
],
};
// Apply theme to document
function applyTheme(theme: string): void {
const effectiveTheme = theme === 'auto'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme;
document.documentElement.setAttribute('data-theme', effectiveTheme);
}
// Extract value from a field based on its type
function getFieldValue(field: SettingsField): unknown {
// These field types have no value property
@@ -138,7 +120,7 @@ export function useSettings(): UseSettingsReturn {
getValueBearingFields(tab.fields).forEach((field) => {
// Special handling for theme field - get from localStorage
if (field.key === '_THEME') {
initialValues[tab.name][field.key] = localStorage.getItem('preferred-theme') || 'auto';
initialValues[tab.name][field.key] = getStoredThemePreference();
} else {
initialValues[tab.name][field.key] = getFieldValue(field);
}
@@ -169,8 +151,7 @@ export function useSettings(): UseSettingsReturn {
const updateValue = useCallback((tabName: string, key: string, value: unknown) => {
// Apply theme immediately when changed (no save button needed)
if (key === '_THEME' && typeof value === 'string') {
localStorage.setItem('preferred-theme', value);
applyTheme(value);
setThemePreference(value);
// Also update original value so it doesn't show as pending change
setOriginalValues((prev) => ({
...prev,

View File

@@ -44,6 +44,10 @@ const API = {
requests: `${API_BASE}/requests`,
adminRequests: `${API_BASE}/admin/requests`,
adminRequestCounts: `${API_BASE}/admin/requests/count`,
activitySnapshot: `${API_BASE}/activity/snapshot`,
activityDismiss: `${API_BASE}/activity/dismiss`,
activityDismissMany: `${API_BASE}/activity/dismiss-many`,
activityHistory: `${API_BASE}/activity/history`,
};
// Custom error class for authentication failures
@@ -289,6 +293,38 @@ export const getStatus = async (): Promise<StatusData> => {
return fetchJSON<StatusData>(API.status);
};
export const getActivitySnapshot = async (): Promise<ActivitySnapshotResponse> => {
return fetchJSON<ActivitySnapshotResponse>(API.activitySnapshot);
};
export const dismissActivityItem = async (payload: ActivityDismissPayload): Promise<void> => {
await fetchJSON(API.activityDismiss, {
method: 'POST',
body: JSON.stringify(payload),
});
};
export const dismissManyActivityItems = async (items: ActivityDismissPayload[]): Promise<void> => {
await fetchJSON(API.activityDismissMany, {
method: 'POST',
body: JSON.stringify({ items }),
});
};
export const listActivityHistory = async (
limit: number = 50,
offset: number = 0
): Promise<ActivityHistoryItem[]> => {
const params = new URLSearchParams();
params.set('limit', String(limit));
params.set('offset', String(offset));
return fetchJSON<ActivityHistoryItem[]>(`${API.activityHistory}?${params.toString()}`);
};
export const clearActivityHistory = async (): Promise<void> => {
await fetchJSON(API.activityHistory, { method: 'DELETE' });
};
export const cancelDownload = async (id: string): Promise<void> => {
await fetchJSON(`${API.cancelDownload}/${encodeURIComponent(id)}/cancel`, { method: 'DELETE' });
};
@@ -309,6 +345,38 @@ export interface AdminRequestCounts {
by_status: Record<string, number>;
}
export interface ActivityDismissedItem {
item_type: 'download' | 'request';
item_key: string;
}
export interface ActivitySnapshotResponse {
status: StatusData;
requests: RequestRecord[];
dismissed: ActivityDismissedItem[];
}
export interface ActivityDismissPayload {
item_type: 'download' | 'request';
item_key: string;
activity_log_id?: number;
}
export interface ActivityHistoryItem {
id: number;
user_id: number;
item_type: 'download' | 'request';
item_key: string;
activity_log_id: number | null;
dismissed_at: string;
snapshot: Record<string, unknown> | null;
origin: 'direct' | 'request' | 'requested' | null;
final_status: string | null;
terminal_at: string | null;
request_id: number | null;
source_id: string | null;
}
export const fetchRequestPolicy = async (): Promise<RequestPolicyResponse> => {
return fetchJSON<RequestPolicyResponse>(API.requestPolicy);
};
@@ -527,6 +595,12 @@ export interface AdminUser {
settings?: Record<string, unknown>;
}
export interface SelfUserEditContext {
user: AdminUser;
deliveryPreferences: DeliveryPreferencesResponse | null;
userOverridableKeys: string[];
}
export const getAdminUsers = async (): Promise<AdminUser[]> => {
return fetchJSON<AdminUser[]>(`${API_BASE}/admin/users`);
};
@@ -644,3 +718,19 @@ export const getAdminSettingsOverridesSummary = async (
): Promise<SettingsOverridesSummaryResponse> => {
return fetchJSON<SettingsOverridesSummaryResponse>(`${API_BASE}/admin/settings/overrides-summary?tab=${encodeURIComponent(tabName)}`);
};
export const getSelfUserEditContext = async (): Promise<SelfUserEditContext> => {
return fetchJSON<SelfUserEditContext>(`${API_BASE}/users/me/edit-context`);
};
export const updateSelfUser = async (
data: Partial<Pick<AdminUser, 'email' | 'display_name'>> & {
password?: string;
settings?: Record<string, unknown>;
}
): Promise<AdminUser> => {
return fetchJSON<AdminUser>(`${API_BASE}/users/me`, {
method: 'PUT',
body: JSON.stringify(data),
});
};

View File

@@ -30,7 +30,7 @@ describe('activityCardModel', () => {
);
assert.equal(model.badges.length, 1);
assert.equal(model.badges[0]?.text, 'Requested by testuser');
assert.equal(model.badges[0]?.text, 'Needs review · testuser');
});
it('keeps pending label for requester-side pending requests', () => {
@@ -45,7 +45,7 @@ describe('activityCardModel', () => {
);
assert.equal(model.badges.length, 1);
assert.equal(model.badges[0]?.text, 'Pending');
assert.equal(model.badges[0]?.text, 'Awaiting review');
});
it('uses requester-friendly approved wording for fulfilled requests', () => {
@@ -98,7 +98,7 @@ describe('activityCardModel', () => {
assert.equal(model.badges[0]?.visualStatus, 'resolving');
});
it('shows request and download badges side-by-side for merged request downloads', () => {
it('shows a single download completion badge for completed merged request downloads', () => {
const model = buildActivityCardModel(
makeItem({
visualStatus: 'complete',
@@ -127,11 +127,42 @@ describe('activityCardModel', () => {
true
);
assert.equal(model.badges.length, 2);
assert.equal(model.badges[0]?.key, 'request');
assert.equal(model.badges[0]?.text, 'Request fulfilled');
assert.equal(model.badges[1]?.key, 'download');
assert.equal(model.badges[1]?.text, 'Sent to Kindle');
assert.equal(model.badges.length, 1);
assert.equal(model.badges[0]?.key, 'download');
assert.equal(model.badges[0]?.text, 'Sent to Kindle');
assert.equal(model.badges[0]?.visualStatus, 'complete');
});
it('does not render a special note for fulfilled requests with terminal delivery state', () => {
const model = buildActivityCardModel(
makeItem({
kind: 'request',
visualStatus: 'fulfilled',
requestId: 42,
requestRecord: {
id: 42,
user_id: 7,
status: 'fulfilled',
delivery_state: 'complete',
source_hint: 'prowlarr',
content_type: 'ebook',
request_level: 'release',
policy_mode: 'request_release',
book_data: { title: 'The Martian', author: 'Andy Weir' },
release_data: { source_id: 'book-1' },
note: null,
admin_note: null,
reviewed_by: null,
reviewed_at: null,
created_at: '2026-02-13T12:00:00Z',
updated_at: '2026-02-13T12:00:00Z',
username: 'testuser',
},
}),
false
);
assert.equal(model.noteLine, undefined);
});
it('builds pending admin request actions from one normalized source', () => {

View File

@@ -74,7 +74,7 @@ describe('activityMappers.downloadToActivityItem', () => {
assert.equal(item.kind, 'download');
assert.equal(item.visualStatus, 'queued');
assert.equal(item.statusLabel, 'Queued');
assert.equal(item.metaLine, 'EPUB | 3 MB | Direct Download | alice');
assert.equal(item.metaLine, 'EPUB · 3 MB · Direct Download · alice');
assert.equal(item.progress, 5);
assert.equal(item.progressAnimated, true);
assert.equal(item.timestamp, 123);
@@ -117,7 +117,7 @@ describe('activityMappers.requestToActivityItem', () => {
assert.equal(item.kind, 'request');
assert.equal(item.visualStatus, 'pending');
assert.equal(item.metaLine, 'EPUB | 2 MB | Prowlarr | alice');
assert.equal(item.metaLine, 'EPUB · 2 MB · Prowlarr · alice');
assert.equal(item.requestId, 42);
assert.equal(item.requestLevel, 'release');
assert.equal(item.requestNote, 'please add this');
@@ -153,6 +153,6 @@ describe('activityMappers.requestToActivityItem', () => {
it('does not append username to meta line for user viewer role', () => {
const item = requestToActivityItem(makeRequest(), 'user');
assert.equal(item.metaLine, 'EPUB | 2 MB | Prowlarr');
assert.equal(item.metaLine, 'EPUB · 2 MB · Prowlarr');
});
});

View File

@@ -197,6 +197,8 @@ export interface RequestRecord {
id: number;
user_id: number;
status: 'pending' | 'fulfilled' | 'rejected' | 'cancelled';
delivery_state?: 'none' | 'unknown' | 'queued' | 'resolving' | 'locating' | 'downloading' | 'complete' | 'error' | 'cancelled';
delivery_updated_at?: string | null;
source_hint: string | null;
content_type: ContentType;
request_level: 'book' | 'release';

View File

@@ -0,0 +1,41 @@
import { SelectFieldConfig } from '../types/settings';
export const THEME_PREFERENCE_KEY = 'preferred-theme';
export const DEFAULT_THEME_PREFERENCE = 'auto';
export const THEME_FIELD: SelectFieldConfig = {
type: 'SelectField',
key: '_THEME',
label: 'Theme',
description: 'Choose your preferred color scheme.',
value: DEFAULT_THEME_PREFERENCE,
options: [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'auto', label: 'Auto (System)' },
],
};
export function getStoredThemePreference(): string {
try {
return localStorage.getItem(THEME_PREFERENCE_KEY) || DEFAULT_THEME_PREFERENCE;
} catch {
return DEFAULT_THEME_PREFERENCE;
}
}
export function applyThemePreference(theme: string): void {
const effectiveTheme = theme === 'auto'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme;
document.documentElement.setAttribute('data-theme', effectiveTheme);
}
export function setThemePreference(theme: string): void {
try {
localStorage.setItem(THEME_PREFERENCE_KEY, theme);
} catch {
// localStorage may be unavailable in private browsing
}
applyThemePreference(theme);
}

View File

@@ -0,0 +1,420 @@
"""API tests for activity snapshot/dismiss/history routes."""
from __future__ import annotations
import importlib
import uuid
from unittest.mock import ANY, patch
import pytest
@pytest.fixture(scope="module")
def main_module():
"""Import `shelfmark.main` with background startup disabled."""
with patch("shelfmark.download.orchestrator.start"):
import shelfmark.main as main
importlib.reload(main)
return main
@pytest.fixture
def client(main_module):
return main_module.app.test_client()
def _set_session(client, *, user_id: str, db_user_id: int | None, is_admin: bool) -> None:
with client.session_transaction() as sess:
sess["user_id"] = user_id
sess["is_admin"] = is_admin
if db_user_id is not None:
sess["db_user_id"] = db_user_id
elif "db_user_id" in sess:
del sess["db_user_id"]
def _create_user(main_module, *, prefix: str, role: str = "user") -> dict:
username = f"{prefix}-{uuid.uuid4().hex[:8]}"
return main_module.user_db.create_user(username=username, role=role)
def _sample_status_payload() -> dict:
return {
"queued": {},
"resolving": {},
"locating": {},
"downloading": {},
"complete": {},
"available": {},
"done": {},
"error": {},
"cancelled": {},
}
class TestActivityRoutes:
def test_snapshot_returns_status_requests_and_dismissed(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
main_module.user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="book",
policy_mode="request_book",
book_data={
"title": "Snapshot Book",
"author": "Snapshot Author",
"provider": "openlibrary",
"provider_id": "snap-1",
},
status="pending",
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()):
response = client.get("/api/activity/snapshot")
assert response.status_code == 200
assert "status" in response.json
assert "requests" in response.json
assert "dismissed" in response.json
assert response.json["dismissed"] == []
assert any(item["user_id"] == user["id"] for item in response.json["requests"])
def test_dismiss_and_history_flow(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
main_module.activity_service.record_terminal_snapshot(
user_id=user["id"],
item_type="download",
item_key="download:test-task",
origin="requested",
final_status="complete",
source_id="test-task",
snapshot={"title": "Dismiss Me"},
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
dismiss_response = client.post(
"/api/activity/dismiss",
json={"item_type": "download", "item_key": "download:test-task"},
)
snapshot_response = client.get("/api/activity/snapshot")
history_response = client.get("/api/activity/history?limit=10&offset=0")
clear_history_response = client.delete("/api/activity/history")
history_after_clear = client.get("/api/activity/history?limit=10&offset=0")
assert dismiss_response.status_code == 200
assert dismiss_response.json["status"] == "dismissed"
assert snapshot_response.status_code == 200
assert {"item_type": "download", "item_key": "download:test-task"} in snapshot_response.json["dismissed"]
assert history_response.status_code == 200
assert len(history_response.json) == 1
assert history_response.json[0]["item_key"] == "download:test-task"
assert history_response.json[0]["snapshot"] == {"title": "Dismiss Me"}
assert clear_history_response.status_code == 200
assert clear_history_response.json["status"] == "cleared"
assert clear_history_response.json["deleted_count"] == 1
assert history_after_clear.status_code == 200
assert history_after_clear.json == []
def test_admin_snapshot_includes_admin_viewer_dismissals(self, main_module, client):
admin = _create_user(main_module, prefix="admin", role="admin")
_set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
dismiss_response = client.post(
"/api/activity/dismiss",
json={"item_type": "download", "item_key": "download:admin-visible-task"},
)
with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()):
snapshot_response = client.get("/api/activity/snapshot")
assert dismiss_response.status_code == 200
assert snapshot_response.status_code == 200
assert {
"item_type": "download",
"item_key": "download:admin-visible-task",
} in snapshot_response.json["dismissed"]
def test_dismiss_legacy_fulfilled_request_creates_minimal_history_snapshot(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
request_row = main_module.user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="book",
policy_mode="request_book",
book_data={
"title": "Legacy Fulfilled Request",
"author": "Legacy Author",
"provider": "openlibrary",
"provider_id": "legacy-fulfilled-1",
},
status="fulfilled",
delivery_state="unknown",
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
dismiss_response = client.post(
"/api/activity/dismiss",
json={"item_type": "request", "item_key": f"request:{request_row['id']}"},
)
history_response = client.get("/api/activity/history?limit=10&offset=0")
assert dismiss_response.status_code == 200
assert history_response.status_code == 200
assert len(history_response.json) == 1
history_entry = history_response.json[0]
assert history_entry["item_type"] == "request"
assert history_entry["item_key"] == f"request:{request_row['id']}"
assert history_entry["final_status"] == "complete"
assert history_entry["snapshot"]["kind"] == "request"
assert history_entry["snapshot"]["request"]["id"] == request_row["id"]
assert history_entry["snapshot"]["request"]["book_data"]["title"] == "Legacy Fulfilled Request"
def test_dismiss_requires_db_identity(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=None, is_admin=False)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
response = client.post(
"/api/activity/dismiss",
json={"item_type": "download", "item_key": "download:test-task"},
)
assert response.status_code == 403
assert response.json["code"] == "user_identity_unavailable"
def test_dismiss_emits_activity_update_to_user_room(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.ws_manager, "is_enabled", return_value=True):
with patch.object(main_module.ws_manager.socketio, "emit") as mock_emit:
response = client.post(
"/api/activity/dismiss",
json={"item_type": "download", "item_key": "download:test-task"},
)
assert response.status_code == 200
mock_emit.assert_called_once_with(
"activity_update",
ANY,
to=f"user_{user['id']}",
)
def test_queue_clear_does_not_set_request_delivery_state_to_cleared(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
request_row = main_module.user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data={
"title": "Queue Clear Book",
"author": "Queue Clear Author",
"provider": "openlibrary",
"provider_id": "clear-1",
},
release_data={
"source": "prowlarr",
"source_id": "clear-task-1",
"title": "Queue Clear Book.epub",
},
status="fulfilled",
delivery_state="complete",
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend, "clear_completed", return_value=1):
response = client.delete("/api/queue/clear")
assert response.status_code == 200
updated_request = main_module.user_db.get_request(request_row["id"])
assert updated_request is not None
assert updated_request["delivery_state"] == "complete"
def test_snapshot_backfills_undismissed_terminal_download_from_activity_log(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
main_module.activity_service.record_terminal_snapshot(
user_id=user["id"],
item_type="download",
item_key="download:expired-task-1",
origin="direct",
final_status="complete",
source_id="expired-task-1",
snapshot={
"kind": "download",
"download": {
"id": "expired-task-1",
"title": "Expired Task",
"author": "Expired Author",
"added_time": 123,
"status_message": "Finished",
"source": "direct_download",
},
},
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()):
response = client.get("/api/activity/snapshot")
assert response.status_code == 200
assert "expired-task-1" in response.json["status"]["complete"]
assert response.json["status"]["complete"]["expired-task-1"]["id"] == "expired-task-1"
def test_snapshot_clears_stale_download_dismissal_when_same_task_is_active(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
dismiss_response = client.post(
"/api/activity/dismiss",
json={"item_type": "download", "item_key": "download:task-reused-1"},
)
assert dismiss_response.status_code == 200
active_status = _sample_status_payload()
active_status["downloading"] = {
"task-reused-1": {
"id": "task-reused-1",
"title": "Reused Task",
"author": "Author",
"source": "direct_download",
"added_time": 1,
}
}
with patch.object(main_module.backend, "queue_status", return_value=active_status):
snapshot_response = client.get("/api/activity/snapshot")
assert snapshot_response.status_code == 200
assert {
"item_type": "download",
"item_key": "download:task-reused-1",
} not in snapshot_response.json["dismissed"]
assert main_module.activity_service.get_dismissal_set(user["id"]) == []
def test_dismiss_state_is_isolated_per_user(self, main_module, client):
user_one = _create_user(main_module, prefix="reader-one")
user_two = _create_user(main_module, prefix="reader-two")
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
_set_session(client, user_id=user_one["username"], db_user_id=user_one["id"], is_admin=False)
dismiss_response = client.post(
"/api/activity/dismiss",
json={"item_type": "download", "item_key": "download:shared-task"},
)
assert dismiss_response.status_code == 200
snapshot_one = client.get("/api/activity/snapshot")
assert snapshot_one.status_code == 200
assert {"item_type": "download", "item_key": "download:shared-task"} in snapshot_one.json["dismissed"]
_set_session(client, user_id=user_two["username"], db_user_id=user_two["id"], is_admin=False)
snapshot_two = client.get("/api/activity/snapshot")
assert snapshot_two.status_code == 200
assert {"item_type": "download", "item_key": "download:shared-task"} not in snapshot_two.json["dismissed"]
def test_history_paging_is_stable_and_non_overlapping(self, main_module, client):
user = _create_user(main_module, prefix="history-user")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
for index in range(5):
item_key = f"download:history-task-{index}"
main_module.activity_service.record_terminal_snapshot(
user_id=user["id"],
item_type="download",
item_key=item_key,
origin="direct",
final_status="complete",
source_id=f"history-task-{index}",
snapshot={"kind": "download", "download": {"id": f"history-task-{index}"}},
)
main_module.activity_service.dismiss_item(
user_id=user["id"],
item_type="download",
item_key=item_key,
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
page_one = client.get("/api/activity/history?limit=2&offset=0")
page_two = client.get("/api/activity/history?limit=2&offset=2")
page_three = client.get("/api/activity/history?limit=2&offset=4")
full = client.get("/api/activity/history?limit=10&offset=0")
assert page_one.status_code == 200
assert page_two.status_code == 200
assert page_three.status_code == 200
assert full.status_code == 200
page_one_ids = [row["id"] for row in page_one.json]
page_two_ids = [row["id"] for row in page_two.json]
page_three_ids = [row["id"] for row in page_three.json]
combined_ids = page_one_ids + page_two_ids + page_three_ids
full_ids = [row["id"] for row in full.json]
assert len(set(page_one_ids).intersection(page_two_ids)) == 0
assert len(set(page_one_ids).intersection(page_three_ids)) == 0
assert len(set(page_two_ids).intersection(page_three_ids)) == 0
assert combined_ids == full_ids[: len(combined_ids)]
def test_dismiss_many_emits_activity_update_only_to_acting_user_room(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.ws_manager, "is_enabled", return_value=True):
with patch.object(main_module.ws_manager.socketio, "emit") as mock_emit:
response = client.post(
"/api/activity/dismiss-many",
json={
"items": [
{"item_type": "download", "item_key": "download:test-task-many"},
]
},
)
assert response.status_code == 200
mock_emit.assert_called_once_with(
"activity_update",
ANY,
to=f"user_{user['id']}",
)
def test_clear_history_emits_activity_update_only_to_acting_user_room(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
main_module.activity_service.dismiss_item(
user_id=user["id"],
item_type="download",
item_key="download:history-clear-task",
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.ws_manager, "is_enabled", return_value=True):
with patch.object(main_module.ws_manager.socketio, "emit") as mock_emit:
response = client.delete("/api/activity/history")
assert response.status_code == 200
mock_emit.assert_called_once_with(
"activity_update",
ANY,
to=f"user_{user['id']}",
)

View File

@@ -0,0 +1,245 @@
"""Tests for activity service persistence helpers."""
from __future__ import annotations
import os
import tempfile
import pytest
from shelfmark.core.activity_service import (
ActivityService,
build_download_item_key,
build_item_key,
build_request_item_key,
)
from shelfmark.core.user_db import UserDB
@pytest.fixture
def db_path():
with tempfile.TemporaryDirectory() as tmpdir:
yield os.path.join(tmpdir, "users.db")
@pytest.fixture
def user_db(db_path):
db = UserDB(db_path)
db.initialize()
return db
@pytest.fixture
def activity_service(db_path):
return ActivityService(db_path)
class TestItemKeys:
def test_build_request_item_key(self):
assert build_request_item_key(42) == "request:42"
assert build_item_key("request", 7) == "request:7"
def test_build_download_item_key(self):
assert build_download_item_key("abc123") == "download:abc123"
assert build_item_key("download", "xyz") == "download:xyz"
def test_build_item_key_validation(self):
with pytest.raises(ValueError):
build_item_key("bad", "x")
with pytest.raises(ValueError):
build_item_key("request", "nope")
with pytest.raises(ValueError):
build_item_key("download", "")
class TestActivityService:
def test_record_snapshot_and_dismiss_and_history(self, user_db, activity_service):
user = user_db.create_user(username="activity-user")
snapshot = activity_service.record_terminal_snapshot(
user_id=user["id"],
item_type="download",
item_key="download:task-1",
origin="requested",
final_status="complete",
request_id=12,
source_id="task-1",
snapshot={"title": "My Book", "status": "complete"},
)
assert snapshot["item_type"] == "download"
assert snapshot["item_key"] == "download:task-1"
assert snapshot["origin"] == "requested"
assert snapshot["final_status"] == "complete"
dismissal = activity_service.dismiss_item(
user_id=user["id"],
item_type="download",
item_key="download:task-1",
)
assert dismissal["item_type"] == "download"
assert dismissal["item_key"] == "download:task-1"
assert dismissal["activity_log_id"] == snapshot["id"]
dismissed_set = activity_service.get_dismissal_set(user["id"])
assert dismissed_set == [{"item_type": "download", "item_key": "download:task-1"}]
history = activity_service.get_history(user["id"], limit=10, offset=0)
assert len(history) == 1
assert history[0]["item_type"] == "download"
assert history[0]["item_key"] == "download:task-1"
assert history[0]["origin"] == "requested"
assert history[0]["final_status"] == "complete"
assert history[0]["snapshot"] == {"title": "My Book", "status": "complete"}
def test_history_hydrates_legacy_request_dismissals_without_snapshot(self, user_db, activity_service):
user = user_db.create_user(username="legacy-reader")
request_row = user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="book",
policy_mode="request_book",
book_data={
"title": "Legacy Request",
"author": "Legacy Author",
"provider": "openlibrary",
"provider_id": "legacy-hydrate-1",
},
status="fulfilled",
delivery_state="unknown",
)
activity_service.dismiss_item(
user_id=user["id"],
item_type="request",
item_key=f"request:{request_row['id']}",
)
history = activity_service.get_history(user["id"], limit=10, offset=0)
assert len(history) == 1
assert history[0]["item_type"] == "request"
assert history[0]["item_key"] == f"request:{request_row['id']}"
assert history[0]["origin"] == "request"
assert history[0]["final_status"] == "complete"
assert history[0]["snapshot"] == {
"kind": "request",
"request": {
"id": request_row["id"],
"user_id": user["id"],
"status": "fulfilled",
"delivery_state": "unknown",
"request_level": "book",
"book_data": {
"title": "Legacy Request",
"author": "Legacy Author",
"provider": "openlibrary",
"provider_id": "legacy-hydrate-1",
},
"release_data": {},
"note": None,
"admin_note": None,
"created_at": request_row["created_at"],
"updated_at": request_row["created_at"],
},
}
def test_dismiss_many_and_clear_history(self, user_db, activity_service):
alice = user_db.create_user(username="alice")
bob = user_db.create_user(username="bob")
activity_service.record_terminal_snapshot(
user_id=alice["id"],
item_type="request",
item_key="request:10",
origin="request",
final_status="rejected",
request_id=10,
snapshot={"title": "Rejected Book"},
)
activity_service.record_terminal_snapshot(
user_id=alice["id"],
item_type="download",
item_key="download:task-2",
origin="direct",
final_status="error",
source_id="task-2",
snapshot={"title": "Failed Download"},
)
dismissed_count = activity_service.dismiss_many(
user_id=alice["id"],
items=[
{"item_type": "request", "item_key": "request:10"},
{"item_type": "download", "item_key": "download:task-2"},
],
)
assert dismissed_count == 2
# Bob has independent dismiss state.
activity_service.dismiss_item(
user_id=bob["id"],
item_type="request",
item_key="request:10",
)
alice_history = activity_service.get_history(alice["id"])
bob_history = activity_service.get_history(bob["id"])
assert len(alice_history) == 2
assert len(bob_history) == 1
cleared = activity_service.clear_history(alice["id"])
assert cleared == 2
assert activity_service.get_history(alice["id"]) == []
assert len(activity_service.get_history(bob["id"])) == 1
def test_get_undismissed_terminal_downloads_returns_latest_per_item_and_excludes_dismissed(
self,
user_db,
activity_service,
):
user = user_db.create_user(username="snapshot-user")
activity_service.record_terminal_snapshot(
user_id=user["id"],
item_type="download",
item_key="download:task-1",
origin="direct",
final_status="error",
source_id="task-1",
terminal_at="2026-01-01T10:00:00+00:00",
snapshot={"kind": "download", "download": {"id": "task-1", "status_message": "failed"}},
)
activity_service.record_terminal_snapshot(
user_id=user["id"],
item_type="download",
item_key="download:task-1",
origin="direct",
final_status="complete",
source_id="task-1",
terminal_at="2026-01-01T11:00:00+00:00",
snapshot={"kind": "download", "download": {"id": "task-1", "status_message": "done"}},
)
activity_service.record_terminal_snapshot(
user_id=user["id"],
item_type="download",
item_key="download:task-2",
origin="direct",
final_status="cancelled",
source_id="task-2",
terminal_at="2026-01-01T09:00:00+00:00",
snapshot={"kind": "download", "download": {"id": "task-2", "status_message": "stopped"}},
)
activity_service.dismiss_item(
user_id=user["id"],
item_type="download",
item_key="download:task-2",
)
rows = activity_service.get_undismissed_terminal_downloads(user["id"])
assert len(rows) == 1
assert rows[0]["item_key"] == "download:task-1"
assert rows[0]["final_status"] == "complete"
assert rows[0]["snapshot"] == {
"kind": "download",
"download": {"id": "task-1", "status_message": "done"},
}

View File

@@ -0,0 +1,156 @@
"""Tests for terminal activity snapshot capture from queue transitions."""
from __future__ import annotations
import importlib
import json
import uuid
from unittest.mock import patch
import pytest
from shelfmark.core.models import DownloadTask, QueueStatus
@pytest.fixture(scope="module")
def main_module():
"""Import `shelfmark.main` with background startup disabled."""
with patch("shelfmark.download.orchestrator.start"):
import shelfmark.main as main
importlib.reload(main)
return main
def _create_user(main_module, *, prefix: str) -> dict:
username = f"{prefix}-{uuid.uuid4().hex[:8]}"
return main_module.user_db.create_user(username=username, role="user")
def _read_activity_log_row(main_module, snapshot_id: int):
conn = main_module.user_db._connect()
try:
return conn.execute(
"SELECT * FROM activity_log WHERE id = ?",
(snapshot_id,),
).fetchone()
finally:
conn.close()
class TestTerminalSnapshotCapture:
def test_complete_transition_records_direct_snapshot_and_survives_queue_clear(self, main_module):
user = _create_user(main_module, prefix="snap-direct")
task_id = f"direct-{uuid.uuid4().hex[:8]}"
task = DownloadTask(
task_id=task_id,
source="direct_download",
title="Direct Snapshot",
user_id=user["id"],
username=user["username"],
)
assert main_module.backend.book_queue.add(task) is True
try:
main_module.backend.book_queue.update_status(task_id, QueueStatus.COMPLETE)
item_key = f"download:{task_id}"
snapshot_id = main_module.activity_service.get_latest_activity_log_id(
item_type="download",
item_key=item_key,
)
assert snapshot_id is not None
removed = main_module.backend.book_queue.clear_completed(user_id=user["id"])
assert removed >= 1
row = _read_activity_log_row(main_module, snapshot_id)
assert row is not None
assert row["user_id"] == user["id"]
assert row["item_key"] == item_key
assert row["origin"] == "direct"
assert row["final_status"] == "complete"
snapshot = json.loads(row["snapshot_json"])
assert snapshot["kind"] == "download"
assert snapshot["download"]["id"] == task_id
finally:
main_module.backend.book_queue.cancel_download(task_id)
def test_complete_transition_records_requested_origin_for_graduated_request(self, main_module):
user = _create_user(main_module, prefix="snap-requested")
task_id = f"requested-{uuid.uuid4().hex[:8]}"
request_row = main_module.user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data={
"title": "Requested Snapshot",
"author": "Snapshot Author",
"provider": "openlibrary",
"provider_id": "snapshot-req",
},
release_data={
"source": "prowlarr",
"source_id": task_id,
"title": "Requested Snapshot.epub",
},
status="fulfilled",
delivery_state="queued",
)
task = DownloadTask(
task_id=task_id,
source="prowlarr",
title="Requested Snapshot",
user_id=user["id"],
username=user["username"],
)
assert main_module.backend.book_queue.add(task) is True
try:
main_module.backend.book_queue.update_status(task_id, QueueStatus.COMPLETE)
snapshot_id = main_module.activity_service.get_latest_activity_log_id(
item_type="download",
item_key=f"download:{task_id}",
)
assert snapshot_id is not None
row = _read_activity_log_row(main_module, snapshot_id)
assert row is not None
assert row["origin"] == "requested"
assert row["request_id"] == request_row["id"]
assert row["source_id"] == task_id
snapshot = json.loads(row["snapshot_json"])
assert snapshot["download"]["id"] == task_id
assert snapshot["request"]["id"] == request_row["id"]
finally:
main_module.backend.book_queue.cancel_download(task_id)
def test_complete_transition_snapshot_uses_latest_terminal_status_message(self, main_module):
user = _create_user(main_module, prefix="snap-message")
task_id = f"message-{uuid.uuid4().hex[:8]}"
task = DownloadTask(
task_id=task_id,
source="direct_download",
title="Message Snapshot",
user_id=user["id"],
username=user["username"],
)
assert main_module.backend.book_queue.add(task) is True
try:
# Simulate a stale in-progress message that used to leak into history snapshots.
main_module.backend.book_queue.update_status_message(task_id, "Moving file")
main_module.backend.update_download_status(task_id, "complete", "Complete")
snapshot_id = main_module.activity_service.get_latest_activity_log_id(
item_type="download",
item_key=f"download:{task_id}",
)
assert snapshot_id is not None
row = _read_activity_log_row(main_module, snapshot_id)
assert row is not None
snapshot = json.loads(row["snapshot_json"])
assert snapshot["download"]["status_message"] == "Complete"
finally:
main_module.backend.book_queue.cancel_download(task_id)

View File

@@ -8,10 +8,13 @@ existing contracts.
from __future__ import annotations
import importlib
import uuid
from unittest.mock import patch
import pytest
from shelfmark.core.models import DownloadTask
@pytest.fixture(scope="module")
def main_module():
@@ -42,6 +45,11 @@ def _set_authenticated_session(
sess["db_user_id"] = db_user_id
def _create_user(main_module, *, prefix: str, role: str = "user") -> dict:
username = f"{prefix}-{uuid.uuid4().hex[:8]}"
return main_module.user_db.create_user(username=username, role=role)
class TestDownloadEndpointGuardrails:
def test_missing_book_id_returns_400_and_does_not_queue(self, main_module, client):
with patch.object(main_module, "get_auth_mode", return_value="none"):
@@ -211,6 +219,148 @@ class TestReleaseDownloadEndpointGuardrails:
mock_queue_release.assert_not_called()
class TestCancelDownloadEndpointGuardrails:
def test_owner_can_cancel_direct_download(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_authenticated_session(
client,
user_id=user["username"],
db_user_id=user["id"],
is_admin=False,
)
task = DownloadTask(
task_id="direct-task-1",
source="direct_download",
title="Direct Task",
user_id=user["id"],
username=user["username"],
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend.book_queue, "get_task", return_value=task):
with patch.object(main_module.backend, "cancel_download", return_value=True) as mock_cancel:
resp = client.delete("/api/download/direct-task-1/cancel")
assert resp.status_code == 200
assert resp.get_json() == {"status": "cancelled", "book_id": "direct-task-1"}
mock_cancel.assert_called_once_with("direct-task-1")
def test_non_owner_cannot_cancel_download(self, main_module, client):
owner = _create_user(main_module, prefix="owner")
actor = _create_user(main_module, prefix="actor")
_set_authenticated_session(
client,
user_id=actor["username"],
db_user_id=actor["id"],
is_admin=False,
)
task = DownloadTask(
task_id="owned-task-1",
source="direct_download",
title="Owned Task",
user_id=owner["id"],
username=owner["username"],
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend.book_queue, "get_task", return_value=task):
with patch.object(main_module.backend, "cancel_download", return_value=True) as mock_cancel:
resp = client.delete("/api/download/owned-task-1/cancel")
assert resp.status_code == 403
assert resp.get_json()["code"] == "download_not_owned"
mock_cancel.assert_not_called()
def test_owner_cannot_cancel_graduated_request_download(self, main_module, client):
user = _create_user(main_module, prefix="requester")
_set_authenticated_session(
client,
user_id=user["username"],
db_user_id=user["id"],
is_admin=False,
)
main_module.user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data={
"title": "Requested Book",
"author": "Request Author",
"provider": "openlibrary",
"provider_id": "req-guard-1",
},
release_data={
"source": "prowlarr",
"source_id": "requested-task-1",
"title": "Requested Book.epub",
},
status="fulfilled",
delivery_state="queued",
)
task = DownloadTask(
task_id="requested-task-1",
source="prowlarr",
title="Requested Book",
user_id=user["id"],
username=user["username"],
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend.book_queue, "get_task", return_value=task):
with patch.object(main_module.backend, "cancel_download", return_value=True) as mock_cancel:
resp = client.delete("/api/download/requested-task-1/cancel")
assert resp.status_code == 403
assert resp.get_json()["code"] == "requested_download_cancel_forbidden"
mock_cancel.assert_not_called()
def test_admin_can_cancel_graduated_request_download(self, main_module, client):
admin = _create_user(main_module, prefix="admin", role="admin")
requester = _create_user(main_module, prefix="requester")
_set_authenticated_session(
client,
user_id=admin["username"],
db_user_id=admin["id"],
is_admin=True,
)
main_module.user_db.create_request(
user_id=requester["id"],
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data={
"title": "Admin Requested Book",
"author": "Admin Request Author",
"provider": "openlibrary",
"provider_id": "req-guard-2",
},
release_data={
"source": "prowlarr",
"source_id": "requested-task-2",
"title": "Admin Requested Book.epub",
},
status="fulfilled",
delivery_state="queued",
)
task = DownloadTask(
task_id="requested-task-2",
source="prowlarr",
title="Admin Requested Book",
user_id=requester["id"],
username=requester["username"],
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend.book_queue, "get_task", return_value=task):
with patch.object(main_module.backend, "cancel_download", return_value=True) as mock_cancel:
resp = client.delete("/api/download/requested-task-2/cancel")
assert resp.status_code == 200
assert resp.get_json() == {"status": "cancelled", "book_id": "requested-task-2"}
mock_cancel.assert_called_once_with("requested-task-2")
class TestStatusEndpointGuardrails:
def test_no_auth_allows_without_session_and_returns_status(self, main_module, client):
observed: dict[str, object] = {}

View File

@@ -70,9 +70,9 @@ class TestSettingsRestrictionPolicy:
def test_default_is_admin_restricted(self):
assert should_restrict_settings_to_admin({}) is True
def test_respects_global_users_toggle(self):
def test_restriction_is_always_enabled(self):
assert should_restrict_settings_to_admin({"RESTRICT_SETTINGS_TO_ADMIN": True}) is True
assert should_restrict_settings_to_admin({"RESTRICT_SETTINGS_TO_ADMIN": False}) is False
assert should_restrict_settings_to_admin({"RESTRICT_SETTINGS_TO_ADMIN": False}) is True
def test_extracts_settings_tab_from_path(self):
assert get_settings_tab_from_path("/api/settings/security") == "security"
@@ -84,11 +84,11 @@ class TestSettingsRestrictionPolicy:
assert requires_admin_for_settings_access("/api/settings/security", users_config) is True
assert requires_admin_for_settings_access("/api/settings/users", users_config) is True
def test_other_tabs_follow_global_toggle(self):
def test_other_tabs_also_require_admin(self):
assert requires_admin_for_settings_access(
"/api/settings/general",
{"RESTRICT_SETTINGS_TO_ADMIN": False},
) is False
) is True
assert requires_admin_for_settings_access(
"/api/settings/general",
{"RESTRICT_SETTINGS_TO_ADMIN": True},
@@ -112,13 +112,13 @@ class TestAuthCheckAdminStatus:
)
assert result is False
def test_authenticated_user_when_not_restricted(self):
def test_authenticated_non_admin_user_is_not_admin(self):
result = get_auth_check_admin_status(
"proxy",
{"RESTRICT_SETTINGS_TO_ADMIN": False},
{"user_id": "user", "is_admin": False},
)
assert result is True
assert result is False
def test_unauthenticated_is_never_admin(self):
result = get_auth_check_admin_status(

View File

@@ -5,7 +5,7 @@ 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.models import DownloadTask, QueueStatus
from shelfmark.core.queue import BookQueue
@@ -116,6 +116,39 @@ class TestQueueFilterByUser:
# User 1 sees their own + legacy (no user_id)
assert len(all_tasks) == 2
def test_clear_completed_for_user_only_removes_user_terminal_tasks(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))
q.update_status("book-1", QueueStatus.COMPLETE)
q.update_status("book-2", QueueStatus.ERROR)
q.update_status("book-3", QueueStatus.QUEUED)
removed = q.clear_completed(user_id=1)
assert removed == 1
status = q.get_status()
all_tasks = {}
for tasks_by_status in status.values():
all_tasks.update(tasks_by_status)
assert "book-1" not in all_tasks
assert "book-2" in all_tasks
assert "book-3" in all_tasks
def test_clear_completed_for_user_includes_legacy_tasks(self):
q = BookQueue()
q.add(self._make_task("legacy-book", user_id=None))
q.add(self._make_task("user-book", user_id=1))
q.update_status("legacy-book", QueueStatus.COMPLETE)
q.update_status("user-book", QueueStatus.COMPLETE)
removed = q.clear_completed(user_id=1)
assert removed == 2
# ---------------------------------------------------------------------------
# Per-user destination override in get_final_destination

View File

@@ -58,6 +58,14 @@ def _policy(
}
def _read_activity_log_row(main_module, snapshot_id: int):
conn = main_module.user_db._connect()
try:
return conn.execute("SELECT * FROM activity_log WHERE id = ?", (snapshot_id,)).fetchone()
finally:
conn.close()
class TestDownloadPolicyGuards:
def test_download_endpoint_blocks_before_queue_when_policy_requires_request(self, main_module, client):
user = _create_user(main_module, prefix="reader")
@@ -194,6 +202,17 @@ class TestRequestRoutes:
assert cancel_resp.status_code == 200
assert cancel_resp.json["status"] == "cancelled"
snapshot_id = main_module.activity_service.get_latest_activity_log_id(
item_type="request",
item_key=f"request:{request_id}",
)
assert snapshot_id is not None
log_row = _read_activity_log_row(main_module, snapshot_id)
assert log_row is not None
assert log_row["user_id"] == user["id"]
assert log_row["final_status"] == "cancelled"
assert log_row["origin"] == "request"
def test_create_request_emits_websocket_events(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
@@ -498,6 +517,17 @@ class TestRequestRoutes:
assert reject_again_resp.status_code == 409
assert reject_again_resp.json["code"] == "stale_transition"
snapshot_id = main_module.activity_service.get_latest_activity_log_id(
item_type="request",
item_key=f"request:{request_id}",
)
assert snapshot_id is not None
log_row = _read_activity_log_row(main_module, snapshot_id)
assert log_row is not None
assert log_row["user_id"] == user["id"]
assert log_row["final_status"] == "rejected"
assert log_row["origin"] == "request"
def test_admin_reject_emits_update_to_user_and_admin_rooms(self, main_module, client):
user = _create_user(main_module, prefix="reader")
admin = _create_user(main_module, prefix="admin", role="admin")
@@ -1638,3 +1668,123 @@ class TestDownloadPolicyGuardsExtended:
})
assert resp.status_code == 200
def test_clear_queue_does_not_mutate_fulfilled_request_delivery_state(main_module, client):
user = _create_user(main_module, prefix="reader")
admin = _create_user(main_module, prefix="admin", role="admin")
_set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True)
created = main_module.user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data={
"title": "Clear Delivery State",
"author": "QA",
"content_type": "ebook",
"provider": "openlibrary",
"provider_id": "ol-clear-delivery",
},
release_data={
"source": "prowlarr",
"source_id": "clear-delivery-source-id",
"title": "Clear Delivery State.epub",
},
status="fulfilled",
delivery_state="complete",
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.ws_manager, "is_enabled", return_value=False):
with patch.object(main_module.ws_manager, "broadcast_status_update"):
with patch.object(main_module.backend, "queue_status", return_value={}) as mock_queue_status:
with patch.object(main_module.backend, "clear_completed", return_value=1) as mock_clear_completed:
resp = client.delete("/api/queue/clear")
assert resp.status_code == 200
assert resp.json["status"] == "cleared"
assert resp.json["removed_count"] == 1
assert mock_queue_status.call_args_list[0].kwargs == {}
mock_clear_completed.assert_called_once_with(user_id=None)
refreshed = main_module.user_db.get_request(created["id"])
assert refreshed["delivery_state"] == "complete"
def test_non_admin_clear_queue_is_scoped_without_mutating_request_delivery_state(main_module, client):
alice = _create_user(main_module, prefix="alice")
bob = _create_user(main_module, prefix="bob")
_set_session(client, user_id=alice["username"], db_user_id=alice["id"], is_admin=False)
alice_request = main_module.user_db.create_request(
user_id=alice["id"],
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data={
"title": "Alice Clear Scope",
"author": "QA",
"content_type": "ebook",
"provider": "openlibrary",
"provider_id": "ol-alice-scope",
},
release_data={
"source": "prowlarr",
"source_id": "shared-clear-scope-source-id",
"title": "Alice Scope.epub",
},
status="fulfilled",
delivery_state="complete",
)
bob_request = main_module.user_db.create_request(
user_id=bob["id"],
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data={
"title": "Bob Clear Scope",
"author": "QA",
"content_type": "ebook",
"provider": "openlibrary",
"provider_id": "ol-bob-scope",
},
release_data={
"source": "prowlarr",
"source_id": "shared-clear-scope-source-id",
"title": "Bob Scope.epub",
},
status="fulfilled",
delivery_state="complete",
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.ws_manager, "is_enabled", return_value=False):
with patch.object(main_module.ws_manager, "broadcast_status_update"):
with patch.object(main_module.backend, "queue_status", return_value={}) as mock_queue_status:
with patch.object(main_module.backend, "clear_completed", return_value=1) as mock_clear_completed:
resp = client.delete("/api/queue/clear")
assert resp.status_code == 200
assert resp.json["status"] == "cleared"
assert resp.json["removed_count"] == 1
assert mock_queue_status.call_args_list[0].kwargs == {}
mock_clear_completed.assert_called_once_with(user_id=alice["id"])
refreshed_alice = main_module.user_db.get_request(alice_request["id"])
refreshed_bob = main_module.user_db.get_request(bob_request["id"])
assert refreshed_alice["delivery_state"] == "complete"
assert refreshed_bob["delivery_state"] == "complete"
def test_non_admin_clear_queue_without_db_user_id_returns_403(main_module, client):
_set_session(client, user_id="reader-no-db", db_user_id=None, is_admin=False)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend, "clear_completed") as mock_clear_completed:
resp = client.delete("/api/queue/clear")
assert resp.status_code == 403
assert resp.json["code"] == "user_identity_unavailable"
mock_clear_completed.assert_not_called()

View File

@@ -14,9 +14,11 @@ from shelfmark.core.requests_service import (
create_request,
fulfil_request,
normalize_policy_mode,
normalize_delivery_state,
normalize_request_level,
normalize_request_status,
reject_request,
sync_delivery_states_from_queue_status,
validate_request_level_payload,
validate_status_transition,
)
@@ -61,6 +63,16 @@ def test_normalize_request_status_rejects_unknown_values():
normalize_request_status("queued")
def test_normalize_delivery_state_accepts_known_values():
assert normalize_delivery_state("none") == "none"
assert normalize_delivery_state(" QUEUED ") == "queued"
def test_normalize_delivery_state_rejects_unknown_values():
with pytest.raises(ValueError, match="Invalid delivery_state"):
normalize_delivery_state("pending")
def test_normalize_policy_mode_accepts_strings_and_enum():
assert normalize_policy_mode("download") == "download"
assert normalize_policy_mode("REQUEST_BOOK") == "request_book"
@@ -370,6 +382,8 @@ def test_fulfil_request_queues_as_requesting_user(user_db):
)
assert fulfilled["status"] == "fulfilled"
assert fulfilled["delivery_state"] == "queued"
assert fulfilled["delivery_updated_at"] is not None
assert fulfilled["reviewed_by"] == admin["id"]
assert captured["priority"] == 0
assert captured["user_id"] == alice["id"]
@@ -411,6 +425,8 @@ def test_fulfil_book_level_request_stores_selected_release_data(user_db):
)
assert fulfilled["status"] == "fulfilled"
assert fulfilled["delivery_state"] == "queued"
assert fulfilled["delivery_updated_at"] is not None
assert fulfilled["request_level"] == "book"
assert fulfilled["release_data"]["source_id"] == "admin-picked-book-release"
assert captured["release_data"]["source_id"] == "admin-picked-book-release"
@@ -418,6 +434,50 @@ def test_fulfil_book_level_request_stores_selected_release_data(user_db):
assert captured["username"] == "alice"
def test_sync_delivery_states_from_queue_status_updates_matching_fulfilled_requests(user_db):
alice = user_db.create_user(username="alice")
bob = user_db.create_user(username="bob")
alice_request = user_db.create_request(
user_id=alice["id"],
source_hint="prowlarr",
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data=_book_data(),
release_data={"source": "prowlarr", "source_id": "alice-rel", "title": "Alice Release"},
status="fulfilled",
delivery_state="queued",
)
bob_request = user_db.create_request(
user_id=bob["id"],
source_hint="prowlarr",
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data=_book_data(),
release_data={"source": "prowlarr", "source_id": "bob-rel", "title": "Bob Release"},
status="fulfilled",
delivery_state="queued",
)
updated = sync_delivery_states_from_queue_status(
user_db,
queue_status={
"downloading": {"alice-rel": {"id": "alice-rel"}},
"complete": {"bob-rel": {"id": "bob-rel"}},
},
user_id=alice["id"],
)
assert [row["id"] for row in updated] == [alice_request["id"]]
refreshed_alice = user_db.get_request(alice_request["id"])
refreshed_bob = user_db.get_request(bob_request["id"])
assert refreshed_alice["delivery_state"] == "downloading"
assert refreshed_alice["delivery_updated_at"] is not None
assert refreshed_bob["delivery_state"] == "queued"
# ---------------------------------------------------------------------------
# book_data validation
# ---------------------------------------------------------------------------

View File

@@ -62,6 +62,18 @@ class TestUserDBInitialization:
assert cursor.fetchone() is not None
conn.close()
def test_initialize_creates_activity_tables(self, user_db, db_path):
conn = sqlite3.connect(db_path)
activity_log = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='activity_log'"
).fetchone()
dismissals = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='activity_dismissals'"
).fetchone()
assert activity_log is not None
assert dismissals is not None
conn.close()
def test_initialize_creates_download_requests_indexes(self, user_db, db_path):
conn = sqlite3.connect(db_path)
rows = conn.execute(
@@ -72,6 +84,22 @@ class TestUserDBInitialization:
assert "idx_download_requests_status_created_at" in index_names
conn.close()
def test_initialize_creates_activity_indexes(self, user_db, db_path):
conn = sqlite3.connect(db_path)
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='activity_log'"
).fetchall()
log_index_names = {row[0] for row in rows}
assert "idx_activity_log_user_terminal" in log_index_names
assert "idx_activity_log_lookup" in log_index_names
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='activity_dismissals'"
).fetchall()
dismissal_index_names = {row[0] for row in rows}
assert "idx_activity_dismissals_user_dismissed_at" in dismissal_index_names
conn.close()
def test_initialize_enables_wal_mode(self, user_db, db_path):
conn = sqlite3.connect(db_path)
cursor = conn.execute("PRAGMA journal_mode")

View File

@@ -191,7 +191,7 @@ class TestLoginRequiredDecorator:
assert resp[0]["success"] is True
def test_settings_access_not_restricted_when_global_toggle_off(self, main_module, view):
def test_settings_access_requires_admin_even_when_legacy_toggle_off(self, main_module, view):
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch(
"shelfmark.core.settings_registry.load_config_file",
@@ -201,9 +201,11 @@ class TestLoginRequiredDecorator:
main_module.session["user_id"] = "user"
main_module.session["is_admin"] = False
decorated = main_module.login_required(view)
resp = decorated()
resp = _as_response(decorated())
data = resp.get_json()
assert resp[0]["success"] is True
assert resp.status_code == 403
assert "Admin access required" in (data.get("error") or "")
def test_security_tab_always_blocks_non_admin_even_when_toggle_off(self, main_module, view):
with patch.object(main_module, "get_auth_mode", return_value="builtin"):