Add user feature permissions and unified account management
This commit is contained in:
@@ -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
@@ -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 "")
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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))}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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]]] = {}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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'}]:
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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=[]), \
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 & 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 & 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>
|
||||||
|
|||||||
@@ -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)); }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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; });
|
||||||
Reference in New Issue
Block a user