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 }) {
- {children} + {children}
diff --git a/frontend/app/lib/features.ts b/frontend/app/lib/features.ts new file mode 100644 index 0000000..f3ee956 --- /dev/null +++ b/frontend/app/lib/features.ts @@ -0,0 +1,23 @@ +export const FEATURES = [ + { key: 'stats', label: 'My Stats', description: 'View personal viewing history, reports and request report emails.' }, + { key: 'requests', label: 'My Requests', description: 'View existing requests, their progress and request actions.' }, + { key: 'new_requests', label: 'New Requests', description: 'Search for movies and TV shows and submit new requests.' }, + { key: 'issues', label: 'Issues', description: 'Report problems, follow up on issues and use available repair tools.' }, + { key: 'invites', label: 'Invites', description: 'Create and manage invitations within the existing invite limits.' }, +] as const +export type Feature = typeof FEATURES[number]['key'] +export type FeatureAccess = Record +export function featureForPath(path: string): Feature | undefined { + if (path === '/insights' || path.startsWith('/insights/')) return 'stats' + if (path === '/' || path.startsWith('/requests/')) return 'requests' + if (path === '/new-requests') return 'new_requests' + if (path.startsWith('/issues/confirm/') || path.startsWith('/portal/issues')) return 'issues' + if (path.startsWith('/profile/invites')) return 'invites' + if (path === '/portal/requests') return 'requests' +} +export function canAccess(user: { role?: string; features?: Partial; invite_management_enabled?: boolean } | null, feature?: Feature) { + if (!feature) return true + if (!user) return false + if (user.role === 'admin') return true + return user.features?.[feature] ?? (feature === 'invites' ? Boolean(user.invite_management_enabled) : true) +} diff --git a/frontend/app/portal/PortalClient.tsx b/frontend/app/portal/PortalClient.tsx index 8dfc336..86b8f02 100644 --- a/frontend/app/portal/PortalClient.tsx +++ b/frontend/app/portal/PortalClient.tsx @@ -618,7 +618,7 @@ export default function PortalClient({ workspace }: PortalClientProps) { const loadOverview = async () => { try { const baseUrl = getApiBase() - const response = await authFetch(`${baseUrl}/portal/overview`) + const response = await authFetch(`${baseUrl}/portal/overview?kind=${workspace}`) if (!response.ok) { if (response.status === 401) { clearToken() diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index 8145405..ea9354e 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -1,5 +1,6 @@ 'use client' +import { canAccess, type FeatureAccess } from '../lib/features' import PageHeading from '../ui/PageHeading' import MonthlyRecapPreference from './MonthlyRecapPreference' import NewsletterPreference from './NewsletterPreference' @@ -9,6 +10,7 @@ import { useRouter } from 'next/navigation' import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' type ProfileInfo = { + features?: FeatureAccess username: string email?: string | null role: string @@ -199,7 +201,7 @@ export default function ProfilePage() {
- + {canAccess(user, 'stats') && } diff --git a/frontend/app/ui/FeatureGate.tsx b/frontend/app/ui/FeatureGate.tsx new file mode 100644 index 0000000..fc6b3be --- /dev/null +++ b/frontend/app/ui/FeatureGate.tsx @@ -0,0 +1,37 @@ +'use client' + +import { usePathname } from 'next/navigation' +import { useEffect, useState, type ReactNode } from 'react' +import { authFetch, getApiBase, getToken } from '../lib/auth' +import { canAccess, featureForPath, type FeatureAccess } from '../lib/features' + +export function useFeatureUser() { + const pathname = usePathname() + const [state, setState] = useState<{ path: string; user: { role?: string; features?: FeatureAccess; invite_management_enabled?: boolean } | null }>({ path: '', user: null }) + useEffect(() => { + let active = true + const load = async () => { + if (!getToken()) { if (active) setState({ path: pathname, user: null }); return } + try { + const response = await authFetch(`${getApiBase()}/auth/me`) + const user = response.ok ? await response.json() : null + if (active) setState({ path: pathname, user }) + } catch { if (active) setState({ path: pathname, user: null }) } + } + void load() + window.addEventListener('focus', load) + return () => { active = false; window.removeEventListener('focus', load) } + }, [pathname]) + return { user: state.user, ready: state.path === pathname } +} + +export default function FeatureGate({ children }: { children: ReactNode }) { + const pathname = usePathname() + const { user, ready } = useFeatureUser() + const feature = featureForPath(pathname) + if (!feature) return children + if (!ready) return
Loading account access...
+ if (!getToken()) return children + if (!canAccess(user, feature)) return

Feature unavailable

Your account does not have access to this feature. Ask an administrator if you need it enabled.

Go to my profile
+ return children +} diff --git a/frontend/app/ui/HeaderActions.tsx b/frontend/app/ui/HeaderActions.tsx index 11c5e07..10f24e1 100644 --- a/frontend/app/ui/HeaderActions.tsx +++ b/frontend/app/ui/HeaderActions.tsx @@ -1,54 +1,15 @@ 'use client' import { usePathname } from 'next/navigation' -import { useEffect, useState } from 'react' -import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' +import { canAccess, featureForPath } from '../lib/features' +import { useFeatureUser } from './FeatureGate' export default function HeaderActions() { - const [signedIn, setSignedIn] = useState(false) - const [role, setRole] = useState(null) - const [showRequestsNav, setShowRequestsNav] = useState(true) const pathname = usePathname() - - useEffect(() => { - const token = getToken() - setSignedIn(Boolean(token)) - if (!token) { - setShowRequestsNav(true) - return - } - const load = async () => { - try { - const baseUrl = getApiBase() - const [response, siteResponse] = await Promise.all([ - authFetch(`${baseUrl}/auth/me`), - fetch(`${baseUrl}/site/public`).catch(() => null), - ]) - if (!response.ok) { - clearToken() - setSignedIn(false) - setRole(null) - return - } - const data = await response.json() - setRole(data?.role ?? null) - if (siteResponse?.ok) { - const siteData = await siteResponse.json() - setShowRequestsNav(siteData?.navigation?.showRequests !== false) - } else { - setShowRequestsNav(true) - } - } catch (err) { - console.error(err) - setShowRequestsNav(true) - } - } - void load() - }, []) - - if (!signedIn) { - return null - } + const { user, ready } = useFeatureUser() + const role = user?.role ?? null + const showRequestsNav = canAccess(user, 'new_requests') + if (!ready || !user) return null const roleItems = role === null @@ -104,7 +65,7 @@ export default function HeaderActions() { const items = [ ...commonItems, ...roleItems, - ] + ].filter((item) => canAccess(user, featureForPath(item.href))) return (