diff --git a/.build_number b/.build_number index 7f7a2e9..f364994 100644 --- a/.build_number +++ b/.build_number @@ -1 +1 @@ -2602261636 +2602261717 \ No newline at end of file diff --git a/backend/app/auth.py b/backend/app/auth.py index e142c4f..479973d 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -67,6 +67,7 @@ def _load_current_user_from_token(token: str, request: Optional[Request] = None) "auth_provider": user.get("auth_provider", "local"), "jellyseerr_user_id": user.get("jellyseerr_user_id"), "auto_search_enabled": bool(user.get("auto_search_enabled", True)), + "invite_management_enabled": bool(user.get("invite_management_enabled", False)), "profile_id": user.get("profile_id"), "expires_at": user.get("expires_at"), "is_expired": bool(user.get("is_expired", False)), diff --git a/backend/app/build_info.py b/backend/app/build_info.py index e6dfb07..84fe317 100644 --- a/backend/app/build_info.py +++ b/backend/app/build_info.py @@ -1,2 +1,2 @@ -BUILD_NUMBER = "2602261636" +BUILD_NUMBER = "2602261717" CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Jellyseerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Jellyseerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container' diff --git a/backend/app/db.py b/backend/app/db.py index 32d66ad..a67af35 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -172,6 +172,7 @@ def init_db() -> None: last_login_at TEXT, is_blocked INTEGER NOT NULL DEFAULT 0, auto_search_enabled INTEGER NOT NULL DEFAULT 1, + invite_management_enabled INTEGER NOT NULL DEFAULT 0, profile_id INTEGER, expires_at TEXT, invited_by_code TEXT, @@ -341,6 +342,10 @@ def init_db() -> None: conn.execute("ALTER TABLE users ADD COLUMN auto_search_enabled INTEGER NOT NULL DEFAULT 1") except sqlite3.OperationalError: pass + try: + conn.execute("ALTER TABLE users ADD COLUMN invite_management_enabled INTEGER NOT NULL DEFAULT 0") + except sqlite3.OperationalError: + pass try: conn.execute("ALTER TABLE users ADD COLUMN profile_id INTEGER") except sqlite3.OperationalError: @@ -498,6 +503,7 @@ def create_user( auth_provider: str = "local", jellyseerr_user_id: Optional[int] = None, auto_search_enabled: bool = True, + invite_management_enabled: bool = False, profile_id: Optional[int] = None, expires_at: Optional[str] = None, invited_by_code: Optional[str] = None, @@ -515,6 +521,7 @@ def create_user( jellyseerr_user_id, created_at, auto_search_enabled, + invite_management_enabled, profile_id, expires_at, invited_by_code, @@ -530,6 +537,7 @@ def create_user( jellyseerr_user_id, created_at, 1 if auto_search_enabled else 0, + 1 if invite_management_enabled else 0, profile_id, expires_at, invited_by_code, @@ -545,6 +553,7 @@ def create_user_if_missing( auth_provider: str = "local", jellyseerr_user_id: Optional[int] = None, auto_search_enabled: bool = True, + invite_management_enabled: bool = False, profile_id: Optional[int] = None, expires_at: Optional[str] = None, invited_by_code: Optional[str] = None, @@ -562,6 +571,7 @@ def create_user_if_missing( jellyseerr_user_id, created_at, auto_search_enabled, + invite_management_enabled, profile_id, expires_at, invited_by_code, @@ -577,6 +587,7 @@ def create_user_if_missing( jellyseerr_user_id, created_at, 1 if auto_search_enabled else 0, + 1 if invite_management_enabled else 0, profile_id, expires_at, invited_by_code, @@ -592,7 +603,7 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]: """ SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id, created_at, last_login_at, is_blocked, auto_search_enabled, - profile_id, expires_at, invited_by_code, invited_at, + invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at, jellyfin_password_hash, last_jellyfin_auth_at FROM users WHERE username = ? COLLATE NOCASE @@ -612,13 +623,14 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]: "last_login_at": row[7], "is_blocked": bool(row[8]), "auto_search_enabled": bool(row[9]), - "profile_id": row[10], - "expires_at": row[11], - "invited_by_code": row[12], - "invited_at": row[13], - "is_expired": _is_datetime_in_past(row[11]), - "jellyfin_password_hash": row[14], - "last_jellyfin_auth_at": row[15], + "invite_management_enabled": bool(row[10]), + "profile_id": row[11], + "expires_at": row[12], + "invited_by_code": row[13], + "invited_at": row[14], + "is_expired": _is_datetime_in_past(row[12]), + "jellyfin_password_hash": row[15], + "last_jellyfin_auth_at": row[16], } @@ -628,7 +640,7 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]: """ SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id, created_at, last_login_at, is_blocked, auto_search_enabled, - profile_id, expires_at, invited_by_code, invited_at, + invite_management_enabled, profile_id, expires_at, invited_by_code, invited_at, jellyfin_password_hash, last_jellyfin_auth_at FROM users WHERE id = ? @@ -648,13 +660,14 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]: "last_login_at": row[7], "is_blocked": bool(row[8]), "auto_search_enabled": bool(row[9]), - "profile_id": row[10], - "expires_at": row[11], - "invited_by_code": row[12], - "invited_at": row[13], - "is_expired": _is_datetime_in_past(row[11]), - "jellyfin_password_hash": row[14], - "last_jellyfin_auth_at": row[15], + "invite_management_enabled": bool(row[10]), + "profile_id": row[11], + "expires_at": row[12], + "invited_by_code": row[13], + "invited_at": row[14], + "is_expired": _is_datetime_in_past(row[12]), + "jellyfin_password_hash": row[15], + "last_jellyfin_auth_at": row[16], } def get_all_users() -> list[Dict[str, Any]]: @@ -662,8 +675,8 @@ def get_all_users() -> list[Dict[str, Any]]: rows = conn.execute( """ SELECT id, username, role, auth_provider, jellyseerr_user_id, created_at, - last_login_at, is_blocked, auto_search_enabled, profile_id, expires_at, - invited_by_code, invited_at + last_login_at, is_blocked, auto_search_enabled, invite_management_enabled, + profile_id, expires_at, invited_by_code, invited_at FROM users ORDER BY username COLLATE NOCASE """ @@ -681,11 +694,12 @@ def get_all_users() -> list[Dict[str, Any]]: "last_login_at": row[6], "is_blocked": bool(row[7]), "auto_search_enabled": bool(row[8]), - "profile_id": row[9], - "expires_at": row[10], - "invited_by_code": row[11], - "invited_at": row[12], - "is_expired": _is_datetime_in_past(row[10]), + "invite_management_enabled": bool(row[9]), + "profile_id": row[10], + "expires_at": row[11], + "invited_by_code": row[12], + "invited_at": row[13], + "is_expired": _is_datetime_in_past(row[11]), } ) return results @@ -788,6 +802,16 @@ def set_user_auto_search_enabled(username: str, enabled: bool) -> None: ) +def set_user_invite_management_enabled(username: str, enabled: bool) -> None: + with _connect() as conn: + conn.execute( + """ + UPDATE users SET invite_management_enabled = ? WHERE username = ? COLLATE NOCASE + """, + (1 if enabled else 0, username), + ) + + def set_auto_search_enabled_for_non_admin_users(enabled: bool) -> int: with _connect() as conn: cursor = conn.execute( @@ -799,6 +823,17 @@ def set_auto_search_enabled_for_non_admin_users(enabled: bool) -> int: return cursor.rowcount +def set_invite_management_enabled_for_non_admin_users(enabled: bool) -> int: + with _connect() as conn: + cursor = conn.execute( + """ + UPDATE users SET invite_management_enabled = ? WHERE role != 'admin' + """, + (1 if enabled else 0,), + ) + return cursor.rowcount + + def set_user_profile_id(username: str, profile_id: Optional[int]) -> None: with _connect() as conn: conn.execute( diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 6f180ef..98bfd0d 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -19,6 +19,7 @@ from ..db import ( get_all_users, get_cached_requests, get_cached_requests_count, + get_setting, get_request_cache_overview, get_request_cache_missing_titles, get_request_cache_stats, @@ -34,9 +35,12 @@ from ..db import ( delete_user_activity_by_username, set_user_auto_search_enabled, set_auto_search_enabled_for_non_admin_users, + set_user_invite_management_enabled, + set_invite_management_enabled_for_non_admin_users, set_user_profile_id, set_user_expires_at, set_user_password, + set_jellyfin_auth_cache, set_user_role, run_integrity_check, vacuum_db, @@ -83,6 +87,7 @@ from ..routers.branding import save_branding_image router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)]) events_router = APIRouter(prefix="/admin/events", tags=["admin"]) logger = logging.getLogger(__name__) +SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id" SENSITIVE_KEYS = { "jellyseerr_api_key", @@ -1107,6 +1112,24 @@ async def update_user_auto_search(username: str, payload: Dict[str, Any]) -> Dic return {"status": "ok", "username": username, "auto_search_enabled": enabled} +@router.post("/users/{username}/invite-access") +async def update_user_invite_access(username: str, payload: Dict[str, Any]) -> Dict[str, Any]: + enabled = payload.get("enabled") if isinstance(payload, dict) else None + if not isinstance(enabled, bool): + raise HTTPException(status_code=400, detail="enabled must be true or false") + user = get_user_by_username(username) + if not user: + raise HTTPException(status_code=404, detail="User not found") + set_user_invite_management_enabled(username, enabled) + refreshed = get_user_by_username(username) + return { + "status": "ok", + "username": username, + "invite_management_enabled": bool(refreshed.get("invite_management_enabled", enabled)) if refreshed else enabled, + "user": refreshed, + } + + @router.post("/users/{username}/profile") async def update_user_profile_assignment(username: str, payload: Dict[str, Any]) -> Dict[str, Any]: user = get_user_by_username(username) @@ -1172,6 +1195,20 @@ async def update_users_auto_search_bulk(payload: Dict[str, Any]) -> Dict[str, An } +@router.post("/users/invite-access/bulk") +async def update_users_invite_access_bulk(payload: Dict[str, Any]) -> Dict[str, Any]: + enabled = payload.get("enabled") if isinstance(payload, dict) else None + if not isinstance(enabled, bool): + raise HTTPException(status_code=400, detail="enabled must be true or false") + updated = set_invite_management_enabled_for_non_admin_users(enabled) + return { + "status": "ok", + "enabled": enabled, + "updated": updated, + "scope": "non-admin-users", + } + + @router.post("/users/profile/bulk") async def update_users_profile_bulk(payload: Dict[str, Any]) -> Dict[str, Any]: if not isinstance(payload, dict): @@ -1242,12 +1279,30 @@ async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[s user = get_user_by_username(username) if not user: raise HTTPException(status_code=404, detail="User not found") - if user.get("auth_provider") != "local": - raise HTTPException( - status_code=400, detail="Password changes are only available for local users." - ) - set_user_password(username, new_password.strip()) - return {"status": "ok", "username": username} + new_password_clean = new_password.strip() + auth_provider = str(user.get("auth_provider") or "local").lower() + if auth_provider == "local": + set_user_password(username, new_password_clean) + return {"status": "ok", "username": username, "provider": "local"} + if auth_provider == "jellyfin": + runtime = get_runtime_settings() + client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) + if not client.configured(): + raise HTTPException(status_code=400, detail="Jellyfin not configured for password passthrough.") + try: + jf_user = await client.find_user_by_name(username) + user_id = client._extract_user_id(jf_user) + if not user_id: + raise RuntimeError("Jellyfin user ID not found") + await client.set_user_password(user_id, new_password_clean) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Jellyfin password update failed: {exc}") from exc + set_jellyfin_auth_cache(username, new_password_clean) + return {"status": "ok", "username": username, "provider": "jellyfin"} + raise HTTPException( + status_code=400, + detail="Password changes are not available for this sign-in provider.", + ) @router.get("/profiles") @@ -1384,6 +1439,63 @@ async def get_invites() -> Dict[str, Any]: return {"invites": results} +@router.get("/invites/policy") +async def get_invite_policy() -> Dict[str, Any]: + users = get_all_users() + non_admin_users = [user for user in users if user.get("role") != "admin"] + invite_access_enabled_count = sum( + 1 for user in non_admin_users if bool(user.get("invite_management_enabled", False)) + ) + raw_master_invite_id = get_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY) + master_invite_id: Optional[int] = None + master_invite: Optional[Dict[str, Any]] = None + if raw_master_invite_id not in (None, ""): + try: + candidate = int(str(raw_master_invite_id).strip()) + if candidate > 0: + master_invite_id = candidate + master_invite = get_signup_invite_by_id(candidate) + except (TypeError, ValueError): + master_invite_id = None + master_invite = None + return { + "status": "ok", + "policy": { + "master_invite_id": master_invite_id if master_invite is not None else None, + "master_invite": master_invite, + "non_admin_users": len(non_admin_users), + "invite_access_enabled_users": invite_access_enabled_count, + }, + } + + +@router.post("/invites/policy") +async def update_invite_policy(payload: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="Invalid payload") + master_invite_value = payload.get("master_invite_id") + if master_invite_value in (None, "", 0, "0"): + set_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY, None) + return {"status": "ok", "policy": {"master_invite_id": None, "master_invite": None}} + try: + master_invite_id = int(master_invite_value) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail="master_invite_id must be a number") from exc + if master_invite_id <= 0: + raise HTTPException(status_code=400, detail="master_invite_id must be a positive number") + invite = get_signup_invite_by_id(master_invite_id) + if not invite: + raise HTTPException(status_code=404, detail="Master invite not found") + set_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY, str(master_invite_id)) + return { + "status": "ok", + "policy": { + "master_invite_id": master_invite_id, + "master_invite": invite, + }, + } + + @router.get("/invites/trace") async def get_invite_trace() -> Dict[str, Any]: return {"status": "ok", "trace": _build_invite_trace_payload()} diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 33299a3..1722277 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -28,6 +28,7 @@ from ..db import ( get_user_request_stats, get_global_request_leader, get_global_request_total, + get_setting, ) from ..runtime import get_runtime_settings from ..clients.jellyfin import JellyfinClient @@ -42,6 +43,7 @@ from ..services.user_cache import ( ) router = APIRouter(prefix="/auth", tags=["auth"]) +SELF_SERVICE_INVITE_MASTER_ID_KEY = "self_service_invite_master_id" def _normalize_username(value: str) -> str: @@ -275,6 +277,89 @@ def _get_owned_invite(invite_id: int, current_user: dict) -> dict: return invite +def _self_service_invite_access_enabled(current_user: dict) -> bool: + if str(current_user.get("role") or "").lower() == "admin": + return True + return bool(current_user.get("invite_management_enabled", False)) + + +def _require_self_service_invite_access(current_user: dict) -> None: + if _self_service_invite_access_enabled(current_user): + return + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Invite management is not enabled for your account.", + ) + + +def _get_self_service_master_invite() -> dict | None: + raw_value = get_setting(SELF_SERVICE_INVITE_MASTER_ID_KEY) + if raw_value is None: + return None + candidate = str(raw_value).strip() + if not candidate: + return None + try: + invite_id = int(candidate) + except (TypeError, ValueError): + return None + if invite_id <= 0: + return None + return get_signup_invite_by_id(invite_id) + + +def _serialize_self_service_master_invite(invite: dict | None) -> dict | None: + if not isinstance(invite, dict): + return None + profile = None + profile_id = invite.get("profile_id") + if isinstance(profile_id, int): + profile = get_user_profile(profile_id) + return { + "id": invite.get("id"), + "code": invite.get("code"), + "label": invite.get("label"), + "description": invite.get("description"), + "profile_id": invite.get("profile_id"), + "profile": ( + {"id": profile.get("id"), "name": profile.get("name")} + if isinstance(profile, dict) + else None + ), + "role": invite.get("role"), + "max_uses": invite.get("max_uses"), + "enabled": bool(invite.get("enabled")), + "expires_at": invite.get("expires_at"), + "is_expired": bool(invite.get("is_expired")), + "is_usable": bool(invite.get("is_usable")), + "created_at": invite.get("created_at"), + "updated_at": invite.get("updated_at"), + } + + +def _master_invite_controlled_values(master_invite: dict) -> tuple[int | None, str, int | None, bool, str | None]: + profile_id_raw = master_invite.get("profile_id") + profile_id: int | None = None + if isinstance(profile_id_raw, int): + profile_id = profile_id_raw + elif profile_id_raw not in (None, ""): + try: + profile_id = int(profile_id_raw) + except (TypeError, ValueError): + profile_id = None + role_value = str(master_invite.get("role") or "").strip().lower() + role = role_value if role_value in {"user", "admin"} else "user" + max_uses_raw = master_invite.get("max_uses") + try: + max_uses = int(max_uses_raw) if max_uses_raw is not None else None + except (TypeError, ValueError): + max_uses = None + enabled = bool(master_invite.get("enabled", True)) + expires_at_value = master_invite.get("expires_at") + expires_at = str(expires_at_value).strip() if isinstance(expires_at_value, str) and str(expires_at_value).strip() else None + return profile_id, role, max_uses, enabled, expires_at + + @router.post("/login") async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict: user = verify_user_password(form_data.username, form_data.password) @@ -568,14 +653,25 @@ async def profile_invites(current_user: dict = Depends(get_current_user)) -> dic username = str(current_user.get("username") or "").strip() if not username: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user") + master_invite = _get_self_service_master_invite() + invite_access_enabled = _self_service_invite_access_enabled(current_user) invites = [_serialize_self_invite(invite) for invite in _current_user_invites(username)] - return {"invites": invites, "count": len(invites)} + return { + "invites": invites, + "count": len(invites), + "invite_access": { + "enabled": invite_access_enabled, + "managed_by_master": bool(master_invite), + }, + "master_invite": _serialize_self_service_master_invite(master_invite), + } @router.post("/profile/invites") async def create_profile_invite(payload: dict, current_user: dict = Depends(get_current_user)) -> dict: if not isinstance(payload, dict): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload") + _require_self_service_invite_access(current_user) username = str(current_user.get("username") or "").strip() if not username: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user") @@ -603,20 +699,32 @@ async def create_profile_invite(payload: dict, current_user: dict = Depends(get_ if description is not None: description = str(description).strip() or None - max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses") - expires_at = _parse_optional_expires_at(payload.get("expires_at")) - enabled = bool(payload.get("enabled", True)) - - profile_id = current_user.get("profile_id") - if not isinstance(profile_id, int) or profile_id <= 0: - profile_id = None + master_invite = _get_self_service_master_invite() + if master_invite: + if not bool(master_invite.get("enabled")) or bool(master_invite.get("is_expired")) or master_invite.get("is_usable") is False: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Self-service invites are temporarily unavailable (master invite template is disabled or expired).", + ) + profile_id, _master_role, max_uses, enabled, expires_at = _master_invite_controlled_values(master_invite) + if profile_id is not None and not get_user_profile(profile_id): + profile_id = None + role = "user" + else: + max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses") + expires_at = _parse_optional_expires_at(payload.get("expires_at")) + enabled = bool(payload.get("enabled", True)) + profile_id = current_user.get("profile_id") + if not isinstance(profile_id, int) or profile_id <= 0: + profile_id = None + role = "user" invite = create_signup_invite( code=code, label=label, description=description, profile_id=profile_id, - role="user", + role=role, max_uses=max_uses, enabled=enabled, expires_at=expires_at, @@ -631,6 +739,7 @@ async def update_profile_invite( ) -> dict: if not isinstance(payload, dict): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload") + _require_self_service_invite_access(current_user) existing = _get_owned_invite(invite_id, current_user) requested_code = payload.get("code", existing.get("code")) @@ -651,18 +760,27 @@ async def update_profile_invite( if description is not None: description = str(description).strip() or None - max_uses = _parse_optional_positive_int(payload.get("max_uses", existing.get("max_uses")), "max_uses") - expires_at = _parse_optional_expires_at(payload.get("expires_at", existing.get("expires_at"))) - enabled_raw = payload.get("enabled", existing.get("enabled")) - enabled = bool(enabled_raw) + master_invite = _get_self_service_master_invite() + if master_invite: + profile_id, _master_role, max_uses, enabled, expires_at = _master_invite_controlled_values(master_invite) + if profile_id is not None and not get_user_profile(profile_id): + profile_id = None + role = "user" + else: + max_uses = _parse_optional_positive_int(payload.get("max_uses", existing.get("max_uses")), "max_uses") + expires_at = _parse_optional_expires_at(payload.get("expires_at", existing.get("expires_at"))) + enabled_raw = payload.get("enabled", existing.get("enabled")) + enabled = bool(enabled_raw) + profile_id = existing.get("profile_id") + role = existing.get("role") invite = update_signup_invite( invite_id, code=code, label=label, description=description, - profile_id=existing.get("profile_id"), - role=existing.get("role"), + profile_id=profile_id, + role=role, max_uses=max_uses, enabled=enabled, expires_at=expires_at, @@ -674,6 +792,7 @@ async def update_profile_invite( @router.delete("/profile/invites/{invite_id}") async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get_current_user)) -> dict: + _require_self_service_invite_access(current_user) _get_owned_invite(invite_id, current_user) deleted = delete_signup_invite(invite_id) if not deleted: @@ -683,11 +802,6 @@ async def delete_profile_invite(invite_id: int, current_user: dict = Depends(get @router.post("/password") async def change_password(payload: dict, current_user: dict = Depends(get_current_user)) -> dict: - if current_user.get("auth_provider") != "local": - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Password changes are only available for local users.", - ) current_password = payload.get("current_password") if isinstance(payload, dict) else None new_password = payload.get("new_password") if isinstance(payload, dict) else None if not isinstance(current_password, str) or not isinstance(new_password, str): @@ -696,8 +810,64 @@ async def change_password(payload: dict, current_user: dict = Depends(get_curren raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Password must be at least 8 characters." ) - user = verify_user_password(current_user["username"], current_password) - if not user: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect") - set_user_password(current_user["username"], new_password.strip()) - return {"status": "ok"} + username = str(current_user.get("username") or "").strip() + auth_provider = str(current_user.get("auth_provider") or "local").lower() + if not username: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user") + new_password_clean = new_password.strip() + + if auth_provider == "local": + user = verify_user_password(username, current_password) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect") + set_user_password(username, new_password_clean) + return {"status": "ok", "provider": "local"} + + if auth_provider == "jellyfin": + runtime = get_runtime_settings() + client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) + if not client.configured(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Jellyfin is not configured for password passthrough.", + ) + try: + auth_result = await client.authenticate_by_name(username, current_password) + if not isinstance(auth_result, dict) or not auth_result.get("User"): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect" + ) + except HTTPException: + raise + except Exception as exc: + detail = _extract_http_error_detail(exc) + if isinstance(exc, httpx.HTTPStatusError) and exc.response is not None and exc.response.status_code in {401, 403}: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect" + ) from exc + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Jellyfin password validation failed: {detail}", + ) from exc + + try: + jf_user = await client.find_user_by_name(username) + user_id = client._extract_user_id(jf_user) + if not user_id: + raise RuntimeError("Jellyfin user ID not found") + await client.set_user_password(user_id, new_password_clean) + except Exception as exc: + detail = _extract_http_error_detail(exc) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Jellyfin password update failed: {detail}", + ) from exc + + # Keep Magent's Jellyfin auth cache in sync for faster subsequent sign-ins. + set_jellyfin_auth_cache(username, new_password_clean) + return {"status": "ok", "provider": "jellyfin"} + + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Password changes are not available for this sign-in provider.", + ) diff --git a/frontend/app/admin/invites/page.tsx b/frontend/app/admin/invites/page.tsx index 3eb22cb..4ad15d5 100644 --- a/frontend/app/admin/invites/page.tsx +++ b/frontend/app/admin/invites/page.tsx @@ -10,6 +10,7 @@ type AdminUserLite = { username: string role: string auth_provider?: string | null + invite_management_enabled?: boolean profile_id?: number | null expires_at?: string | null created_at?: string | null @@ -70,6 +71,13 @@ type ProfileForm = { type InviteManagementTab = 'bulk' | 'profiles' | 'invites' | 'trace' +type InvitePolicy = { + master_invite_id?: number | null + master_invite?: Invite | null + non_admin_users?: number + invite_access_enabled_users?: number +} + const defaultInviteForm = (): InviteForm => ({ code: '', label: '', @@ -109,6 +117,8 @@ export default function AdminInviteManagementPage() { const [profileSaving, setProfileSaving] = useState(false) const [bulkProfileBusy, setBulkProfileBusy] = useState(false) const [bulkExpiryBusy, setBulkExpiryBusy] = useState(false) + const [bulkInviteAccessBusy, setBulkInviteAccessBusy] = useState(false) + const [invitePolicySaving, setInvitePolicySaving] = useState(false) const [error, setError] = useState(null) const [status, setStatus] = useState(null) @@ -121,6 +131,8 @@ export default function AdminInviteManagementPage() { const [bulkProfileId, setBulkProfileId] = useState('') const [bulkExpiryDays, setBulkExpiryDays] = useState('') + const [masterInviteSelection, setMasterInviteSelection] = useState('') + const [invitePolicy, setInvitePolicy] = useState(null) const [activeTab, setActiveTab] = useState('bulk') const [traceFilter, setTraceFilter] = useState('') @@ -151,10 +163,11 @@ export default function AdminInviteManagementPage() { setError(null) try { const baseUrl = getApiBase() - const [inviteRes, profileRes, usersRes] = await Promise.all([ + const [inviteRes, profileRes, usersRes, policyRes] = await Promise.all([ authFetch(`${baseUrl}/admin/invites`), authFetch(`${baseUrl}/admin/profiles`), authFetch(`${baseUrl}/admin/users`), + authFetch(`${baseUrl}/admin/invites/policy`), ]) if (!inviteRes.ok) { if (handleAuthResponse(inviteRes)) return @@ -168,14 +181,24 @@ export default function AdminInviteManagementPage() { if (handleAuthResponse(usersRes)) return throw new Error(`Failed to load users (${usersRes.status})`) } - const [inviteData, profileData, usersData] = await Promise.all([ + if (!policyRes.ok) { + if (handleAuthResponse(policyRes)) return + throw new Error(`Failed to load invite policy (${policyRes.status})`) + } + const [inviteData, profileData, usersData, policyData] = await Promise.all([ inviteRes.json(), profileRes.json(), usersRes.json(), + policyRes.json(), ]) + const nextPolicy = (policyData?.policy ?? null) as InvitePolicy | null setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : []) setProfiles(Array.isArray(profileData?.profiles) ? profileData.profiles : []) setUsers(Array.isArray(usersData?.users) ? usersData.users : []) + setInvitePolicy(nextPolicy) + setMasterInviteSelection( + nextPolicy?.master_invite_id == null ? '' : String(nextPolicy.master_invite_id) + ) try { const jellyfinRes = await authFetch(`${baseUrl}/admin/jellyfin/users`) if (jellyfinRes.ok) { @@ -482,12 +505,71 @@ export default function AdminInviteManagementPage() { } } + const bulkSetInviteAccess = async (enabled: boolean) => { + setBulkInviteAccessBusy(true) + setStatus(null) + setError(null) + try { + const baseUrl = getApiBase() + const response = await authFetch(`${baseUrl}/admin/users/invite-access/bulk`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ enabled }), + }) + if (!response.ok) { + if (handleAuthResponse(response)) return + const text = await response.text() + throw new Error(text || 'Bulk invite access update failed') + } + const data = await response.json() + setStatus( + `${enabled ? 'Enabled' : 'Disabled'} self-service invites for ${data?.updated ?? 0} non-admin users.` + ) + await loadData() + } catch (err) { + console.error(err) + setError(err instanceof Error ? err.message : 'Could not update invite access for all users.') + } finally { + setBulkInviteAccessBusy(false) + } + } + + const saveMasterInvitePolicy = async (nextMasterInviteId?: string | null) => { + const selectedValue = + nextMasterInviteId === undefined ? masterInviteSelection : nextMasterInviteId || '' + setInvitePolicySaving(true) + setStatus(null) + setError(null) + try { + const baseUrl = getApiBase() + const response = await authFetch(`${baseUrl}/admin/invites/policy`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ master_invite_id: selectedValue || null }), + }) + if (!response.ok) { + if (handleAuthResponse(response)) return + const text = await response.text() + throw new Error(text || 'Invite policy update failed') + } + setStatus(selectedValue ? 'Master invite template updated.' : 'Master invite template cleared.') + await loadData() + } catch (err) { + console.error(err) + setError(err instanceof Error ? err.message : 'Could not update invite policy.') + } finally { + setInvitePolicySaving(false) + } + } + const nonAdminUsers = users.filter((user) => user.role !== 'admin') const profiledUsers = nonAdminUsers.filter((user) => user.profile_id != null).length const expiringUsers = nonAdminUsers.filter((user) => Boolean(user.expires_at)).length + const inviteAccessEnabledUsers = nonAdminUsers.filter((user) => Boolean(user.invite_management_enabled)).length const usableInvites = invites.filter((invite) => invite.is_usable !== false).length const disabledInvites = invites.filter((invite) => invite.enabled === false).length const activeProfiles = profiles.filter((profile) => profile.is_active !== false).length + const masterInvite = invitePolicy?.master_invite ?? null const inviteTraceRows = useMemo(() => { const inviteByCode = new Map() @@ -663,6 +745,17 @@ export default function AdminInviteManagementPage() { {jellyfinUsersCount == null ? 'Unavailable/not configured' : 'Current Jellyfin user objects'} +
+ Self-service invites +
+ {inviteAccessEnabledUsers} + + {masterInvite + ? `users enabled • master template ${masterInvite.code ?? `#${masterInvite.id}`}` + : 'users enabled • no master template set'} + +
+
Expiry rules
@@ -746,17 +839,83 @@ export default function AdminInviteManagementPage() {

Blanket controls

- Apply invite profile defaults or expiry to all local non-admin accounts. Individual users can still be edited from their user page. + Apply invite access, master invite template rules, profile defaults, or expiry to all local non-admin accounts. Individual users can still be edited from their user page.

Local non-admin users: {nonAdminUsers.length} Jellyfin users: {jellyfinUsersCount ?? '—'} + Invite access enabled: {inviteAccessEnabledUsers} Profile assigned: {profiledUsers} Custom expiry set: {expiringUsers}
+
+
+ Self-service invites + + Enable or disable the “My invites” tab for all non-admin users. + +
+
+ + +
+
+
+ +
+ + +
+
+ {masterInvite + ? `Current master template: ${masterInvite.code}${masterInvite.label ? ` (${masterInvite.label})` : ''}. Self-service invites inherit its limits/status/profile.` + : 'No master template set. Self-service invites use each user’s profile/defaults.'} +
+
)} -
-
+
+
+ + + {canManageInvites ? ( + + ) : null} + +
+
+ + {activeTab === 'overview' && ( +

Account stats

@@ -353,6 +458,18 @@ export default function ProfilePage() {
Declined
{stats?.declined ?? 0}
+
+
Working
+
{stats?.working ?? 0}
+
+
+
Partial
+
{stats?.partial ?? 0}
+
+
+
Approved
+
{stats?.approved ?? 0}
+
Last request
@@ -367,6 +484,10 @@ export default function ProfilePage() { : '0%'}
+
+
Total requests (global)
+
{stats?.global_total ?? 0}
+
{profile?.role === 'admin' ? (
Most active user
@@ -379,7 +500,10 @@ export default function ProfilePage() { ) : null}
-
+ )} + + {activeTab === 'activity' && ( +

Connection history

Last seen {formatDate(activity?.last_seen_at)} from {activity?.last_ip ?? 'Unknown'}. @@ -390,6 +514,7 @@ export default function ProfilePage() {
{parseBrowser(entry.user_agent)}
IP: {entry.ip}
+
First seen: {formatDate(entry.first_seen_at)}
Last seen: {formatDate(entry.last_seen_at)}
{entry.hit_count} visits
@@ -400,80 +525,34 @@ export default function ProfilePage() { ) : null}
-
-
+ )} + + {activeTab === 'invites' && ( +

My invites

- Create and manage invite links you’ve issued. New invites use your account defaults. + {inviteManagedByMaster + ? 'Create and manage invite links you’ve issued. New invites use the admin master invite rule.' + : 'Create and manage invite links you’ve issued. New invites use your account defaults.'}

{inviteError &&
{inviteError}
} {inviteStatus &&
{inviteStatus}
}
-
- {invites.length === 0 ? ( -
You haven’t created any invites yet.
- ) : ( -
- {invites.map((invite) => ( -
-
-
- {invite.code} - - {invite.is_usable ? 'Usable' : 'Unavailable'} - - - {invite.remaining_uses == null ? 'Unlimited' : `${invite.remaining_uses} left`} - -
- {invite.label &&

{invite.label}

} - {invite.description && ( -

- {invite.description} -

- )} -
- - Uses: {invite.use_count} - {typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''} - - Expires: {formatDate(invite.expires_at)} - Created: {formatDate(invite.created_at)} -
-
-
- - - -
-
- ))} -
- )} -

{inviteEditingId == null ? 'Create invite' : 'Edit invite'}

Share the generated signup link with the person you want to invite.

+ {inviteManagedByMaster && masterInviteTemplate ? ( +
+ Using master invite rule {masterInviteTemplate.code} + {masterInviteTemplate.label ? ` (${masterInviteTemplate.label})` : ''}. Limits/status are managed by admin. +
+ ) : null}
@@ -539,6 +618,7 @@ export default function ProfilePage() { } inputMode="numeric" placeholder="Blank = unlimited" + disabled={inviteManagedByMaster} />
@@ -570,6 +651,7 @@ export default function ProfilePage() { enabled: event.target.checked, })) } + disabled={inviteManagedByMaster} /> Invite is enabled @@ -594,37 +676,103 @@ export default function ProfilePage() { Invite URL format: {signupBaseUrl}?code=INVITECODE
-
-
- {profile?.auth_provider !== 'local' ? ( -
- Password changes are only available for local Magent accounts. -
- ) : ( - - - - {status &&
{status}
} -
- +
+ {invites.length === 0 ? ( +
You haven’t created any invites yet.
+ ) : ( +
+ {invites.map((invite) => ( +
+
+
+ {invite.code} + + {invite.is_usable ? 'Usable' : 'Unavailable'} + + + {invite.remaining_uses == null ? 'Unlimited' : `${invite.remaining_uses} left`} + +
+ {invite.label &&

{invite.label}

} + {invite.description && ( +

+ {invite.description} +

+ )} +
+ + Uses: {invite.use_count} + {typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''} + + Expires: {formatDate(invite.expires_at)} + Created: {formatDate(invite.created_at)} +
+
+
+ + + +
+
+ ))} +
+ )}
- +
+
+ )} + + {activeTab === 'security' && ( +
+

Security

+
{securityHelpText}
+ {canChangePassword ? ( +
+ + + {status &&
{status}
} +
+ +
+
+ ) : ( +
+ Password changes are not available for {authProvider} sign-in accounts from Magent. +
+ )} +
)} ) diff --git a/frontend/app/users/[id]/page.tsx b/frontend/app/users/[id]/page.tsx index 166c8a2..2758e78 100644 --- a/frontend/app/users/[id]/page.tsx +++ b/frontend/app/users/[id]/page.tsx @@ -25,6 +25,7 @@ type AdminUser = { last_login_at?: string | null is_blocked?: boolean auto_search_enabled?: boolean + invite_management_enabled?: boolean jellyseerr_user_id?: number | null profile_id?: number | null expires_at?: string | null @@ -240,6 +241,30 @@ 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) => { if (!user) return const profileValue = profileOverride ?? profileSelection @@ -539,6 +564,15 @@ export default function UserDetailPage() { /> Allow auto search/download +