diff --git a/backend/app/auth.py b/backend/app/auth.py
index 945bc84..0ccb0c4 100644
--- a/backend/app/auth.py
+++ b/backend/app/auth.py
@@ -161,6 +161,8 @@ def _load_current_user_from_token(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
user = normalize_user_auth_provider(user)
+ from .feature_access import permissions
+ features = permissions(user)
if request is not None:
ip = _extract_client_ip(request)
@@ -168,6 +170,7 @@ def _load_current_user_from_token(
upsert_user_activity(user["username"], ip, user_agent)
return {
+ "features": features,
"username": user["username"],
"email": user.get("email"),
"role": user["role"],
diff --git a/backend/app/db.py b/backend/app/db.py
index 2e0492e..e2727a5 100644
--- a/backend/app/db.py
+++ b/backend/app/db.py
@@ -187,6 +187,9 @@ def _has_secure_bootstrap_admin_credentials() -> bool:
def init_db() -> None:
with _connect() as conn:
+ conn.execute("""CREATE TABLE IF NOT EXISTS user_feature_permissions (
+ user_id INTEGER NOT NULL, feature TEXT NOT NULL, enabled INTEGER NOT NULL,
+ PRIMARY KEY(user_id, feature))""")
conn.execute("""
CREATE TABLE IF NOT EXISTS jellyfin_user_links (
source TEXT NOT NULL, local_user_id INTEGER NOT NULL, jellyfin_user_id TEXT NOT NULL,
@@ -747,11 +750,16 @@ def init_db() -> None:
init_recap_schema(conn)
from .services.newsletter_store import init_schema as init_newsletter_schema
init_newsletter_schema(conn)
+ conn.execute("""CREATE TRIGGER IF NOT EXISTS delete_user_feature_permissions
+ AFTER DELETE ON users BEGIN
+ DELETE FROM user_feature_permissions WHERE user_id = OLD.id;
+ END""")
_backfill_auth_providers()
ensure_admin_user()
_backfill_request_repairs()
+
def start_request_repair(tracking: Dict[str, Any]) -> None:
"""Persist the new collection cycle before a managed file is removed."""
with _connect() as conn:
@@ -3823,21 +3831,23 @@ def list_portal_item_activity(item_id: int, *, limit: int = 300) -> list[Dict[st
]
-def get_portal_overview() -> Dict[str, Any]:
+def get_portal_overview(kind: Optional[str] = None) -> Dict[str, Any]:
with _connect() as conn:
kind_rows = conn.execute(
"""
SELECT kind, COUNT(*)
FROM portal_items
+ WHERE (? IS NULL OR kind = ?)
GROUP BY kind
- """
+ """, (kind, kind)
).fetchall()
status_rows = conn.execute(
"""
SELECT status, COUNT(*)
FROM portal_items
+ WHERE (? IS NULL OR kind = ?)
GROUP BY status
- """
+ """, (kind, kind)
).fetchall()
request_workflow_rows = conn.execute(
"""
@@ -3846,12 +3856,12 @@ def get_portal_overview() -> Dict[str, Any]:
COALESCE(workflow_media_status, ''),
COUNT(*)
FROM portal_items
- WHERE kind = 'request'
+ WHERE kind = 'request' AND (? IS NULL OR kind = ?)
GROUP BY workflow_request_status, workflow_media_status
- """
+ """, (kind, kind)
).fetchall()
- total_items_row = conn.execute("SELECT COUNT(*) FROM portal_items").fetchone()
- total_comments_row = conn.execute("SELECT COUNT(*) FROM portal_comments").fetchone()
+ total_items_row = conn.execute("SELECT COUNT(*) FROM portal_items WHERE (? IS NULL OR kind = ?)", (kind, kind)).fetchone()
+ total_comments_row = conn.execute("SELECT COUNT(*) FROM portal_comments c JOIN portal_items i ON i.id = c.item_id WHERE (? IS NULL OR i.kind = ?)", (kind, kind)).fetchone()
request_workflow: Dict[str, Dict[str, int]] = {}
for row in request_workflow_rows:
request_status = str(row[0] or "")
diff --git a/backend/app/feature_access.py b/backend/app/feature_access.py
new file mode 100644
index 0000000..43bb5d6
--- /dev/null
+++ b/backend/app/feature_access.py
@@ -0,0 +1,36 @@
+"""Live account permissions. Invite access uses the existing users column."""
+from .db import _connect
+
+FEATURES = ("stats", "requests", "new_requests", "issues", "invites")
+
+
+def permissions(user: dict) -> dict[str, bool]:
+ if user.get("role") == "admin":
+ return dict.fromkeys(FEATURES, True)
+ values = dict.fromkeys(FEATURES, True)
+ values["invites"] = bool(user.get("invite_management_enabled", False))
+ with _connect() as conn:
+ rows = conn.execute("""SELECT p.feature, p.enabled FROM user_feature_permissions p
+ JOIN users u ON u.id = p.user_id WHERE u.username = ? COLLATE NOCASE""",
+ (user.get("username", ""),)).fetchall()
+ values.update({key: bool(enabled) for key, enabled in rows if key in FEATURES and key != "invites"})
+ return values
+
+
+def update_permissions(changes: dict[str, bool], username: str | None = None) -> int:
+ if not changes or any(key not in FEATURES or type(value) is not bool for key, value in changes.items()):
+ raise ValueError("Choose valid features with true or false values")
+ with _connect() as conn:
+ conn.execute("BEGIN IMMEDIATE")
+ users = conn.execute("SELECT id FROM users WHERE role != 'admin'" +
+ (" AND username = ? COLLATE NOCASE" if username is not None else ""),
+ (username,) if username is not None else ()).fetchall()
+ for (user_id,) in users:
+ for feature, enabled in changes.items():
+ if feature == "invites":
+ conn.execute("UPDATE users SET invite_management_enabled = ? WHERE id = ?", (int(enabled), user_id))
+ else:
+ conn.execute("""INSERT INTO user_feature_permissions(user_id, feature, enabled) VALUES (?, ?, ?)
+ ON CONFLICT(user_id, feature) DO UPDATE SET enabled = excluded.enabled""",
+ (user_id, feature, int(enabled)))
+ return len(users)
diff --git a/backend/app/feature_guards.py b/backend/app/feature_guards.py
new file mode 100644
index 0000000..c4d3847
--- /dev/null
+++ b/backend/app/feature_guards.py
@@ -0,0 +1,69 @@
+from fastapi import Depends, HTTPException, Request
+from .auth import get_current_user, get_current_user_event_stream
+from .db import get_portal_item
+
+
+def check(user: dict, *features: str) -> None:
+ access = user.get("features") or {}
+ if user.get("role") == "admin":
+ return
+ if not any(access.get(feature, False) for feature in features):
+ raise HTTPException(status_code=403, detail="This feature is disabled for your account")
+
+
+def require_stats(user: dict = Depends(get_current_user)) -> dict:
+ check(user, "stats")
+ return user
+
+
+def require_invites(user: dict = Depends(get_current_user)) -> dict:
+ check(user, "invites")
+ return user
+
+
+def require_request_access(request: Request, user: dict = Depends(get_current_user)) -> None:
+ path = request.url.path.rstrip("/")
+ if path.endswith("/search") and "/actions/" not in path:
+ # The issue picker uses the same media search; creation is checked separately.
+ check(user, "new_requests", "issues")
+ elif path.endswith(("/create", "/request-options")):
+ check(user, "new_requests")
+ elif path.endswith(("/issue-options", "/replacement-options", "/actions/replace", "/actions/search-missing", "/actions/repair-subtitles")):
+ check(user, "issues")
+ else:
+ check(user, "requests")
+
+
+async def require_portal_access(request: Request, user: dict = Depends(get_current_user)) -> None:
+ if user.get("role") == "admin":
+ return
+ path = request.url.path.rstrip("/")
+ access = user.get("features", {})
+ if access.get("requests") and access.get("issues") and access.get("new_requests"):
+ return
+ if "/issues" in path:
+ check(user, "issues")
+ elif path.endswith("/requests") or path.endswith("/pipeline"):
+ check(user, "requests")
+ elif "item_id" in request.path_params:
+ try:
+ item = get_portal_item(int(request.path_params["item_id"]))
+ except (ValueError, TypeError):
+ item = None
+ if not item:
+ raise HTTPException(status_code=404, detail="Item not found")
+ check(user, "requests" if item.get("kind") == "request" else "issues")
+ elif path.endswith("/items") and request.method == "POST":
+ payload = await request.json()
+ check(user, "new_requests" if isinstance(payload, dict) and payload.get("kind", "request") == "request" else "issues")
+ elif path.endswith(("/items", "/overview")) and request.query_params.get("kind"):
+ check(user, "requests" if request.query_params["kind"] == "request" else "issues")
+ else:
+ # Unfiltered lists/overview can include both kinds.
+ check(user, "requests")
+ check(user, "issues")
+
+
+def require_request_stream(user: dict = Depends(get_current_user_event_stream)) -> dict:
+ check(user, "requests")
+ return user
diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py
index 0e6f49d..2445d57 100644
--- a/backend/app/routers/admin.py
+++ b/backend/app/routers/admin.py
@@ -1,3 +1,4 @@
+from ..feature_access import permissions, update_permissions
from typing import Any, Dict, List, Optional
from datetime import datetime, timedelta, timezone
import asyncio
@@ -1199,7 +1200,7 @@ async def list_users_summary() -> Dict[str, Any]:
username = user.get("username") or ""
username_norm = _normalize_username(username) if username else ""
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
- results.append({**user, "stats": stats})
+ results.append({**user, "features": permissions(user), "stats": stats})
return {"users": results}
@router.get("/users/{username}")
@@ -1209,7 +1210,7 @@ async def get_user_summary(username: str) -> Dict[str, Any]:
raise HTTPException(status_code=404, detail="User not found")
username_norm = _normalize_username(user.get("username") or "")
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
- return {"user": user, "stats": stats, "lineage": _user_inviter_details(user)}
+ return {"user": {**user, "features": permissions(user)}, "stats": stats, "lineage": _user_inviter_details(user)}
@router.get("/users/id/{user_id}")
@@ -1219,7 +1220,7 @@ async def get_user_summary_by_id(user_id: int) -> Dict[str, Any]:
raise HTTPException(status_code=404, detail="User not found")
username_norm = _normalize_username(user.get("username") or "")
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
- return {"user": user, "stats": stats, "lineage": _user_inviter_details(user)}
+ return {"user": {**user, "features": permissions(user)}, "stats": stats, "lineage": _user_inviter_details(user)}
@router.post("/users/{username}/block")
@@ -2122,3 +2123,26 @@ async def remove_invite(invite_id: int) -> Dict[str, Any]:
raise HTTPException(status_code=404, detail="Invite not found")
logger.warning("Admin deleted invite: invite_id=%s", invite_id)
return {"status": "ok", "deleted": True, "invite_id": invite_id}
+
+
+@router.put("/users/features/bulk")
+async def bulk_feature_permissions(payload: Dict[str, Any]) -> dict:
+ try:
+ updated = update_permissions(payload)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ return {"updated": updated, "scope": "non-admin-users"}
+
+
+@router.put("/users/{username}/features")
+async def user_feature_permissions(username: str, payload: Dict[str, Any]) -> dict:
+ user = get_user_by_username(username)
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+ if user.get("role") == "admin":
+ raise HTTPException(status_code=400, detail="Administrators always have all features")
+ try:
+ update_permissions(payload, username)
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ return {"features": permissions(get_user_by_username(username))}
diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py
index 4bf3a61..1603ed8 100644
--- a/backend/app/routers/auth.py
+++ b/backend/app/routers/auth.py
@@ -1,3 +1,4 @@
+from ..feature_guards import require_invites
from datetime import datetime, timedelta, timezone
from collections import defaultdict, deque
import logging
@@ -1233,7 +1234,7 @@ async def update_profile_email(payload: dict, current_user: dict = Depends(get_c
return {"status": "ok", "email": email}
-@router.get("/profile/invites")
+@router.get("/profile/invites", dependencies=[Depends(require_invites)])
async def profile_invites(current_user: dict = Depends(get_current_user)) -> dict:
username = str(current_user.get("username") or "").strip()
if not username:
diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py
index 881c48e..638689b 100644
--- a/backend/app/routers/events.py
+++ b/backend/app/routers/events.py
@@ -9,7 +9,9 @@ from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse
-from ..auth import get_current_user_event_stream
+from ..feature_guards import require_request_stream, check
+from ..feature_access import permissions
+from ..db import get_user_by_username
from . import requests as requests_router
router = APIRouter(prefix="/events", tags=["events"])
@@ -76,7 +78,7 @@ async def events_stream(
request: Request,
recent_days: int = 90,
recent_stage: str = "all",
- user: Dict[str, Any] = Depends(get_current_user_event_stream),
+ user: Dict[str, Any] = Depends(require_request_stream),
) -> StreamingResponse:
recent_days = max(0, min(int(recent_days or 90), 3650))
recent_take = 50 if user.get("role") == "admin" else 6
@@ -91,6 +93,13 @@ async def events_stream(
if await request.is_disconnected():
break
+ try:
+ account = get_user_by_username(user.get("username", ""))
+ if not account or account.get("is_blocked") or account.get("is_expired"):
+ break
+ check({**account, "features": permissions(account)}, "requests")
+ except HTTPException:
+ break
now = time.monotonic()
sent_any = False
@@ -148,7 +157,7 @@ async def events_stream(
async def request_events_stream(
request_id: str,
request: Request,
- user: Dict[str, Any] = Depends(get_current_user_event_stream),
+ user: Dict[str, Any] = Depends(require_request_stream),
) -> StreamingResponse:
request_id = str(request_id).strip()
if not request_id:
@@ -164,6 +173,13 @@ async def request_events_stream(
if await request.is_disconnected():
break
+ try:
+ account = get_user_by_username(user.get("username", ""))
+ if not account or account.get("is_blocked") or account.get("is_expired"):
+ break
+ check({**account, "features": permissions(account)}, "requests")
+ except HTTPException:
+ break
now = time.monotonic()
sent_any = False
diff --git a/backend/app/routers/insights.py b/backend/app/routers/insights.py
index 79b59c8..6c3f616 100644
--- a/backend/app/routers/insights.py
+++ b/backend/app/routers/insights.py
@@ -1,3 +1,4 @@
+from ..feature_guards import require_stats
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Response
@@ -10,7 +11,7 @@ from ..services.insights_artwork import get_artwork
from ..services.monthly_reports import get_monthly_report, report_csv
from ..runtime import get_runtime_settings
-router = APIRouter(prefix="/insights", tags=["insights"])
+router = APIRouter(prefix="/insights", tags=["insights"], dependencies=[Depends(require_stats)])
class MonthlyReportQuery(BaseModel):
diff --git a/backend/app/routers/portal.py b/backend/app/routers/portal.py
index 8953224..1b187af 100644
--- a/backend/app/routers/portal.py
+++ b/backend/app/routers/portal.py
@@ -1,4 +1,5 @@
from __future__ import annotations
+from ..feature_guards import require_portal_access
import logging
import re
@@ -33,7 +34,7 @@ from ..services.issue_resolution import (
from ..services.notifications import send_portal_notification
from ..runtime import get_runtime_settings
-router = APIRouter(prefix="/portal", tags=["portal"], dependencies=[Depends(get_current_user)])
+router = APIRouter(prefix="/portal", tags=["portal"], dependencies=[Depends(get_current_user), Depends(require_portal_access)])
logger = logging.getLogger(__name__)
PORTAL_KINDS = {"request", "issue", "feature"}
@@ -654,10 +655,11 @@ async def _notify(
@router.get("/overview")
-async def portal_overview(current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
- mine = count_portal_items(mine_username=str(current_user.get("username") or ""))
+async def portal_overview(kind: Optional[str] = None, current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
+ kind = _normalize_choice(kind, field="kind", allowed=PORTAL_KINDS, allow_empty=True)
+ mine = count_portal_items(kind=kind, mine_username=str(current_user.get("username") or ""))
return {
- "overview": get_portal_overview(),
+ "overview": get_portal_overview(kind) if kind else get_portal_overview(),
"my_items": mine,
}
diff --git a/backend/app/routers/recaps.py b/backend/app/routers/recaps.py
index a47735b..4e712d4 100644
--- a/backend/app/routers/recaps.py
+++ b/backend/app/routers/recaps.py
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import BaseModel, ConfigDict, Field, field_validator
from ..auth import get_current_user, require_admin
+from ..feature_guards import require_stats
from ..services import email_recaps as recaps, recap_store as store
@@ -65,7 +66,7 @@ def error(exc: recaps.RecapError):
@router.get("/profile/email-recaps")
-def preferences(user: dict = Depends(get_current_user)) -> dict:
+def preferences(user: dict = Depends(require_stats)) -> dict:
try:
return recaps.preferences(user)
except recaps.RecapError as exc:
@@ -73,7 +74,7 @@ def preferences(user: dict = Depends(get_current_user)) -> dict:
@router.put("/profile/email-recaps")
-async def preference(payload: Preference, user: dict = Depends(get_current_user)) -> dict:
+async def preference(payload: Preference, user: dict = Depends(require_stats)) -> dict:
try:
if payload.enabled:
return await recaps.subscribe(user, payload.automatic_monthly)
@@ -135,7 +136,7 @@ def test_email(payload: TestEmail, user: dict = Depends(require_admin)) -> dict:
@router.post('/profile/email-recaps/send', status_code=202)
-def email_personal_report(payload: TestEmail, user: dict = Depends(get_current_user)) -> dict:
+def email_personal_report(payload: TestEmail, user: dict = Depends(require_stats)) -> dict:
try:
return recaps.queue_personal(user, payload.month, str(payload.request_id))
except recaps.RecapError as exc:
diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py
index aa79055..b14b5d7 100644
--- a/backend/app/routers/requests.py
+++ b/backend/app/routers/requests.py
@@ -1,3 +1,4 @@
+from ..feature_guards import require_request_access
from typing import Any, Dict, List, Optional, Tuple
import asyncio
import httpx
@@ -65,7 +66,7 @@ from ..services.snapshot import (
jellyfin_item_matches_request,
)
-router = APIRouter(prefix="/requests", tags=["requests"], dependencies=[Depends(get_current_user)])
+router = APIRouter(prefix="/requests", tags=["requests"], dependencies=[Depends(get_current_user), Depends(require_request_access)])
CACHE_TTL_SECONDS = 600
_detail_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
diff --git a/backend/app/services/email_recaps.py b/backend/app/services/email_recaps.py
index f5484a3..902c207 100644
--- a/backend/app/services/email_recaps.py
+++ b/backend/app/services/email_recaps.py
@@ -178,6 +178,9 @@ def queue_test(user: dict, month: str | None, request_id: str) -> dict:
def eligible_delivery(delivery: dict) -> tuple[dict, dict]:
account = db.get_user_by_id(delivery["user_id"])
+ from ..feature_access import permissions
+ if not account or not permissions(account)["stats"]:
+ raise mail.DeliveryCancelled()
sub = active_subscription(account) if account else None
config = store.settings()
ready, _ = delivery_ready()
diff --git a/backend/tests/test_email_recaps.py b/backend/tests/test_email_recaps.py
index 09f91af..6e8b8d0 100644
--- a/backend/tests/test_email_recaps.py
+++ b/backend/tests/test_email_recaps.py
@@ -260,6 +260,20 @@ class RecapDeliveryTests(RecapFixture, unittest.IsolatedAsyncioTestCase):
await self.run_claim()
self.assertEqual(self.delivery(delivery_id)['state'], 'cancelled')
+ async def test_stats_permission_revoked_during_report_cancels_email(self):
+ from backend.app.feature_access import update_permissions
+ delivery_id = self.queue()
+ db.set_user_role('viewer', 'user')
+ async def report(*args):
+ update_permissions({'stats': False}, 'viewer')
+ return self.report
+ def transport(recipient, rendered, message_id, before_data):
+ before_data()
+ self.fail('Report must not be sent after stats permission is revoked')
+ with patch.object(recaps, 'get_monthly_report', side_effect=report), patch.object(mail, 'send_email', side_effect=transport):
+ await self.run_claim()
+ self.assertEqual(self.delivery(delivery_id)['state'], 'cancelled')
+
async def test_blocked_expired_and_deleted_accounts_are_not_sent(self):
for kind in ['blocked', 'expired', 'deleted']:
with self.subTest(kind=kind):
@@ -331,7 +345,7 @@ class RecapApiTests(RecapFixture, unittest.TestCase):
self.addCleanup(self.client.close)
def login(self, role='admin'):
- self.app.dependency_overrides[get_current_user] = lambda: {**self.user, 'role': role}
+ self.app.dependency_overrides[get_current_user] = lambda: {**self.user, 'role': role, 'features': {'stats': True}}
def test_authentication_roles_and_recipient_override(self):
self.assertEqual(self.client.get('/admin/email-recaps').status_code, 401)
@@ -550,7 +564,7 @@ class OnDemandReportTests(RecapFixture, unittest.IsolatedAsyncioTestCase):
async def test_regular_user_can_only_send_to_self(self):
self.subscribe()
app = FastAPI(); app.include_router(router.router)
- app.dependency_overrides[get_current_user] = lambda: {'username': 'viewer', 'role': 'user'}
+ app.dependency_overrides[get_current_user] = lambda: {'username': 'viewer', 'role': 'user', 'features': {'stats': True}}
client = TestClient(app)
body = {'month': self.report['month'], 'request_id': '11111111-1111-4111-8111-111111111111'}
for extra in [{'email': 'other@example.test'}, {'user_id': 42}, {'kind': 'scheduled'}]:
diff --git a/backend/tests/test_feature_access.py b/backend/tests/test_feature_access.py
new file mode 100644
index 0000000..6d52829
--- /dev/null
+++ b/backend/tests/test_feature_access.py
@@ -0,0 +1,110 @@
+import unittest
+from unittest.mock import patch
+from backend.app.config import settings
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+from backend.app import db
+from backend.app.feature_access import FEATURES, permissions, update_permissions
+from backend.app.routers import admin, auth, events, insights, portal, recaps, requests
+from backend.app.security import create_access_token
+from backend.tests.test_backend_quality import TempDatabaseMixin
+
+
+class FeatureAccessTests(TempDatabaseMixin, unittest.TestCase):
+ def setUp(self):
+ super().setUp()
+ secret = patch.object(settings, "jwt_secret", "feature-access-tests-only-secret-123456789")
+ secret.start()
+ self.addCleanup(secret.stop)
+ db.create_user('feature-viewer', 'Example-password123!', role='user')
+ db.create_user('feature-admin', 'Example-password123!', role='admin')
+ self.user = db.get_user_by_username('feature-viewer')
+ app = FastAPI()
+ for module in (admin, auth, events, insights, portal, recaps, requests):
+ app.include_router(module.router)
+ self.client = TestClient(app)
+ self.client.headers['Authorization'] = 'Bearer ' + create_access_token(self.user['username'], 'user')
+
+ def test_defaults_persist_and_invites_share_existing_setting(self):
+ self.assertEqual(permissions(self.user), dict(stats=True, requests=True, new_requests=True, issues=True, invites=False))
+ update_permissions({'stats': False, 'invites': True}, self.user['username'])
+ db.init_db()
+ fresh = db.get_user_by_username(self.user['username'])
+ self.assertTrue(fresh['invite_management_enabled'])
+ self.assertFalse(permissions(fresh)['stats'])
+ db.set_user_invite_management_enabled(self.user['username'], False)
+ self.assertFalse(permissions(db.get_user_by_username(self.user['username']))['invites'])
+
+ def test_all_feature_apis_reject_disabled_access_with_existing_token(self):
+ update_permissions(dict.fromkeys(FEATURES, False), self.user['username'])
+ endpoints = [
+ ('GET', '/insights', None), ('GET', '/insights/reports/monthly', None),
+ ('GET', '/insights/reports/monthly.csv', None), ('GET', '/insights/artwork/item?token=x', None),
+ ('GET', '/profile/email-recaps', None), ('POST', '/profile/email-recaps/send', {}),
+ ('GET', '/requests/recent', None), ('GET', '/requests/search?query=Movie', None),
+ ('GET', '/requests/request-options?mediaType=movie&tmdbId=1', None),
+ ('POST', '/requests/create', {'mediaType': 'movie', 'tmdbId': 1}),
+ ('GET', '/requests/1/snapshot', None), ('POST', '/requests/1/actions/search', {}),
+ ('GET', '/requests/1/issue-options', None), ('POST', '/requests/1/actions/replace', {}),
+ ('GET', '/portal/items?kind=issue', None), ('GET', '/portal/requests', None),
+ ('POST', '/portal/items', {'kind': 'issue'}), ('POST', '/portal/items', {'kind': 'request'}),
+ ('GET', '/portal/issues/media-status', None), ('POST', '/portal/requests/1/issues', {}),
+ ('GET', '/auth/profile/invites', None), ('POST', '/auth/profile/invites', {}),
+ ('PUT', '/auth/profile/invites/1', {}), ('DELETE', '/auth/profile/invites/1', None),
+ ('GET', '/events/stream', None), ('GET', '/events/requests/1/stream', None),
+ ]
+ for method, path, payload in endpoints:
+ with self.subTest(path=path, method=method):
+ self.assertEqual(self.client.request(method, path, json=payload).status_code, 403)
+ self.assertEqual(self.client.get('/auth/me').json()['features'], dict.fromkeys(FEATURES, False))
+ self.assertEqual(self.client.get('/auth/profile').status_code, 200)
+
+ def test_bulk_is_admin_only_strict_and_leaves_other_features_untouched(self):
+ self.assertEqual(self.client.put('/admin/users/features/bulk', json={'issues': False}).status_code, 403)
+ self.client.headers['Authorization'] = 'Bearer ' + create_access_token('feature-admin', 'admin')
+ for invalid in ({'issues': 'false'}, {'unknown': True}, {}):
+ self.assertEqual(self.client.put('/admin/users/features/bulk', json=invalid).status_code, 400)
+ response = self.client.put('/admin/users/features/bulk', json={'issues': False})
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.json()['updated'], 1)
+ self.assertFalse(permissions(self.user)['issues'])
+ self.assertTrue(permissions(self.user)['requests'])
+ self.assertTrue(all(permissions(db.get_user_by_username('feature-admin')).values()))
+ self.assertEqual(self.client.put('/admin/users/feature-admin/features', json={'stats': False}).status_code, 400)
+ self.assertEqual(self.client.put('/admin/users/missing/features', json={'stats': False}).status_code, 404)
+
+ def test_issue_and_request_item_routes_cannot_bypass_disabled_feature(self):
+ issue = db.create_portal_item(kind='issue', title='Problem', description='Problem', created_by_username=self.user['username'], created_by_id=self.user['id'])
+ update_permissions({'issues': False}, self.user['username'])
+ for path in (f'/portal/items/{issue["id"]}', f'/portal/items/{issue["id"]}/comments', '/portal/items', '/portal/overview'):
+ self.assertEqual(self.client.get(path).status_code, 403)
+ self.assertEqual(self.client.get('/portal/requests').status_code, 200)
+ self.assertEqual(self.client.get('/portal/items?kind=request').status_code, 200)
+ update_permissions({'issues': True, 'requests': False, 'new_requests': False}, self.user['username'])
+ self.assertEqual(self.client.get(f'/portal/items/{issue["id"]}').status_code, 200)
+ self.assertEqual(self.client.get('/portal/items?kind=issue').status_code, 200)
+ overview = self.client.get('/portal/overview?kind=issue')
+ self.assertEqual(overview.status_code, 200)
+ self.assertEqual(overview.json()['overview']['by_kind'], {'issue': 1})
+ self.assertEqual(self.client.post('/requests/create', json={'mediaType': 'movie', 'tmdbId': 1}).status_code, 403)
+
+ def test_deleted_account_does_not_leave_permissions_for_reused_id(self):
+ update_permissions({'stats': False}, self.user['username'])
+ db.delete_user_by_username(self.user['username'])
+ with db._connect() as conn:
+ self.assertEqual(conn.execute('SELECT COUNT(*) FROM user_feature_permissions').fetchone()[0], 0)
+
+
+ def test_open_request_stream_closes_after_permission_revocation(self):
+ import asyncio
+ from unittest.mock import AsyncMock
+ from types import SimpleNamespace
+ async def scenario():
+ request = SimpleNamespace(is_disconnected=AsyncMock(return_value=False))
+ response = await events.events_stream(request, user={**self.user, "features": permissions(self.user)})
+ iterator = response.body_iterator
+ self.assertIn('retry', await anext(iterator))
+ update_permissions({'requests': False}, self.user['username'])
+ with self.assertRaises(StopAsyncIteration):
+ await anext(iterator)
+ asyncio.run(scenario())
diff --git a/backend/tests/test_insights.py b/backend/tests/test_insights.py
index e0740c4..c8bb35b 100644
--- a/backend/tests/test_insights.py
+++ b/backend/tests/test_insights.py
@@ -177,7 +177,7 @@ class InsightsRouteTests(unittest.TestCase):
app = FastAPI()
app.include_router(router.router)
if authenticated:
- app.dependency_overrides[router.get_current_user] = lambda: USER
+ app.dependency_overrides[router.get_current_user] = lambda: {**USER, "features": {"stats": True}}
return TestClient(app)
def test_requires_authentication(self):
@@ -190,7 +190,7 @@ class InsightsRouteTests(unittest.TestCase):
response = client.get(f"/insights?days={days}")
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.headers["cache-control"], "no-store")
- report.assert_awaited_with(USER, 365)
+ report.assert_awaited_with({**USER, "features": {"stats": True}}, 365)
for query in ["days=-1", "days=999999", "days=invalid", "userid=other", "user_id=other", "scope=server"]:
self.assertEqual(client.get(f"/insights?{query}").status_code, 422, query)
diff --git a/backend/tests/test_insights_media.py b/backend/tests/test_insights_media.py
index 6ad38c6..08c61a5 100644
--- a/backend/tests/test_insights_media.py
+++ b/backend/tests/test_insights_media.py
@@ -154,7 +154,7 @@ class ArtworkRouteTests(unittest.TestCase):
app.include_router(router.router)
client = TestClient(app)
self.assertEqual(client.get(f"/insights/artwork/{ITEM}?token=invalid").status_code, 401)
- app.dependency_overrides[router.get_current_user] = lambda: USER
+ app.dependency_overrides[router.get_current_user] = lambda: {**USER, "features": {"stats": True}}
with patch.object(router, "get_runtime_settings", return_value=None), \
patch.object(router, "get_artwork", new_callable=AsyncMock, return_value=(PNG, "image/png")):
result = client.get(f"/insights/artwork/{ITEM}?token=fixture")
diff --git a/backend/tests/test_monthly_reports.py b/backend/tests/test_monthly_reports.py
index ecdc238..81b300a 100644
--- a/backend/tests/test_monthly_reports.py
+++ b/backend/tests/test_monthly_reports.py
@@ -175,7 +175,7 @@ class MonthlyReportRouteTests(unittest.TestCase):
app = FastAPI()
app.include_router(router.router)
if authenticated:
- app.dependency_overrides[router.get_current_user] = lambda: USER
+ app.dependency_overrides[router.get_current_user] = lambda: {**USER, "features": {"stats": True}}
return TestClient(app)
def test_both_formats_require_auth_and_reject_scope_overrides(self):
@@ -189,7 +189,7 @@ class MonthlyReportRouteTests(unittest.TestCase):
patch.object(router, 'report_csv', return_value='\ufeffMetric,Value\r\nMinutes,60\r\n'):
response = self.client().get('/insights/reports/monthly?month=2026-08')
self.assertEqual(response.headers['cache-control'], 'no-store')
- report.assert_awaited_with(USER, '2026-08')
+ report.assert_awaited_with({**USER, "features": {"stats": True}}, '2026-08')
response = self.client().get('/insights/reports/monthly.csv?month=2026-08')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers['content-type'], 'text/csv; charset=utf-8')
diff --git a/backend/tests/test_portal_privacy.py b/backend/tests/test_portal_privacy.py
index 87616ad..84882d6 100644
--- a/backend/tests/test_portal_privacy.py
+++ b/backend/tests/test_portal_privacy.py
@@ -15,7 +15,7 @@ class PortalPrivacyTests(unittest.TestCase):
'message': 'Sent to secret@example.com for private-reporter', 'is_internal': False}
app = FastAPI()
app.include_router(portal.router)
- app.dependency_overrides[portal.get_current_user] = lambda: {'username': 'viewer', 'role': 'user'}
+ app.dependency_overrides[portal.get_current_user] = lambda: {'username': 'viewer', 'role': 'user', 'features': {'issues': True, 'requests': True, 'new_requests': True}}
with patch.object(portal, 'get_portal_item', return_value=item), \
patch.object(portal, '_list_portal_comments', return_value=[comment]), \
patch.object(portal, 'list_portal_item_activity', return_value=[]), \
diff --git a/docs/user-feature-access.md b/docs/user-feature-access.md
new file mode 100644
index 0000000..3a0e268
--- /dev/null
+++ b/docs/user-feature-access.md
@@ -0,0 +1,27 @@
+# User feature access
+
+In **Configuration → User management → Manage users**, the Feature access checkboxes apply to all existing non-admin accounts. A mixed checkbox means some accounts have access. Only changed checkboxes are saved; search filters do not restrict the bulk operation. New accounts retain the default access described below.
+
+Open a user and choose **Manage this user** to change individual permissions, contact email, role, automatic search/download, profile defaults or expiry. Request statistics remain on the main profile page. Administrators always have all features.
+
+| Feature | Access controlled |
+| --- | --- |
+| My Stats | Viewing statistics, report exports, report email preferences and delivery |
+| My Requests | Existing requests, progress, request actions and live request streams |
+| New Requests | Media request options and submission |
+| Issues | Issue lists, reporting, comments, resolution responses and issue repair actions |
+| Invites | Creating, viewing and managing personal invitations; existing limits still apply |
+
+Media search is shared by New Requests and the issue picker. Either permission allows search; only New Requests permits submission. The existing automatic search/download permission still applies in addition to feature access.
+
+Navigation and direct-page access use the authenticated account's permissions. APIs enforce them independently on each request. Open request streams recheck access, and report emails recheck Stats access before sending. Removing a permission does not erase existing records or unsend emails. The switches apply inside Magent and do not change Jellyfin or Seerr permissions.
+
+Existing users retain Stats, My Requests, New Requests and Issues access when upgrading. Invite access uses the existing `users.invite_management_enabled` column. Other overrides are stored by Magent user ID in `user_feature_permissions`; deletion of the account removes its overrides. The old site-wide navigation visibility setting is no longer used by the menus.
+
+The red account section distinguishes:
+
+- **Block Magent access:** prevent Magent sign-in and keep the account.
+- **Disable Magent and Jellyfin access:** block Magent, attempt to disable the same-name Jellyfin account, disable issued invitations and attempt a notification email. Seerr relies on Jellyfin sign-in; its account is not directly banned. Restoring access does not reactivate invitations.
+- **Delete Magent, Jellyfin and Seerr accounts:** remove Magent and local activity, attempt deletion of the same-name Jellyfin account and linked Seerr account, disable invitations and attempt notification. Media files and Jellystat history are retained. External actions can partially fail.
+
+Validation: backend permission tests use temporary databases and real signed tokens. `scripts/review_feature_access_ui.cjs` checks desktop/mobile profiles, dialogs, bulk scope and denied routes with intercepted API fixtures. Set `PLAYWRIGHT_PACKAGE` when Playwright is installed outside the project, and optionally `REVIEW_BASE` to target a deployed frontend.
diff --git a/frontend/app/admin/SettingsPage.tsx b/frontend/app/admin/SettingsPage.tsx
index 759ba9c..a35ff58 100644
--- a/frontend/app/admin/SettingsPage.tsx
+++ b/frontend/app/admin/SettingsPage.tsx
@@ -56,7 +56,6 @@ const BOOL_SETTINGS = new Set([
'site_login_show_local_login',
'site_login_show_forgot_password',
'site_login_show_signup_link',
- 'site_nav_show_requests',
'magent_proxy_enabled',
'magent_proxy_trust_forwarded_headers',
'magent_ssl_bind_enabled',
@@ -280,12 +279,6 @@ const SITE_SECTION_GROUPS: Array<{
'site_login_show_signup_link',
],
},
- {
- key: 'site-navigation',
- title: 'Navigation',
- description: 'Control new requests in the navigation.',
- keys: ['site_nav_show_requests'],
- },
]
const STANDARD_SECTION_GROUPS: Record<
@@ -839,7 +832,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source'])
const artworkSettingKeys = new Set(['artwork_cache_mode'])
const generatedSettingKeys = new Set(['site_changelog', 'site_build_number'])
- const hiddenSettingKeys = new Set([...cacheSettingKeys, ...artworkSettingKeys, ...generatedSettingKeys])
+ const hiddenSettingKeys = new Set(['site_nav_show_requests', ...cacheSettingKeys, ...artworkSettingKeys, ...generatedSettingKeys])
const obsoleteSettingKeys = new Set([
'sonarr_qbittorrent_category',
'radarr_qbittorrent_category',
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
index d9ebafe..2d88922 100644
--- a/frontend/app/layout.tsx
+++ b/frontend/app/layout.tsx
@@ -6,6 +6,7 @@ import './workspace.css'
import './portal/issue-flow.css'
import type { ReactNode } from 'react'
import BrandingFavicon from './ui/BrandingFavicon'
+import FeatureGate from './ui/FeatureGate'
import ApplicationChrome from './ui/ApplicationChrome'
export const metadata = {
@@ -20,7 +21,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {