Add user feature permissions and unified account management
Magent CI/CD / verify (push) Canceled after 1m19s
Magent CI/CD / deploy-prod (push) Canceled after 0s
Magent CI/CD / deploy-beta (push) Canceled after 0s

This commit is contained in:
2026-09-11 12:31:25 +12:00
parent e2be8b3872
commit ec0a866ef3
32 changed files with 650 additions and 214 deletions
+3
View File
@@ -161,6 +161,8 @@ def _load_current_user_from_token(
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
user = normalize_user_auth_provider(user) user = normalize_user_auth_provider(user)
from .feature_access import permissions
features = permissions(user)
if request is not None: if request is not None:
ip = _extract_client_ip(request) ip = _extract_client_ip(request)
@@ -168,6 +170,7 @@ def _load_current_user_from_token(
upsert_user_activity(user["username"], ip, user_agent) upsert_user_activity(user["username"], ip, user_agent)
return { return {
"features": features,
"username": user["username"], "username": user["username"],
"email": user.get("email"), "email": user.get("email"),
"role": user["role"], "role": user["role"],
+17 -7
View File
@@ -187,6 +187,9 @@ def _has_secure_bootstrap_admin_credentials() -> bool:
def init_db() -> None: def init_db() -> None:
with _connect() as conn: 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(""" conn.execute("""
CREATE TABLE IF NOT EXISTS jellyfin_user_links ( CREATE TABLE IF NOT EXISTS jellyfin_user_links (
source TEXT NOT NULL, local_user_id INTEGER NOT NULL, jellyfin_user_id TEXT NOT NULL, 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) init_recap_schema(conn)
from .services.newsletter_store import init_schema as init_newsletter_schema from .services.newsletter_store import init_schema as init_newsletter_schema
init_newsletter_schema(conn) 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() _backfill_auth_providers()
ensure_admin_user() ensure_admin_user()
_backfill_request_repairs() _backfill_request_repairs()
def start_request_repair(tracking: Dict[str, Any]) -> None: def start_request_repair(tracking: Dict[str, Any]) -> None:
"""Persist the new collection cycle before a managed file is removed.""" """Persist the new collection cycle before a managed file is removed."""
with _connect() as conn: 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: with _connect() as conn:
kind_rows = conn.execute( kind_rows = conn.execute(
""" """
SELECT kind, COUNT(*) SELECT kind, COUNT(*)
FROM portal_items FROM portal_items
WHERE (? IS NULL OR kind = ?)
GROUP BY kind GROUP BY kind
""" """, (kind, kind)
).fetchall() ).fetchall()
status_rows = conn.execute( status_rows = conn.execute(
""" """
SELECT status, COUNT(*) SELECT status, COUNT(*)
FROM portal_items FROM portal_items
WHERE (? IS NULL OR kind = ?)
GROUP BY status GROUP BY status
""" """, (kind, kind)
).fetchall() ).fetchall()
request_workflow_rows = conn.execute( request_workflow_rows = conn.execute(
""" """
@@ -3846,12 +3856,12 @@ def get_portal_overview() -> Dict[str, Any]:
COALESCE(workflow_media_status, ''), COALESCE(workflow_media_status, ''),
COUNT(*) COUNT(*)
FROM portal_items FROM portal_items
WHERE kind = 'request' WHERE kind = 'request' AND (? IS NULL OR kind = ?)
GROUP BY workflow_request_status, workflow_media_status GROUP BY workflow_request_status, workflow_media_status
""" """, (kind, kind)
).fetchall() ).fetchall()
total_items_row = conn.execute("SELECT COUNT(*) FROM portal_items").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").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]] = {} request_workflow: Dict[str, Dict[str, int]] = {}
for row in request_workflow_rows: for row in request_workflow_rows:
request_status = str(row[0] or "") request_status = str(row[0] or "")
+36
View File
@@ -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)
+69
View File
@@ -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
+27 -3
View File
@@ -1,3 +1,4 @@
from ..feature_access import permissions, update_permissions
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import asyncio import asyncio
@@ -1199,7 +1200,7 @@ async def list_users_summary() -> Dict[str, Any]:
username = user.get("username") or "" username = user.get("username") or ""
username_norm = _normalize_username(username) if username else "" username_norm = _normalize_username(username) if username else ""
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id")) 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} return {"users": results}
@router.get("/users/{username}") @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") raise HTTPException(status_code=404, detail="User not found")
username_norm = _normalize_username(user.get("username") or "") username_norm = _normalize_username(user.get("username") or "")
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id")) 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}") @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") raise HTTPException(status_code=404, detail="User not found")
username_norm = _normalize_username(user.get("username") or "") username_norm = _normalize_username(user.get("username") or "")
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id")) 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") @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") raise HTTPException(status_code=404, detail="Invite not found")
logger.warning("Admin deleted invite: invite_id=%s", invite_id) logger.warning("Admin deleted invite: invite_id=%s", invite_id)
return {"status": "ok", "deleted": True, "invite_id": 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))}
+2 -1
View File
@@ -1,3 +1,4 @@
from ..feature_guards import require_invites
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from collections import defaultdict, deque from collections import defaultdict, deque
import logging import logging
@@ -1233,7 +1234,7 @@ async def update_profile_email(payload: dict, current_user: dict = Depends(get_c
return {"status": "ok", "email": email} 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: async def profile_invites(current_user: dict = Depends(get_current_user)) -> dict:
username = str(current_user.get("username") or "").strip() username = str(current_user.get("username") or "").strip()
if not username: if not username:
+19 -3
View File
@@ -9,7 +9,9 @@ from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import StreamingResponse 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 from . import requests as requests_router
router = APIRouter(prefix="/events", tags=["events"]) router = APIRouter(prefix="/events", tags=["events"])
@@ -76,7 +78,7 @@ async def events_stream(
request: Request, request: Request,
recent_days: int = 90, recent_days: int = 90,
recent_stage: str = "all", recent_stage: str = "all",
user: Dict[str, Any] = Depends(get_current_user_event_stream), user: Dict[str, Any] = Depends(require_request_stream),
) -> StreamingResponse: ) -> StreamingResponse:
recent_days = max(0, min(int(recent_days or 90), 3650)) recent_days = max(0, min(int(recent_days or 90), 3650))
recent_take = 50 if user.get("role") == "admin" else 6 recent_take = 50 if user.get("role") == "admin" else 6
@@ -91,6 +93,13 @@ async def events_stream(
if await request.is_disconnected(): if await request.is_disconnected():
break 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() now = time.monotonic()
sent_any = False sent_any = False
@@ -148,7 +157,7 @@ async def events_stream(
async def request_events_stream( async def request_events_stream(
request_id: str, request_id: str,
request: Request, request: Request,
user: Dict[str, Any] = Depends(get_current_user_event_stream), user: Dict[str, Any] = Depends(require_request_stream),
) -> StreamingResponse: ) -> StreamingResponse:
request_id = str(request_id).strip() request_id = str(request_id).strip()
if not request_id: if not request_id:
@@ -164,6 +173,13 @@ async def request_events_stream(
if await request.is_disconnected(): if await request.is_disconnected():
break 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() now = time.monotonic()
sent_any = False sent_any = False
+2 -1
View File
@@ -1,3 +1,4 @@
from ..feature_guards import require_stats
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Response 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 ..services.monthly_reports import get_monthly_report, report_csv
from ..runtime import get_runtime_settings 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): class MonthlyReportQuery(BaseModel):
+6 -4
View File
@@ -1,4 +1,5 @@
from __future__ import annotations from __future__ import annotations
from ..feature_guards import require_portal_access
import logging import logging
import re import re
@@ -33,7 +34,7 @@ from ..services.issue_resolution import (
from ..services.notifications import send_portal_notification from ..services.notifications import send_portal_notification
from ..runtime import get_runtime_settings 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__) logger = logging.getLogger(__name__)
PORTAL_KINDS = {"request", "issue", "feature"} PORTAL_KINDS = {"request", "issue", "feature"}
@@ -654,10 +655,11 @@ async def _notify(
@router.get("/overview") @router.get("/overview")
async def portal_overview(current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]: async def portal_overview(kind: Optional[str] = None, current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
mine = count_portal_items(mine_username=str(current_user.get("username") or "")) 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 { return {
"overview": get_portal_overview(), "overview": get_portal_overview(kind) if kind else get_portal_overview(),
"my_items": mine, "my_items": mine,
} }
+4 -3
View File
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, Response
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field, field_validator
from ..auth import get_current_user, require_admin 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 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") @router.get("/profile/email-recaps")
def preferences(user: dict = Depends(get_current_user)) -> dict: def preferences(user: dict = Depends(require_stats)) -> dict:
try: try:
return recaps.preferences(user) return recaps.preferences(user)
except recaps.RecapError as exc: except recaps.RecapError as exc:
@@ -73,7 +74,7 @@ def preferences(user: dict = Depends(get_current_user)) -> dict:
@router.put("/profile/email-recaps") @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: try:
if payload.enabled: if payload.enabled:
return await recaps.subscribe(user, payload.automatic_monthly) 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) @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: try:
return recaps.queue_personal(user, payload.month, str(payload.request_id)) return recaps.queue_personal(user, payload.month, str(payload.request_id))
except recaps.RecapError as exc: except recaps.RecapError as exc:
+2 -1
View File
@@ -1,3 +1,4 @@
from ..feature_guards import require_request_access
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import asyncio import asyncio
import httpx import httpx
@@ -65,7 +66,7 @@ from ..services.snapshot import (
jellyfin_item_matches_request, 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 CACHE_TTL_SECONDS = 600
_detail_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {} _detail_cache: Dict[str, Tuple[float, Dict[str, Any]]] = {}
+3
View File
@@ -178,6 +178,9 @@ def queue_test(user: dict, month: str | None, request_id: str) -> dict:
def eligible_delivery(delivery: dict) -> tuple[dict, dict]: def eligible_delivery(delivery: dict) -> tuple[dict, dict]:
account = db.get_user_by_id(delivery["user_id"]) 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 sub = active_subscription(account) if account else None
config = store.settings() config = store.settings()
ready, _ = delivery_ready() ready, _ = delivery_ready()
+16 -2
View File
@@ -260,6 +260,20 @@ class RecapDeliveryTests(RecapFixture, unittest.IsolatedAsyncioTestCase):
await self.run_claim() await self.run_claim()
self.assertEqual(self.delivery(delivery_id)['state'], 'cancelled') 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): async def test_blocked_expired_and_deleted_accounts_are_not_sent(self):
for kind in ['blocked', 'expired', 'deleted']: for kind in ['blocked', 'expired', 'deleted']:
with self.subTest(kind=kind): with self.subTest(kind=kind):
@@ -331,7 +345,7 @@ class RecapApiTests(RecapFixture, unittest.TestCase):
self.addCleanup(self.client.close) self.addCleanup(self.client.close)
def login(self, role='admin'): 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): def test_authentication_roles_and_recipient_override(self):
self.assertEqual(self.client.get('/admin/email-recaps').status_code, 401) 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): async def test_regular_user_can_only_send_to_self(self):
self.subscribe() self.subscribe()
app = FastAPI(); app.include_router(router.router) 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) client = TestClient(app)
body = {'month': self.report['month'], 'request_id': '11111111-1111-4111-8111-111111111111'} body = {'month': self.report['month'], 'request_id': '11111111-1111-4111-8111-111111111111'}
for extra in [{'email': 'other@example.test'}, {'user_id': 42}, {'kind': 'scheduled'}]: for extra in [{'email': 'other@example.test'}, {'user_id': 42}, {'kind': 'scheduled'}]:
+110
View File
@@ -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())
+2 -2
View File
@@ -177,7 +177,7 @@ class InsightsRouteTests(unittest.TestCase):
app = FastAPI() app = FastAPI()
app.include_router(router.router) app.include_router(router.router)
if authenticated: 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) return TestClient(app)
def test_requires_authentication(self): def test_requires_authentication(self):
@@ -190,7 +190,7 @@ class InsightsRouteTests(unittest.TestCase):
response = client.get(f"/insights?days={days}") response = client.get(f"/insights?days={days}")
self.assertEqual(response.status_code, 200, response.text) self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.headers["cache-control"], "no-store") 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"]: 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) self.assertEqual(client.get(f"/insights?{query}").status_code, 422, query)
+1 -1
View File
@@ -154,7 +154,7 @@ class ArtworkRouteTests(unittest.TestCase):
app.include_router(router.router) app.include_router(router.router)
client = TestClient(app) client = TestClient(app)
self.assertEqual(client.get(f"/insights/artwork/{ITEM}?token=invalid").status_code, 401) 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), \ with patch.object(router, "get_runtime_settings", return_value=None), \
patch.object(router, "get_artwork", new_callable=AsyncMock, return_value=(PNG, "image/png")): patch.object(router, "get_artwork", new_callable=AsyncMock, return_value=(PNG, "image/png")):
result = client.get(f"/insights/artwork/{ITEM}?token=fixture") result = client.get(f"/insights/artwork/{ITEM}?token=fixture")
+2 -2
View File
@@ -175,7 +175,7 @@ class MonthlyReportRouteTests(unittest.TestCase):
app = FastAPI() app = FastAPI()
app.include_router(router.router) app.include_router(router.router)
if authenticated: 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) return TestClient(app)
def test_both_formats_require_auth_and_reject_scope_overrides(self): 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'): patch.object(router, 'report_csv', return_value='\ufeffMetric,Value\r\nMinutes,60\r\n'):
response = self.client().get('/insights/reports/monthly?month=2026-08') response = self.client().get('/insights/reports/monthly?month=2026-08')
self.assertEqual(response.headers['cache-control'], 'no-store') 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') response = self.client().get('/insights/reports/monthly.csv?month=2026-08')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.headers['content-type'], 'text/csv; charset=utf-8') self.assertEqual(response.headers['content-type'], 'text/csv; charset=utf-8')
+1 -1
View File
@@ -15,7 +15,7 @@ class PortalPrivacyTests(unittest.TestCase):
'message': 'Sent to secret@example.com for private-reporter', 'is_internal': False} 'message': 'Sent to secret@example.com for private-reporter', 'is_internal': False}
app = FastAPI() app = FastAPI()
app.include_router(portal.router) 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), \ with patch.object(portal, 'get_portal_item', return_value=item), \
patch.object(portal, '_list_portal_comments', return_value=[comment]), \ patch.object(portal, '_list_portal_comments', return_value=[comment]), \
patch.object(portal, 'list_portal_item_activity', return_value=[]), \ patch.object(portal, 'list_portal_item_activity', return_value=[]), \
+27
View File
@@ -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.
+1 -8
View File
@@ -56,7 +56,6 @@ const BOOL_SETTINGS = new Set([
'site_login_show_local_login', 'site_login_show_local_login',
'site_login_show_forgot_password', 'site_login_show_forgot_password',
'site_login_show_signup_link', 'site_login_show_signup_link',
'site_nav_show_requests',
'magent_proxy_enabled', 'magent_proxy_enabled',
'magent_proxy_trust_forwarded_headers', 'magent_proxy_trust_forwarded_headers',
'magent_ssl_bind_enabled', 'magent_ssl_bind_enabled',
@@ -280,12 +279,6 @@ const SITE_SECTION_GROUPS: Array<{
'site_login_show_signup_link', '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< 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 cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source'])
const artworkSettingKeys = new Set(['artwork_cache_mode']) const artworkSettingKeys = new Set(['artwork_cache_mode'])
const generatedSettingKeys = new Set(['site_changelog', 'site_build_number']) 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([ const obsoleteSettingKeys = new Set([
'sonarr_qbittorrent_category', 'sonarr_qbittorrent_category',
'radarr_qbittorrent_category', 'radarr_qbittorrent_category',
+2 -1
View File
@@ -6,6 +6,7 @@ import './workspace.css'
import './portal/issue-flow.css' import './portal/issue-flow.css'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import BrandingFavicon from './ui/BrandingFavicon' import BrandingFavicon from './ui/BrandingFavicon'
import FeatureGate from './ui/FeatureGate'
import ApplicationChrome from './ui/ApplicationChrome' import ApplicationChrome from './ui/ApplicationChrome'
export const metadata = { export const metadata = {
@@ -20,7 +21,7 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<BrandingFavicon /> <BrandingFavicon />
<div className="page"> <div className="page">
<ApplicationChrome /> <ApplicationChrome />
{children} <FeatureGate>{children}</FeatureGate>
</div> </div>
</body> </body>
</html> </html>
+23
View File
@@ -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<Feature, boolean>
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<FeatureAccess>; 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)
}
+1 -1
View File
@@ -618,7 +618,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const loadOverview = async () => { const loadOverview = async () => {
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/portal/overview`) const response = await authFetch(`${baseUrl}/portal/overview?kind=${workspace}`)
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { if (response.status === 401) {
clearToken() clearToken()
+3 -1
View File
@@ -1,5 +1,6 @@
'use client' 'use client'
import { canAccess, type FeatureAccess } from '../lib/features'
import PageHeading from '../ui/PageHeading' import PageHeading from '../ui/PageHeading'
import MonthlyRecapPreference from './MonthlyRecapPreference' import MonthlyRecapPreference from './MonthlyRecapPreference'
import NewsletterPreference from './NewsletterPreference' import NewsletterPreference from './NewsletterPreference'
@@ -9,6 +10,7 @@ import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type ProfileInfo = { type ProfileInfo = {
features?: FeatureAccess
username: string username: string
email?: string | null email?: string | null
role: string role: string
@@ -199,7 +201,7 @@ export default function ProfilePage() {
</div> </div>
</form> </form>
<div className="account-connected"><span className="account-connection-dot" aria-hidden="true" /><span>{user.auth_provider === 'jellyfin' ? 'Connected with your Grizzlyflix account' : user.auth_provider === 'local' ? 'Signed in with a Magent account' : 'Signed in with your media account'}</span></div> <div className="account-connected"><span className="account-connection-dot" aria-hidden="true" /><span>{user.auth_provider === 'jellyfin' ? 'Connected with your Grizzlyflix account' : user.auth_provider === 'local' ? 'Signed in with a Magent account' : 'Signed in with your media account'}</span></div>
<MonthlyRecapPreference key={user.email || 'no-email'} /> {canAccess(user, 'stats') && <MonthlyRecapPreference key={user.email || 'no-email'} />}
<NewsletterPreference key={`newsletter-${user.email || 'no-email'}`} /> <NewsletterPreference key={`newsletter-${user.email || 'no-email'}`} />
</section> </section>
+37
View File
@@ -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 <main className="card">Loading account access...</main>
if (!getToken()) return children
if (!canAccess(user, feature)) return <main className="card"><h1>Feature unavailable</h1><p>Your account does not have access to this feature. Ask an administrator if you need it enabled.</p><a href="/profile">Go to my profile</a></main>
return children
}
+7 -46
View File
@@ -1,54 +1,15 @@
'use client' 'use client'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react' import { canAccess, featureForPath } from '../lib/features'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' import { useFeatureUser } from './FeatureGate'
export default function HeaderActions() { export default function HeaderActions() {
const [signedIn, setSignedIn] = useState(false)
const [role, setRole] = useState<string | null>(null)
const [showRequestsNav, setShowRequestsNav] = useState(true)
const pathname = usePathname() const pathname = usePathname()
const { user, ready } = useFeatureUser()
useEffect(() => { const role = user?.role ?? null
const token = getToken() const showRequestsNav = canAccess(user, 'new_requests')
setSignedIn(Boolean(token)) if (!ready || !user) return null
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 roleItems = const roleItems =
role === null role === null
@@ -104,7 +65,7 @@ export default function HeaderActions() {
const items = [ const items = [
...commonItems, ...commonItems,
...roleItems, ...roleItems,
] ].filter((item) => canAccess(user, featureForPath(item.href)))
return ( return (
<nav className="header-actions" aria-label="Primary"> <nav className="header-actions" aria-label="Primary">
+6 -26
View File
@@ -1,8 +1,9 @@
'use client' 'use client'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { useEffect, useState } from 'react' import { getToken } from '../lib/auth'
import { authFetch, getApiBase, getToken } from '../lib/auth' import { canAccess, featureForPath } from '../lib/features'
import { useFeatureUser } from './FeatureGate'
type NavigationItem = { type NavigationItem = {
href: string href: string
@@ -38,36 +39,16 @@ function NavigationIcon({ name }: { name: NavigationItem['icon'] }) {
export default function WorkspaceNavigation() { export default function WorkspaceNavigation() {
const pathname = usePathname() const pathname = usePathname()
const [role, setRole] = useState<string | null>(null) const { user, ready } = useFeatureUser()
const [ready, setReady] = useState(false) const role = user?.role
const [showRequestsNav, setShowRequestsNav] = useState(true)
useEffect(() => {
const token = getToken()
if (!token) {
setReady(true)
return
}
Promise.all([
authFetch(`${getApiBase()}/auth/me`),
fetch(`${getApiBase()}/site/public`).catch(() => null),
])
.then(async ([response, siteResponse]) => {
if (response.ok) setRole((await response.json())?.role ?? 'user')
if (siteResponse?.ok) setShowRequestsNav((await siteResponse.json())?.navigation?.showRequests !== false)
})
.catch(() => undefined)
.finally(() => setReady(true))
}, [])
if (!ready || !getToken() || HIDDEN_ROUTES.some((route) => pathname.startsWith(route))) { if (!ready || !getToken() || HIDDEN_ROUTES.some((route) => pathname.startsWith(route))) {
return null return null
} }
const items = NAVIGATION.filter((item) => (!item.adminOnly || role === 'admin') && (showRequestsNav || item.href !== '/new-requests')) const items = NAVIGATION.filter((item) => (!item.adminOnly || role === 'admin') && canAccess(user, featureForPath(item.href)))
return ( return (
<>
<nav className="workspace-mobile-nav" aria-label="Mobile navigation"> <nav className="workspace-mobile-nav" aria-label="Mobile navigation">
{items.map((item) => ( {items.map((item) => (
<a key={item.href} href={item.href} className={item.match(pathname) ? 'is-active' : undefined}> <a key={item.href} href={item.href} className={item.match(pathname) ? 'is-active' : undefined}>
@@ -75,6 +56,5 @@ export default function WorkspaceNavigation() {
</a> </a>
))} ))}
</nav> </nav>
</>
) )
} }
+58
View File
@@ -0,0 +1,58 @@
'use client'
import { useEffect, useState } from 'react'
import { authFetch, getApiBase } from '../lib/auth'
import { FEATURES, type Feature, type FeatureAccess } from '../lib/features'
type Account = { username: string; role: string; features: FeatureAccess }
export default function FeatureControls({ username, onSaved }: { username?: string; onSaved: () => void }) {
const [accounts, setAccounts] = useState<Account[] | null>(null)
const [changes, setChanges] = useState<Partial<FeatureAccess>>({})
const [busy, setBusy] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const load = async () => {
const response = await authFetch(`${getApiBase()}/admin/users/${username ? encodeURIComponent(username) : 'summary'}`)
if (!response.ok) throw new Error('Could not load feature permissions.')
const data = await response.json()
setAccounts(username ? [data.user] : data.users.filter((user: Account) => user.role !== 'admin'))
}
useEffect(() => { void load().catch((err) => setError(err.message)) }, [username])
const save = async () => {
setBusy(true); setError(''); setMessage('')
try {
const response = await authFetch(`${getApiBase()}/admin/users/${username ? `${encodeURIComponent(username)}/features` : 'features/bulk'}`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(changes),
})
if (!response.ok) throw new Error((await response.json()).detail || 'Could not save permissions.')
const result = await response.json()
setChanges({})
setMessage(username ? 'Feature access saved.' : `Feature access saved for ${result.updated} non-admin accounts.`)
await load(); onSaved()
} catch (err) { setError(err instanceof Error ? err.message : 'Could not save permissions.') }
finally { setBusy(false) }
}
const admin = accounts?.some((account) => account.role === 'admin')
return <section className="user-management-panel feature-controls">
<h3>Feature access</h3>
<p>{username ? 'Choose which features this person can use in Magent.' : 'Apply feature access to every existing non-admin account, including users outside the current search. Only the checkboxes you change will be applied.'}</p>
<p>{admin ? 'Administrators always have access to all features.' : 'Changes take effect on the next page or API request. These permissions control Magent access; linked services keep their own permissions.'}</p>
{!accounts && !error && <p>Loading permissions...</p>}
{FEATURES.map(({ key, label, description }) => {
const enabled = accounts?.filter((account) => account.features?.[key]).length ?? 0
const mixed = !!accounts?.length && enabled > 0 && enabled < accounts.length
const changed = Object.hasOwn(changes, key)
return <label key={key} className="feature-access-row">
<input type="checkbox" ref={(input) => { if (input) input.indeterminate = !changed && mixed }}
checked={changes[key] ?? (!!accounts?.length && enabled === accounts.length)}
disabled={busy || !accounts?.length || admin}
onChange={(event) => setChanges((previous) => ({ ...previous, [key as Feature]: event.target.checked }))} />
<span><strong>{label}</strong><small>{description}</small>{!username && <small>{enabled} of {accounts?.length ?? 0} enabled{mixed && !changed ? ' · Mixed access' : ''}{changed ? ` · Will ${changes[key] ? 'enable' : 'disable'} for everyone` : ''}</small>}</span>
</label>
})}
{error && <p className="error-banner" role="alert">{error}</p>}
{message && <p className="status-banner" role="status">{message}</p>}
<div className="admin-inline-actions"><button type="button" disabled={busy || !Object.keys(changes).length} onClick={() => void save()}>{busy ? 'Saving...' : username ? 'Save feature access' : 'Apply changed features to all users'}</button><button type="button" className="ghost-button" disabled={busy || !Object.keys(changes).length} onClick={() => setChanges({})}>Reset changes</button></div>
</section>
}
+70 -74
View File
@@ -1,8 +1,10 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useParams, useRouter } from 'next/navigation' import { useParams, useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth' import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
import FeatureControls from '../FeatureControls'
import '../users.css'
import AdminShell from '../../ui/AdminShell' import AdminShell from '../../ui/AdminShell'
type UserStats = { type UserStats = {
@@ -91,6 +93,22 @@ const normalizeStats = (stats: any): UserStats => ({
}) })
export default function UserDetailPage() { export default function UserDetailPage() {
const [manageOpen, setManageOpen] = useState(false)
const managementDialog = useRef<HTMLDialogElement>(null)
const manageTrigger = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (!manageOpen) return
const dialog = managementDialog.current
if (!dialog) return
const overflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
dialog.showModal()
return () => {
dialog.close()
document.body.style.overflow = overflow
manageTrigger.current?.focus()
}
}, [manageOpen])
const params = useParams() const params = useParams()
const router = useRouter() const router = useRouter()
const idParam = Array.isArray(params?.id) ? params.id[0] : params?.id const idParam = Array.isArray(params?.id) ? params.id[0] : params?.id
@@ -286,30 +304,6 @@ export default function UserDetailPage() {
} }
} }
const updateInviteManagementEnabled = async (enabled: boolean) => {
if (!user) return
try {
setActionStatus(null)
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/invite-access`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
}
)
if (!response.ok) {
throw new Error('Update failed')
}
await loadUser()
setActionStatus(`Invite management ${enabled ? 'enabled' : 'disabled'} for this user.`)
} catch (err) {
console.error(err)
setError('Could not update invite access.')
}
}
const applyProfileToUser = async (profileOverride?: string | null) => { const applyProfileToUser = async (profileOverride?: string | null) => {
if (!user) return if (!user) return
const profileValue = profileOverride ?? profileSelection const profileValue = profileOverride ?? profileSelection
@@ -408,13 +402,13 @@ export default function UserDetailPage() {
if (!user) return if (!user) return
if (action === 'remove') { if (action === 'remove') {
const confirmed = window.confirm( const confirmed = window.confirm(
`Remove ${user.username} from Magent and external systems? This is destructive.` `Permanently delete ${user.username} from Magent, the same-name Jellyfin account and linked Seerr account, disable their invitations and attempt a notification email? This cannot be undone. Media files and Jellystat history are kept.`
) )
if (!confirmed) return if (!confirmed) return
} }
if (action === 'ban') { if (action === 'ban') {
const confirmed = window.confirm( const confirmed = window.confirm(
`Ban ${user.username} across systems and disable invites they created?` `Block ${user.username} in Magent, disable their same-name Jellyfin account and issued invitations, and attempt a notification email? Seerr relies on Jellyfin sign-in and is not directly banned.`
) )
if (!confirmed) return if (!confirmed) return
} }
@@ -475,9 +469,8 @@ export default function UserDetailPage() {
title={user?.username || 'User'} title={user?.username || 'User'}
subtitle="User overview and request stats." subtitle="User overview and request stats."
actions={ actions={
<button type="button" onClick={() => router.push('/users')}> <><button type="button" onClick={() => router.push('/users')}>Back to users</button>
Back to users <button ref={manageTrigger} type="button" disabled={!user} aria-haspopup="dialog" onClick={() => setManageOpen(true)}>Manage this user</button></>
</button>
} }
> >
<section className="admin-section"> <section className="admin-section">
@@ -486,7 +479,7 @@ export default function UserDetailPage() {
{!user ? ( {!user ? (
<div className="status-banner">No user data found.</div> <div className="status-banner">No user data found.</div>
) : ( ) : (
<div className="user-detail-page-grid"> <div className="user-detail-page-grid user-detail-centered">
<div className="user-detail-main-column"> <div className="user-detail-main-column">
<div className="admin-panel user-detail-panel"> <div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header"> <div className="user-detail-panel-header">
@@ -510,7 +503,7 @@ export default function UserDetailPage() {
</div> </div>
<div className="user-detail-meta-item"> <div className="user-detail-meta-item">
<span className="label">Seerr ID</span> <span className="label">Seerr ID</span>
<strong>{user.jellyseerr_user_id ?? user.id ?? 'Unknown'}</strong> <strong>{user.jellyseerr_user_id ?? 'Not linked'}</strong>
</div> </div>
<div className="user-detail-meta-item"> <div className="user-detail-meta-item">
<span className="label">Role</span> <span className="label">Role</span>
@@ -589,7 +582,13 @@ export default function UserDetailPage() {
</div> </div>
</div> </div>
<div className="user-detail-side-column"> <dialog ref={managementDialog} className="user-management-dialog" aria-labelledby="manage-this-user-title" onCancel={() => setManageOpen(false)} onClose={() => setManageOpen(false)}>
<div className="user-management-content">
<header className="user-management-heading"><div><h2 id="manage-this-user-title">Manage {user.username}</h2><p>Feature access, account settings and account restrictions.</p></div><button type="button" className="ghost-button" onClick={() => setManageOpen(false)}>Close</button></header>
{error && <p className="error-banner" role="alert">{error}</p>}
{actionStatus && <p className="status-banner" role="status">{actionStatus}</p>}
{manageOpen && <FeatureControls key={user.role} username={user.username} onSaved={() => void loadUser()} />}
<div className="user-management-grid">
<div className="admin-panel user-detail-panel"> <div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header"> <div className="user-detail-panel-header">
<h2>Contact email</h2> <h2>Contact email</h2>
@@ -660,48 +659,9 @@ export default function UserDetailPage() {
/> />
<span>Allow auto search/download</span> <span>Allow auto search/download</span>
</label> </label>
<label className="toggle">
<input
type="checkbox"
checked={Boolean(user.invite_management_enabled ?? false)}
disabled={user.role === 'admin'}
onChange={(event) => updateInviteManagementEnabled(event.target.checked)}
/>
<span>Allow self-service invites</span>
</label>
<button
type="button"
className="ghost-button"
onClick={() => toggleUserBlock(!user.is_blocked)}
disabled={systemActionBusy}
>
{user.is_blocked ? 'Allow access' : 'Block access'}
</button>
<div className="admin-inline-actions">
<button
type="button"
className="ghost-button"
onClick={() => void runSystemAction(user.is_blocked ? 'unban' : 'ban')}
disabled={systemActionBusy}
>
{systemActionBusy
? 'Working...'
: user.is_blocked
? 'Unban everywhere'
: 'Ban everywhere'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => void runSystemAction('remove')}
disabled={systemActionBusy}
>
Remove everywhere
</button>
</div>
{user.role === 'admin' && ( {user.role === 'admin' && (
<div className="user-detail-helper"> <div className="user-detail-helper">
Admins always have auto search/download and invite-management access. Admins always have automatic search/download and all features.
</div> </div>
)} )}
</div> </div>
@@ -778,7 +738,43 @@ export default function UserDetailPage() {
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<section className="user-management-panel user-management-danger"><h3>Restrict access or delete accounts</h3><p>Blocking Magent prevents sign-in here and keeps the account. It does not block Jellyfin or Seerr.</p>
<button
type="button"
className="ghost-button"
onClick={() => toggleUserBlock(!user.is_blocked)}
disabled={systemActionBusy || user.role === 'admin'}
>
{user.is_blocked ? 'Restore Magent access' : 'Block Magent access'}
</button>
<p>Disable access also disables invitations this user created and attempts an account notification email. Jellyfin is matched by username. Seerr relies on Jellyfin sign-in; its account is not directly banned. Restoring access does not reactivate invitations.</p>
<div className="admin-inline-actions">
<button
type="button"
className="ghost-button"
onClick={() => void runSystemAction(user.is_blocked ? 'unban' : 'ban')}
disabled={systemActionBusy || user.role === 'admin'}
>
{systemActionBusy
? 'Working...'
: user.is_blocked
? 'Restore Magent and Jellyfin access'
: 'Disable Magent and Jellyfin access'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => void runSystemAction('remove')}
disabled={systemActionBusy || user.role === 'admin'}
>
Delete Magent, Jellyfin and Seerr accounts
</button>
</div>
<p>Deletion removes the Magent account and local login activity, attempts to delete the same-name Jellyfin account and linked Seerr account, and disables issued invitations. It cannot be undone here. Media files and Jellystat history are not deleted. External actions can partially fail.</p>
</section>
</div>
</dialog>
</div> </div>
)} )}
</section> </section>
+3 -26
View File
@@ -6,6 +6,7 @@ import Link from 'next/link'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell' import AdminShell from '../ui/AdminShell'
import './users.css' import './users.css'
import FeatureControls from './FeatureControls'
import IdentityReviewPanel from '../admin/identities/IdentityReviewPanel' import IdentityReviewPanel from '../admin/identities/IdentityReviewPanel'
type AdminUser = { type AdminUser = {
@@ -107,8 +108,6 @@ export default function UsersPage() {
const [jellyseerrSyncBusy, setJellyseerrSyncBusy] = useState(false) const [jellyseerrSyncBusy, setJellyseerrSyncBusy] = useState(false)
const [jellyseerrResyncBusy, setJellyseerrResyncBusy] = useState(false) const [jellyseerrResyncBusy, setJellyseerrResyncBusy] = useState(false)
const [bulkAutoSearchBusy, setBulkAutoSearchBusy] = useState(false) const [bulkAutoSearchBusy, setBulkAutoSearchBusy] = useState(false)
const [bulkInvitesBusy, setBulkInvitesBusy] = useState(false)
const [inviteStatus, setInviteStatus] = useState<string | null>(null)
const loadUsers = async () => { const loadUsers = async () => {
setRefreshing(true) setRefreshing(true)
@@ -216,25 +215,6 @@ export default function UsersPage() {
} }
} }
const enableInvitesForEveryone = async () => {
if (bulkInvitesBusy || !window.confirm('Enable invite access for all existing non-admin users, including users outside the current search? Existing invite limits, account blocks and expiry dates will not change.')) return
setBulkInvitesBusy(true)
setInviteStatus(null)
try {
const response = await authFetch(`${getApiBase()}/admin/users/invite-access/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true }),
})
if (!response.ok) throw new Error('Invite access update failed')
const data = await response.json()
setInviteStatus(`Invite access enabled for ${data.updated ?? 0} non-admin accounts. Existing limits are unchanged.`)
await loadUsers()
} catch {
setInviteStatus('Could not enable invites. Please reload the list to check the current permissions, then try again.')
} finally { setBulkInvitesBusy(false) }
}
const bulkUpdateAutoSearch = async (enabled: boolean) => { const bulkUpdateAutoSearch = async (enabled: boolean) => {
setBulkAutoSearchBusy(true) setBulkAutoSearchBusy(true)
setJellyseerrSyncStatus(null) setJellyseerrSyncStatus(null)
@@ -284,7 +264,7 @@ export default function UsersPage() {
} }
}, [controlsOpen]) }, [controlsOpen])
const controlsBusy = refreshing || jellyseerrSyncBusy || jellyseerrResyncBusy || bulkAutoSearchBusy || bulkInvitesBusy const controlsBusy = refreshing || jellyseerrSyncBusy || jellyseerrResyncBusy || bulkAutoSearchBusy
if (loading) { if (loading) {
return <main className="card">Loading users...</main> return <main className="card">Loading users...</main>
@@ -292,7 +272,6 @@ export default function UsersPage() {
const nonAdminUsers = users.filter((user) => user.role !== 'admin') const nonAdminUsers = users.filter((user) => user.role !== 'admin')
const autoSearchEnabledCount = nonAdminUsers.filter((user) => user.autoSearchEnabled !== false).length const autoSearchEnabledCount = nonAdminUsers.filter((user) => user.autoSearchEnabled !== false).length
const inviteEnabledCount = nonAdminUsers.filter((user) => user.inviteManagementEnabled).length
const blockedCount = users.filter((user) => user.isBlocked).length const blockedCount = users.filter((user) => user.isBlocked).length
const expiredCount = users.filter((user) => user.isExpired).length const expiredCount = users.filter((user) => user.isExpired).length
const adminCount = users.filter((user) => user.role === 'admin').length const adminCount = users.filter((user) => user.role === 'admin').length
@@ -371,7 +350,6 @@ export default function UsersPage() {
<header className="user-management-heading"><div><span className="users-page-toolbar-label">Directory tools</span><h2 id="user-management-title">Manage users</h2><p>Account links, service sync and permissions for your user directory.</p></div><button ref={controlsClose} type="button" className="ghost-button" onClick={() => setControlsOpen(false)}>Close</button></header> <header className="user-management-heading"><div><span className="users-page-toolbar-label">Directory tools</span><h2 id="user-management-title">Manage users</h2><p>Account links, service sync and permissions for your user directory.</p></div><button ref={controlsClose} type="button" className="ghost-button" onClick={() => setControlsOpen(false)}>Close</button></header>
{error && <p className="error-banner" role="alert">{error}</p>} {error && <p className="error-banner" role="alert">{error}</p>}
{jellyseerrSyncStatus && <p className="status-banner" role="status">{jellyseerrSyncStatus}</p>} {jellyseerrSyncStatus && <p className="status-banner" role="status">{jellyseerrSyncStatus}</p>}
{inviteStatus && <p className="status-banner" role="status">{inviteStatus}</p>}
<div className="user-management-grid"> <div className="user-management-grid">
<section className="user-management-panel"><h3>Directory actions</h3><p>Review linked accounts, manage invitations or refresh the list.</p> <section className="user-management-panel"><h3>Directory actions</h3><p>Review linked accounts, manage invitations or refresh the list.</p>
<div className="user-management-action"><Link className="ghost-button" href="/users?view=identities" aria-describedby="identity-help">Review account links </Link><p id="identity-help">Compare and confirm each user's Magent, Jellyfin, Jellystat and Seerr IDs.</p></div> <div className="user-management-action"><Link className="ghost-button" href="/users?view=identities" aria-describedby="identity-help">Review account links </Link><p id="identity-help">Compare and confirm each user's Magent, Jellyfin, Jellystat and Seerr IDs.</p></div>
@@ -383,7 +361,7 @@ export default function UsersPage() {
<details className="user-management-advanced"><summary>Advanced: rebuild from Seerr</summary><p id="resync-help">Deletes all non-admin Magent accounts, then imports accounts from Seerr. Account settings and saved identity links may be lost. Admin accounts are kept. Use this only when you intend to replace the directory.</p><button type="button" className="ghost-button" onClick={() => void resyncJellyseerrUsers()} disabled={controlsBusy} aria-describedby="resync-help">{jellyseerrResyncBusy ? 'Rebuilding directory…' : 'Rebuild directory from Seerr'}</button></details> <details className="user-management-advanced"><summary>Advanced: rebuild from Seerr</summary><p id="resync-help">Deletes all non-admin Magent accounts, then imports accounts from Seerr. Account settings and saved identity links may be lost. Admin accounts are kept. Use this only when you intend to replace the directory.</p><button type="button" className="ghost-button" onClick={() => void resyncJellyseerrUsers()} disabled={controlsBusy} aria-describedby="resync-help">{jellyseerrResyncBusy ? 'Rebuilding directory…' : 'Rebuild directory from Seerr'}</button></details>
</section> </section>
<section className="user-management-panel"><h3>Automatic search &amp; download</h3><p>Allow users to trigger automatic searches and downloads for their requests.</p><span className="user-management-count">{autoSearchEnabledCount} of {nonAdminUsers.length} non-admin users enabled</span><p id="auto-search-help">Applies to every existing non-admin account, including accounts outside your search results. Use an individual user's page to change just their access.</p><div className="user-management-buttons"><button type="button" onClick={() => void bulkUpdateAutoSearch(true)} disabled={controlsBusy || !nonAdminUsers.length || autoSearchEnabledCount === nonAdminUsers.length} aria-describedby="auto-search-help">Enable for all non-admin users</button><button type="button" className="ghost-button" onClick={() => void bulkUpdateAutoSearch(false)} disabled={controlsBusy || !autoSearchEnabledCount} aria-describedby="auto-search-help">Disable for all non-admin users</button></div></section> <section className="user-management-panel"><h3>Automatic search &amp; download</h3><p>Allow users to trigger automatic searches and downloads for their requests.</p><span className="user-management-count">{autoSearchEnabledCount} of {nonAdminUsers.length} non-admin users enabled</span><p id="auto-search-help">Applies to every existing non-admin account, including accounts outside your search results. Use an individual user's page to change just their access.</p><div className="user-management-buttons"><button type="button" onClick={() => void bulkUpdateAutoSearch(true)} disabled={controlsBusy || !nonAdminUsers.length || autoSearchEnabledCount === nonAdminUsers.length} aria-describedby="auto-search-help">Enable for all non-admin users</button><button type="button" className="ghost-button" onClick={() => void bulkUpdateAutoSearch(false)} disabled={controlsBusy || !autoSearchEnabledCount} aria-describedby="auto-search-help">Disable for all non-admin users</button></div></section>
<section className="user-management-panel"><h3>Invite access</h3><p>Let users create and manage invitations for other people to join.</p><span className="user-management-count">{inviteEnabledCount} of {nonAdminUsers.length} non-admin users enabled</span><p id="invite-access-help">Grants invite access to every existing non-admin account. Each user's invite limits still apply, and account blocks and expiry dates stay in effect. Administrators already have access.</p><button type="button" onClick={() => void enableInvitesForEveryone()} disabled={controlsBusy || !nonAdminUsers.length || inviteEnabledCount === nonAdminUsers.length} aria-describedby="invite-access-help">{bulkInvitesBusy ? 'Enabling invites' : inviteEnabledCount === nonAdminUsers.length && nonAdminUsers.length > 0 ? 'Invites enabled for everyone' : 'Enable invites for all non-admin users'}</button></section> <FeatureControls onSaved={() => void loadUsers()} />
</div> </div>
<details className="user-management-summary"><summary>Directory totals</summary>{usersRail}</details> <details className="user-management-summary"><summary>Directory totals</summary>{usersRail}</details>
</div> </div>
@@ -395,7 +373,6 @@ export default function UsersPage() {
{view === 'identities' ? <IdentityReviewPanel /> : <section className="admin-section users-directory-centered"> {view === 'identities' ? <IdentityReviewPanel /> : <section className="admin-section users-directory-centered">
{!controlsOpen && error && <p className="error-banner" role="alert">{error}</p>} {!controlsOpen && error && <p className="error-banner" role="alert">{error}</p>}
{!controlsOpen && jellyseerrSyncStatus && <p className="status-banner" role="status">{jellyseerrSyncStatus}</p>} {!controlsOpen && jellyseerrSyncStatus && <p className="status-banner" role="status">{jellyseerrSyncStatus}</p>}
{!controlsOpen && inviteStatus && <p className="status-banner" role="status">{inviteStatus}</p>}
<div className="admin-panel user-directory-search-panel"> <div className="admin-panel user-directory-search-panel">
<div className="user-directory-panel-header"> <div className="user-directory-panel-header">
<div> <div>
+19
View File
@@ -31,3 +31,22 @@
.user-management-panel { padding: 18px; } .user-management-panel { padding: 18px; }
.user-management-heading h2 { font-size: 22px; } .user-management-heading h2 { font-size: 22px; }
} }
/* Individual profiles use the directory's modal management pattern. */
.user-detail-page-grid.user-detail-centered { display: block; width: min(100%, 1100px); margin-inline: auto; }
.user-detail-centered .user-detail-main-column { display: flex; flex-direction: column; gap: 24px; }
.user-detail-centered .user-detail-main-column > :nth-child(2) { order: -1; }
.user-detail-centered .user-detail-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.feature-controls { margin-bottom: 20px; }
.feature-access-row { display: flex; align-items: flex-start; gap: 14px; padding: 15px 0; border-bottom: 1px solid var(--border, #34343c); cursor: pointer; }
.feature-access-row input { flex: 0 0 auto; margin-top: 4px; width: 18px; height: 18px; accent-color: #c4b5fd; }
.feature-access-row span { display: grid; gap: 5px; }
.feature-access-row small { color: var(--text-muted, #a9a9ba); line-height: 1.5; }
.feature-controls .admin-inline-actions { margin-top: 20px; }
.user-management-panel.user-management-danger { margin-top: 24px; border: 1px solid #a84049; background: #321b2080; }
.user-management-danger h3 { color: #ff9ca6; }
.user-management-danger button { border-color: #a84049; color: #ffb6bd; background: #441e27; }
.user-management-danger p { margin-block: 16px; line-height: 1.6; }
@media (max-width: 640px) {
.user-detail-centered .user-detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
+71
View File
@@ -0,0 +1,71 @@
const assert = require('node:assert/strict');
const { chromium } = require(process.env.PLAYWRIGHT_PACKAGE || 'playwright');
const base = process.env.REVIEW_BASE || 'http://localhost:3114';
(async () => {
const browser = await chromium.launch();
try {
const context = await browser.newContext();
await context.addCookies([{ name: 'magent_logged_in', value: '1', url: base }]);
const all = { stats: true, requests: true, new_requests: true, issues: true, invites: true };
const viewer = { id: 2, username: 'Georgia', role: 'user', email: 'georgia@example.test', features: { ...all }, stats: { total: 12, ready: 7, in_progress: 5 } };
const other = { ...viewer, id: 3, username: 'Other viewer', features: { ...all, issues: false } };
let signedIn = { username: 'Admin', role: 'admin', features: all };
const writes = [];
await context.route('**/api/**', async (route) => {
const request = route.request(), path = new URL(request.url()).pathname;
if (path === '/api/auth/me') return route.fulfill({ json: signedIn });
if (request.method() === 'PUT' && path.includes('/features')) {
writes.push({ path, payload: request.postDataJSON() });
Object.assign(viewer.features, request.postDataJSON());
return route.fulfill({ json: { updated: 2, features: viewer.features } });
}
if (path === '/api/admin/users/summary') return route.fulfill({ json: { users: [viewer, other] } });
if (path === '/api/admin/users/id/2' || path === '/api/admin/users/Georgia') return route.fulfill({ json: { user: viewer, stats: viewer.stats } });
if (path === '/api/admin/profiles') return route.fulfill({ json: { profiles: [] } });
return route.fulfill({ json: {} });
});
const page = await context.newPage();
const errors = []; page.on('pageerror', (error) => errors.push(error.message));
for (const width of [1440, 390]) {
await page.setViewportSize({ width, height: 1000 });
await page.goto(`${base}/users/2`);
await page.getByRole('heading', { name: 'Request statistics', exact: true }).waitFor();
assert.equal(await page.getByRole('heading', { name: 'Access controls', exact: true }).count(), 0);
const box = await page.locator('.user-detail-centered').boundingBox();
assert(Math.abs(box.x + box.width / 2 - width / 2) < 4, 'Profile is centred');
await page.getByRole('button', { name: 'Manage this user', exact: true }).click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('checkbox', { name: /My Stats/ }).waitFor();
assert(await dialog.evaluate((element) => element.scrollWidth <= element.clientWidth), `No modal overflow at ${width}`);
assert.equal(await dialog.getByRole('checkbox').count(), 7);
assert(await dialog.getByText('Restrict access or delete accounts', { exact: true }).count());
await dialog.getByRole('checkbox', { name: /^Issues/ }).uncheck();
await dialog.getByRole('button', { name: 'Save feature access', exact: true }).click();
await dialog.getByRole('status').filter({ hasText: 'Feature access saved.' }).waitFor();
assert.deepEqual(writes.at(-1).payload, { issues: false });
await page.keyboard.press('Escape');
await dialog.waitFor({ state: 'hidden' });
assert(await page.getByRole('button', { name: 'Manage this user', exact: true }).evaluate((element) => element === document.activeElement));
viewer.features.issues = true;
}
await page.goto(`${base}/users`);
await page.getByRole('button', { name: 'Manage users', exact: true }).click();
const dialog = page.getByRole('dialog');
const issues = dialog.getByRole('checkbox', { name: /^Issues/ });
await issues.waitFor();
assert(await issues.evaluate((element) => element.indeterminate), 'Bulk mixed permissions shown');
await issues.check();
await dialog.getByRole('button', { name: 'Apply changed features to all users', exact: true }).click();
await dialog.getByRole('status').filter({ hasText: 'Feature access saved for 2' }).waitFor();
assert.deepEqual(writes.at(-1), { path: '/api/admin/users/features/bulk', payload: { issues: true } });
signedIn = { username: 'Viewer', role: 'user', features: Object.fromEntries(Object.keys(all).map((key) => [key, false])) };
for (const path of ['/insights', '/', '/new-requests', '/portal/issues', '/profile/invites']) {
await page.goto(base + path);
await page.getByRole('heading', { name: 'Feature unavailable' }).waitFor();
assert.equal(await page.locator('.header-actions a, .workspace-mobile-nav a').count(), 0);
}
assert.deepEqual(errors, []);
console.log('Passed: responsive centred profiles, management overlay, focus restoration, individual and mixed bulk permissions, saved payload scope, and all five denied routes/navigation. API traffic used fixtures only.');
} finally { await browser.close(); }
})().catch((error) => { console.error(error); process.exitCode = 1; });