diff --git a/.build_number b/.build_number index 6a90235..e6152fe 100644 --- a/.build_number +++ b/.build_number @@ -1 +1 @@ -2602260022 +2602260214 diff --git a/backend/app/auth.py b/backend/app/auth.py index dea4358..f32072d 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from typing import Dict, Any from fastapi import Depends, HTTPException, status, Request @@ -8,6 +9,21 @@ from .security import safe_decode_token, TokenError oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") + +def _is_expired(expires_at: str | None) -> bool: + if not isinstance(expires_at, str) or not expires_at.strip(): + return False + candidate = expires_at.strip() + if candidate.endswith("Z"): + candidate = candidate[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(candidate) + except ValueError: + return False + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed <= datetime.now(timezone.utc) + def _extract_client_ip(request: Request) -> str: forwarded = request.headers.get("x-forwarded-for") if forwarded: @@ -37,6 +53,8 @@ def get_current_user(token: str = Depends(oauth2_scheme), request: Request = Non raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") if user.get("is_blocked"): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked") + if _is_expired(user.get("expires_at")): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired") if request is not None: ip = _extract_client_ip(request) @@ -49,6 +67,9 @@ def get_current_user(token: str = Depends(oauth2_scheme), request: Request = Non "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)), + "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 a213b51..ba863fd 100644 --- a/backend/app/build_info.py +++ b/backend/app/build_info.py @@ -1,2 +1,2 @@ -BUILD_NUMBER = "2602260022" +BUILD_NUMBER = "2602260214" 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 ede28db..f0dd995 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -24,6 +24,28 @@ def _connect() -> sqlite3.Connection: return sqlite3.connect(_db_path()) +def _parse_datetime_value(value: Optional[str]) -> Optional[datetime]: + if not isinstance(value, str) or not value.strip(): + return None + candidate = value.strip() + if candidate.endswith("Z"): + candidate = candidate[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(candidate) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed + + +def _is_datetime_in_past(value: Optional[str]) -> bool: + parsed = _parse_datetime_value(value) + if parsed is None: + return False + return parsed <= datetime.now(timezone.utc) + + def _normalize_title_value(title: Optional[str]) -> Optional[str]: if not isinstance(title, str): return None @@ -150,11 +172,61 @@ def init_db() -> None: last_login_at TEXT, is_blocked INTEGER NOT NULL DEFAULT 0, auto_search_enabled INTEGER NOT NULL DEFAULT 1, + profile_id INTEGER, + expires_at TEXT, + invited_by_code TEXT, + invited_at TEXT, jellyfin_password_hash TEXT, last_jellyfin_auth_at TEXT ) """ ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS user_profiles ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT, + role TEXT NOT NULL DEFAULT 'user', + auto_search_enabled INTEGER NOT NULL DEFAULT 1, + account_expires_days INTEGER, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS signup_invites ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + code TEXT NOT NULL UNIQUE, + label TEXT, + description TEXT, + profile_id INTEGER, + role TEXT, + max_uses INTEGER, + use_count INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + expires_at TEXT, + created_by TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_signup_invites_enabled + ON signup_invites (enabled) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_signup_invites_expires_at + ON signup_invites (expires_at) + """ + ) conn.execute( """ CREATE TABLE IF NOT EXISTS settings ( @@ -269,6 +341,40 @@ 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 profile_id INTEGER") + except sqlite3.OperationalError: + pass + try: + conn.execute("ALTER TABLE users ADD COLUMN expires_at TEXT") + except sqlite3.OperationalError: + pass + try: + conn.execute("ALTER TABLE users ADD COLUMN invited_by_code TEXT") + except sqlite3.OperationalError: + pass + try: + conn.execute("ALTER TABLE users ADD COLUMN invited_at TEXT") + except sqlite3.OperationalError: + pass + try: + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_users_profile_id + ON users (profile_id) + """ + ) + except sqlite3.OperationalError: + pass + try: + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_users_expires_at + ON users (expires_at) + """ + ) + except sqlite3.OperationalError: + pass try: conn.execute("ALTER TABLE requests_cache ADD COLUMN requested_by_id INTEGER") except sqlite3.OperationalError: @@ -391,16 +497,44 @@ def create_user( role: str = "user", auth_provider: str = "local", jellyseerr_user_id: Optional[int] = None, + auto_search_enabled: bool = True, + profile_id: Optional[int] = None, + expires_at: Optional[str] = None, + invited_by_code: Optional[str] = None, ) -> None: created_at = datetime.now(timezone.utc).isoformat() password_hash = hash_password(password) with _connect() as conn: conn.execute( """ - INSERT INTO users (username, password_hash, role, auth_provider, jellyseerr_user_id, created_at) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO users ( + username, + password_hash, + role, + auth_provider, + jellyseerr_user_id, + created_at, + auto_search_enabled, + profile_id, + expires_at, + invited_by_code, + invited_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, - (username, password_hash, role, auth_provider, jellyseerr_user_id, created_at), + ( + username, + password_hash, + role, + auth_provider, + jellyseerr_user_id, + created_at, + 1 if auto_search_enabled else 0, + profile_id, + expires_at, + invited_by_code, + created_at if invited_by_code else None, + ), ) @@ -410,16 +544,44 @@ def create_user_if_missing( role: str = "user", auth_provider: str = "local", jellyseerr_user_id: Optional[int] = None, + auto_search_enabled: bool = True, + profile_id: Optional[int] = None, + expires_at: Optional[str] = None, + invited_by_code: Optional[str] = None, ) -> bool: created_at = datetime.now(timezone.utc).isoformat() password_hash = hash_password(password) with _connect() as conn: cursor = conn.execute( """ - INSERT OR IGNORE INTO users (username, password_hash, role, auth_provider, jellyseerr_user_id, created_at) - VALUES (?, ?, ?, ?, ?, ?) + INSERT OR IGNORE INTO users ( + username, + password_hash, + role, + auth_provider, + jellyseerr_user_id, + created_at, + auto_search_enabled, + profile_id, + expires_at, + invited_by_code, + invited_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, - (username, password_hash, role, auth_provider, jellyseerr_user_id, created_at), + ( + username, + password_hash, + role, + auth_provider, + jellyseerr_user_id, + created_at, + 1 if auto_search_enabled else 0, + profile_id, + expires_at, + invited_by_code, + created_at if invited_by_code else None, + ), ) return cursor.rowcount > 0 @@ -429,7 +591,9 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]: row = conn.execute( """ SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id, - created_at, last_login_at, is_blocked, auto_search_enabled, jellyfin_password_hash, last_jellyfin_auth_at + created_at, last_login_at, is_blocked, auto_search_enabled, + profile_id, expires_at, invited_by_code, invited_at, + jellyfin_password_hash, last_jellyfin_auth_at FROM users WHERE username = ? COLLATE NOCASE """, @@ -448,8 +612,13 @@ 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]), - "jellyfin_password_hash": row[10], - "last_jellyfin_auth_at": row[11], + "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], } @@ -458,7 +627,9 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]: row = conn.execute( """ SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id, - created_at, last_login_at, is_blocked, auto_search_enabled, jellyfin_password_hash, last_jellyfin_auth_at + created_at, last_login_at, is_blocked, auto_search_enabled, + profile_id, expires_at, invited_by_code, invited_at, + jellyfin_password_hash, last_jellyfin_auth_at FROM users WHERE id = ? """, @@ -477,15 +648,22 @@ 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]), - "jellyfin_password_hash": row[10], - "last_jellyfin_auth_at": row[11], + "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], } def get_all_users() -> list[Dict[str, Any]]: with _connect() as conn: rows = conn.execute( """ - SELECT id, username, role, auth_provider, jellyseerr_user_id, created_at, last_login_at, is_blocked, auto_search_enabled + 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 FROM users ORDER BY username COLLATE NOCASE """ @@ -503,6 +681,11 @@ 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]), } ) return results @@ -580,6 +763,333 @@ def set_auto_search_enabled_for_non_admin_users(enabled: bool) -> int: return cursor.rowcount +def set_user_profile_id(username: str, profile_id: Optional[int]) -> None: + with _connect() as conn: + conn.execute( + """ + UPDATE users SET profile_id = ? WHERE username = ? COLLATE NOCASE + """, + (profile_id, username), + ) + + +def set_user_expires_at(username: str, expires_at: Optional[str]) -> None: + with _connect() as conn: + conn.execute( + """ + UPDATE users SET expires_at = ? WHERE username = ? COLLATE NOCASE + """, + (expires_at, username), + ) + + +def _row_to_user_profile(row: Any) -> Dict[str, Any]: + return { + "id": row[0], + "name": row[1], + "description": row[2], + "role": row[3], + "auto_search_enabled": bool(row[4]), + "account_expires_days": row[5], + "is_active": bool(row[6]), + "created_at": row[7], + "updated_at": row[8], + } + + +def list_user_profiles() -> list[Dict[str, Any]]: + with _connect() as conn: + rows = conn.execute( + """ + SELECT id, name, description, role, auto_search_enabled, account_expires_days, is_active, created_at, updated_at + FROM user_profiles + ORDER BY name COLLATE NOCASE + """ + ).fetchall() + return [_row_to_user_profile(row) for row in rows] + + +def get_user_profile(profile_id: int) -> Optional[Dict[str, Any]]: + with _connect() as conn: + row = conn.execute( + """ + SELECT id, name, description, role, auto_search_enabled, account_expires_days, is_active, created_at, updated_at + FROM user_profiles + WHERE id = ? + """, + (profile_id,), + ).fetchone() + if not row: + return None + return _row_to_user_profile(row) + + +def create_user_profile( + name: str, + description: Optional[str] = None, + role: str = "user", + auto_search_enabled: bool = True, + account_expires_days: Optional[int] = None, + is_active: bool = True, +) -> Dict[str, Any]: + timestamp = datetime.now(timezone.utc).isoformat() + with _connect() as conn: + cursor = conn.execute( + """ + INSERT INTO user_profiles ( + name, description, role, auto_search_enabled, account_expires_days, is_active, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + name, + description, + role, + 1 if auto_search_enabled else 0, + account_expires_days, + 1 if is_active else 0, + timestamp, + timestamp, + ), + ) + profile_id = int(cursor.lastrowid) + profile = get_user_profile(profile_id) + if not profile: + raise RuntimeError("Profile creation failed") + return profile + + +def update_user_profile( + profile_id: int, + *, + name: str, + description: Optional[str], + role: str, + auto_search_enabled: bool, + account_expires_days: Optional[int], + is_active: bool, +) -> Optional[Dict[str, Any]]: + timestamp = datetime.now(timezone.utc).isoformat() + with _connect() as conn: + cursor = conn.execute( + """ + UPDATE user_profiles + SET name = ?, description = ?, role = ?, auto_search_enabled = ?, + account_expires_days = ?, is_active = ?, updated_at = ? + WHERE id = ? + """, + ( + name, + description, + role, + 1 if auto_search_enabled else 0, + account_expires_days, + 1 if is_active else 0, + timestamp, + profile_id, + ), + ) + if cursor.rowcount <= 0: + return None + return get_user_profile(profile_id) + + +def delete_user_profile(profile_id: int) -> bool: + with _connect() as conn: + users_count = conn.execute( + "SELECT COUNT(*) FROM users WHERE profile_id = ?", + (profile_id,), + ).fetchone() + invites_count = conn.execute( + "SELECT COUNT(*) FROM signup_invites WHERE profile_id = ?", + (profile_id,), + ).fetchone() + if int((users_count or [0])[0] or 0) > 0: + raise ValueError("Profile is assigned to existing users.") + if int((invites_count or [0])[0] or 0) > 0: + raise ValueError("Profile is assigned to existing invites.") + cursor = conn.execute( + "DELETE FROM user_profiles WHERE id = ?", + (profile_id,), + ) + return cursor.rowcount > 0 + + +def _row_to_signup_invite(row: Any) -> Dict[str, Any]: + max_uses = row[6] + use_count = int(row[7] or 0) + expires_at = row[9] + is_expired = _is_datetime_in_past(expires_at) + remaining_uses = None if max_uses is None else max(int(max_uses) - use_count, 0) + return { + "id": row[0], + "code": row[1], + "label": row[2], + "description": row[3], + "profile_id": row[4], + "role": row[5], + "max_uses": max_uses, + "use_count": use_count, + "enabled": bool(row[8]), + "expires_at": expires_at, + "created_by": row[10], + "created_at": row[11], + "updated_at": row[12], + "is_expired": is_expired, + "remaining_uses": remaining_uses, + "is_usable": bool(row[8]) and not is_expired and (remaining_uses is None or remaining_uses > 0), + } + + +def list_signup_invites() -> list[Dict[str, Any]]: + with _connect() as conn: + rows = conn.execute( + """ + SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled, + expires_at, created_by, created_at, updated_at + FROM signup_invites + ORDER BY created_at DESC, id DESC + """ + ).fetchall() + return [_row_to_signup_invite(row) for row in rows] + + +def get_signup_invite_by_id(invite_id: int) -> Optional[Dict[str, Any]]: + with _connect() as conn: + row = conn.execute( + """ + SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled, + expires_at, created_by, created_at, updated_at + FROM signup_invites + WHERE id = ? + """, + (invite_id,), + ).fetchone() + if not row: + return None + return _row_to_signup_invite(row) + + +def get_signup_invite_by_code(code: str) -> Optional[Dict[str, Any]]: + with _connect() as conn: + row = conn.execute( + """ + SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled, + expires_at, created_by, created_at, updated_at + FROM signup_invites + WHERE code = ? COLLATE NOCASE + """, + (code,), + ).fetchone() + if not row: + return None + return _row_to_signup_invite(row) + + +def create_signup_invite( + *, + code: str, + label: Optional[str] = None, + description: Optional[str] = None, + profile_id: Optional[int] = None, + role: Optional[str] = None, + max_uses: Optional[int] = None, + enabled: bool = True, + expires_at: Optional[str] = None, + created_by: Optional[str] = None, +) -> Dict[str, Any]: + timestamp = datetime.now(timezone.utc).isoformat() + with _connect() as conn: + cursor = conn.execute( + """ + INSERT INTO signup_invites ( + code, label, description, profile_id, role, max_uses, use_count, enabled, + expires_at, created_by, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?) + """, + ( + code, + label, + description, + profile_id, + role, + max_uses, + 1 if enabled else 0, + expires_at, + created_by, + timestamp, + timestamp, + ), + ) + invite_id = int(cursor.lastrowid) + invite = get_signup_invite_by_id(invite_id) + if not invite: + raise RuntimeError("Invite creation failed") + return invite + + +def update_signup_invite( + invite_id: int, + *, + code: str, + label: Optional[str], + description: Optional[str], + profile_id: Optional[int], + role: Optional[str], + max_uses: Optional[int], + enabled: bool, + expires_at: Optional[str], +) -> Optional[Dict[str, Any]]: + timestamp = datetime.now(timezone.utc).isoformat() + with _connect() as conn: + cursor = conn.execute( + """ + UPDATE signup_invites + SET code = ?, label = ?, description = ?, profile_id = ?, role = ?, max_uses = ?, + enabled = ?, expires_at = ?, updated_at = ? + WHERE id = ? + """, + ( + code, + label, + description, + profile_id, + role, + max_uses, + 1 if enabled else 0, + expires_at, + timestamp, + invite_id, + ), + ) + if cursor.rowcount <= 0: + return None + return get_signup_invite_by_id(invite_id) + + +def delete_signup_invite(invite_id: int) -> bool: + with _connect() as conn: + cursor = conn.execute( + "DELETE FROM signup_invites WHERE id = ?", + (invite_id,), + ) + return cursor.rowcount > 0 + + +def increment_signup_invite_use(invite_id: int) -> None: + timestamp = datetime.now(timezone.utc).isoformat() + with _connect() as conn: + conn.execute( + """ + UPDATE signup_invites + SET use_count = use_count + 1, updated_at = ? + WHERE id = ? + """, + (timestamp, invite_id), + ) + + def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any]]: user = get_user_by_username(username) if not user: diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index db20644..0fff9c4 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -2,6 +2,9 @@ from typing import Any, Dict, List, Optional from datetime import datetime, timedelta, timezone import ipaddress import os +import secrets +import sqlite3 +import string from urllib.parse import urlparse, urlunparse from fastapi import APIRouter, HTTPException, Depends, UploadFile, File @@ -26,6 +29,8 @@ from ..db import ( set_user_blocked, set_user_auto_search_enabled, set_auto_search_enabled_for_non_admin_users, + set_user_profile_id, + set_user_expires_at, set_user_password, set_user_role, run_integrity_check, @@ -36,6 +41,16 @@ from ..db import ( update_request_cache_title, repair_request_cache_titles, delete_non_admin_users, + list_user_profiles, + get_user_profile, + create_user_profile, + update_user_profile, + delete_user_profile, + list_signup_invites, + get_signup_invite_by_id, + create_signup_invite, + update_signup_invite, + delete_signup_invite, ) from ..runtime import get_runtime_settings from ..clients.sonarr import SonarrClient @@ -226,6 +241,105 @@ def _normalize_quality_profiles(profiles: Any) -> List[Dict[str, Any]]: return results +def _normalize_optional_text(value: Any) -> Optional[str]: + if value is None: + return None + if not isinstance(value, str): + value = str(value) + trimmed = value.strip() + return trimmed if trimmed else None + + +def _parse_optional_positive_int(value: Any, field_name: str) -> Optional[int]: + if value is None or value == "": + return None + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail=f"{field_name} must be a number") from exc + if parsed <= 0: + raise HTTPException(status_code=400, detail=f"{field_name} must be greater than 0") + return parsed + + +def _parse_optional_profile_id(value: Any) -> Optional[int]: + if value is None or value == "": + return None + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail="profile_id must be a number") from exc + if parsed <= 0: + raise HTTPException(status_code=400, detail="profile_id must be greater than 0") + profile = get_user_profile(parsed) + if not profile: + raise HTTPException(status_code=404, detail="Profile not found") + return parsed + + +def _parse_optional_expires_at(value: Any) -> Optional[str]: + if value is None or value == "": + return None + if not isinstance(value, str): + raise HTTPException(status_code=400, detail="expires_at must be an ISO datetime string") + candidate = value.strip() + if not candidate: + return None + try: + parsed = datetime.fromisoformat(candidate.replace("Z", "+00:00")) + except ValueError as exc: + raise HTTPException(status_code=400, detail="expires_at must be a valid ISO datetime") from exc + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.isoformat() + + +def _normalize_invite_code(value: Optional[str]) -> str: + raw = (value or "").strip().upper() + filtered = "".join(ch for ch in raw if ch.isalnum()) + if len(filtered) < 6: + raise HTTPException(status_code=400, detail="Invite code must be at least 6 letters/numbers.") + return filtered + + +def _generate_invite_code(length: int = 12) -> str: + alphabet = string.ascii_uppercase + string.digits + return "".join(secrets.choice(alphabet) for _ in range(length)) + + +def _normalize_role_or_none(value: Any) -> Optional[str]: + if value is None: + return None + if not isinstance(value, str): + value = str(value) + role = value.strip().lower() + if not role: + return None + if role not in {"user", "admin"}: + raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'") + return role + + +def _calculate_profile_expiry(profile: Dict[str, Any]) -> Optional[str]: + expires_days = profile.get("account_expires_days") + if isinstance(expires_days, int) and expires_days > 0: + return (datetime.now(timezone.utc) + timedelta(days=expires_days)).isoformat() + return None + + +def _apply_profile_defaults_to_user(username: str, profile: Dict[str, Any]) -> Dict[str, Any]: + set_user_profile_id(username, int(profile["id"])) + role = profile.get("role") or "user" + if role in {"user", "admin"}: + set_user_role(username, role) + set_user_auto_search_enabled(username, bool(profile.get("auto_search_enabled", True))) + set_user_expires_at(username, _calculate_profile_expiry(profile)) + refreshed = get_user_by_username(username) + if not refreshed: + raise HTTPException(status_code=404, detail="User not found") + return refreshed + + @router.get("/settings") async def list_settings() -> Dict[str, Any]: overrides = get_settings_overrides() @@ -607,12 +721,12 @@ async def clear_logs() -> Dict[str, Any]: @router.get("/users") async def list_users() -> Dict[str, Any]: - users = [user for user in get_all_users() if user.get("role") == "admin" or user.get("auth_provider") == "jellyseerr"] + users = get_all_users() return {"users": users} @router.get("/users/summary") async def list_users_summary() -> Dict[str, Any]: - users = [user for user in get_all_users() if user.get("role") == "admin" or user.get("auth_provider") == "jellyseerr"] + users = get_all_users() results: list[Dict[str, Any]] = [] for user in users: username = user.get("username") or "" @@ -674,6 +788,57 @@ 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}/profile") +async def update_user_profile_assignment(username: str, payload: Dict[str, Any]) -> Dict[str, Any]: + user = get_user_by_username(username) + if not user: + raise HTTPException(status_code=404, detail="User not found") + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="Invalid payload") + profile_id = payload.get("profile_id") + if profile_id in (None, ""): + set_user_profile_id(username, None) + refreshed = get_user_by_username(username) + return {"status": "ok", "user": refreshed} + try: + parsed_profile_id = int(profile_id) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail="profile_id must be a number") from exc + profile = get_user_profile(parsed_profile_id) + if not profile: + raise HTTPException(status_code=404, detail="Profile not found") + if not profile.get("is_active", True): + raise HTTPException(status_code=400, detail="Profile is disabled") + refreshed = _apply_profile_defaults_to_user(username, profile) + return {"status": "ok", "user": refreshed, "applied_profile_id": parsed_profile_id} + + +@router.post("/users/{username}/expiry") +async def update_user_expiry(username: str, payload: Dict[str, Any]) -> Dict[str, Any]: + user = get_user_by_username(username) + if not user: + raise HTTPException(status_code=404, detail="User not found") + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="Invalid payload") + clear = payload.get("clear") + if clear is True: + set_user_expires_at(username, None) + refreshed = get_user_by_username(username) + return {"status": "ok", "user": refreshed} + if "days" in payload and payload.get("days") not in (None, ""): + days = _parse_optional_positive_int(payload.get("days"), "days") + expires_at = None + if days is not None: + expires_at = (datetime.now(timezone.utc) + timedelta(days=days)).isoformat() + set_user_expires_at(username, expires_at) + refreshed = get_user_by_username(username) + return {"status": "ok", "user": refreshed} + expires_at = _parse_optional_expires_at(payload.get("expires_at")) + set_user_expires_at(username, expires_at) + refreshed = get_user_by_username(username) + return {"status": "ok", "user": refreshed} + + @router.post("/users/auto-search/bulk") async def update_users_auto_search_bulk(payload: Dict[str, Any]) -> Dict[str, Any]: enabled = payload.get("enabled") if isinstance(payload, dict) else None @@ -688,6 +853,68 @@ async def update_users_auto_search_bulk(payload: Dict[str, Any]) -> Dict[str, An } +@router.post("/users/profile/bulk") +async def update_users_profile_bulk(payload: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="Invalid payload") + scope = str(payload.get("scope") or "non-admin-users").strip().lower() + if scope not in {"non-admin-users", "all-users"}: + raise HTTPException(status_code=400, detail="Invalid scope") + profile_id_value = payload.get("profile_id") + if profile_id_value in (None, ""): + users = get_all_users() + updated = 0 + for user in users: + if scope == "non-admin-users" and user.get("role") == "admin": + continue + set_user_profile_id(user["username"], None) + updated += 1 + return {"status": "ok", "updated": updated, "scope": scope, "profile_id": None} + try: + profile_id = int(profile_id_value) + except (TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail="profile_id must be a number") from exc + profile = get_user_profile(profile_id) + if not profile: + raise HTTPException(status_code=404, detail="Profile not found") + if not profile.get("is_active", True): + raise HTTPException(status_code=400, detail="Profile is disabled") + users = get_all_users() + updated = 0 + for user in users: + if scope == "non-admin-users" and user.get("role") == "admin": + continue + _apply_profile_defaults_to_user(user["username"], profile) + updated += 1 + return {"status": "ok", "updated": updated, "scope": scope, "profile_id": profile_id} + + +@router.post("/users/expiry/bulk") +async def update_users_expiry_bulk(payload: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="Invalid payload") + scope = str(payload.get("scope") or "non-admin-users").strip().lower() + if scope not in {"non-admin-users", "all-users"}: + raise HTTPException(status_code=400, detail="Invalid scope") + clear = payload.get("clear") + expires_at: Optional[str] = None + if clear is True: + expires_at = None + elif "days" in payload and payload.get("days") not in (None, ""): + days = _parse_optional_positive_int(payload.get("days"), "days") + expires_at = (datetime.now(timezone.utc) + timedelta(days=int(days or 0))).isoformat() if days else None + else: + expires_at = _parse_optional_expires_at(payload.get("expires_at")) + users = get_all_users() + updated = 0 + for user in users: + if scope == "non-admin-users" and user.get("role") == "admin": + continue + set_user_expires_at(user["username"], expires_at) + updated += 1 + return {"status": "ok", "updated": updated, "scope": scope, "expires_at": expires_at} + + @router.post("/users/{username}/password") async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[str, Any]: new_password = payload.get("password") if isinstance(payload, dict) else None @@ -702,3 +929,211 @@ async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[s ) set_user_password(username, new_password.strip()) return {"status": "ok", "username": username} + + +@router.get("/profiles") +async def get_profiles() -> Dict[str, Any]: + profiles = list_user_profiles() + users = get_all_users() + invites = list_signup_invites() + user_counts: Dict[int, int] = {} + invite_counts: Dict[int, int] = {} + for user in users: + profile_id = user.get("profile_id") + if isinstance(profile_id, int): + user_counts[profile_id] = user_counts.get(profile_id, 0) + 1 + for invite in invites: + profile_id = invite.get("profile_id") + if isinstance(profile_id, int): + invite_counts[profile_id] = invite_counts.get(profile_id, 0) + 1 + enriched = [] + for profile in profiles: + pid = int(profile["id"]) + enriched.append( + { + **profile, + "assigned_users": user_counts.get(pid, 0), + "assigned_invites": invite_counts.get(pid, 0), + } + ) + return {"profiles": enriched} + + +@router.post("/profiles") +async def create_profile(payload: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="Invalid payload") + name = _normalize_optional_text(payload.get("name")) + if not name: + raise HTTPException(status_code=400, detail="Profile name is required") + role = _normalize_role_or_none(payload.get("role")) or "user" + auto_search_enabled = payload.get("auto_search_enabled") + if auto_search_enabled is None: + auto_search_enabled = True + if not isinstance(auto_search_enabled, bool): + raise HTTPException(status_code=400, detail="auto_search_enabled must be true or false") + is_active = payload.get("is_active") + if is_active is None: + is_active = True + if not isinstance(is_active, bool): + raise HTTPException(status_code=400, detail="is_active must be true or false") + account_expires_days = _parse_optional_positive_int( + payload.get("account_expires_days"), "account_expires_days" + ) + try: + profile = create_user_profile( + name=name, + description=_normalize_optional_text(payload.get("description")), + role=role, + auto_search_enabled=auto_search_enabled, + account_expires_days=account_expires_days, + is_active=is_active, + ) + except sqlite3.IntegrityError as exc: + raise HTTPException(status_code=409, detail="A profile with that name already exists") from exc + return {"status": "ok", "profile": profile} + + +@router.put("/profiles/{profile_id}") +async def edit_profile(profile_id: int, payload: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="Invalid payload") + existing = get_user_profile(profile_id) + if not existing: + raise HTTPException(status_code=404, detail="Profile not found") + name = _normalize_optional_text(payload.get("name")) + if not name: + raise HTTPException(status_code=400, detail="Profile name is required") + role = _normalize_role_or_none(payload.get("role")) or "user" + auto_search_enabled = payload.get("auto_search_enabled") + if not isinstance(auto_search_enabled, bool): + raise HTTPException(status_code=400, detail="auto_search_enabled must be true or false") + is_active = payload.get("is_active") + if not isinstance(is_active, bool): + raise HTTPException(status_code=400, detail="is_active must be true or false") + account_expires_days = _parse_optional_positive_int( + payload.get("account_expires_days"), "account_expires_days" + ) + try: + profile = update_user_profile( + profile_id, + name=name, + description=_normalize_optional_text(payload.get("description")), + role=role, + auto_search_enabled=auto_search_enabled, + account_expires_days=account_expires_days, + is_active=is_active, + ) + except sqlite3.IntegrityError as exc: + raise HTTPException(status_code=409, detail="A profile with that name already exists") from exc + if not profile: + raise HTTPException(status_code=404, detail="Profile not found") + return {"status": "ok", "profile": profile} + + +@router.delete("/profiles/{profile_id}") +async def remove_profile(profile_id: int) -> Dict[str, Any]: + try: + deleted = delete_user_profile(profile_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if not deleted: + raise HTTPException(status_code=404, detail="Profile not found") + return {"status": "ok", "deleted": True, "profile_id": profile_id} + + +@router.get("/invites") +async def get_invites() -> Dict[str, Any]: + invites = list_signup_invites() + profiles = {profile["id"]: profile for profile in list_user_profiles()} + results = [] + for invite in invites: + profile = profiles.get(invite.get("profile_id")) + results.append( + { + **invite, + "profile": ( + { + "id": profile.get("id"), + "name": profile.get("name"), + } + if profile + else None + ), + } + ) + return {"invites": results} + + +@router.post("/invites") +async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]: + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="Invalid payload") + raw_code = _normalize_optional_text(payload.get("code")) + code = _normalize_invite_code(raw_code) if raw_code else _generate_invite_code() + profile_id = _parse_optional_profile_id(payload.get("profile_id")) + enabled = payload.get("enabled") + if enabled is None: + enabled = True + if not isinstance(enabled, bool): + raise HTTPException(status_code=400, detail="enabled must be true or false") + role = _normalize_role_or_none(payload.get("role")) + max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses") + expires_at = _parse_optional_expires_at(payload.get("expires_at")) + try: + invite = create_signup_invite( + code=code, + label=_normalize_optional_text(payload.get("label")), + description=_normalize_optional_text(payload.get("description")), + profile_id=profile_id, + role=role, + max_uses=max_uses, + enabled=enabled, + expires_at=expires_at, + created_by=current_user.get("username"), + ) + except sqlite3.IntegrityError as exc: + raise HTTPException(status_code=409, detail="An invite with that code already exists") from exc + return {"status": "ok", "invite": invite} + + +@router.put("/invites/{invite_id}") +async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]: + if not isinstance(payload, dict): + raise HTTPException(status_code=400, detail="Invalid payload") + existing = get_signup_invite_by_id(invite_id) + if not existing: + raise HTTPException(status_code=404, detail="Invite not found") + code = _normalize_invite_code(_normalize_optional_text(payload.get("code")) or existing["code"]) + profile_id = _parse_optional_profile_id(payload.get("profile_id")) + enabled = payload.get("enabled") + if not isinstance(enabled, bool): + raise HTTPException(status_code=400, detail="enabled must be true or false") + role = _normalize_role_or_none(payload.get("role")) + max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses") + expires_at = _parse_optional_expires_at(payload.get("expires_at")) + try: + invite = update_signup_invite( + invite_id, + code=code, + label=_normalize_optional_text(payload.get("label")), + description=_normalize_optional_text(payload.get("description")), + profile_id=profile_id, + role=role, + max_uses=max_uses, + enabled=enabled, + expires_at=expires_at, + ) + except sqlite3.IntegrityError as exc: + raise HTTPException(status_code=409, detail="An invite with that code already exists") from exc + if not invite: + raise HTTPException(status_code=404, detail="Invite not found") + return {"status": "ok", "invite": invite} + + +@router.delete("/invites/{invite_id}") +async def remove_invite(invite_id: int) -> Dict[str, Any]: + deleted = delete_signup_invite(invite_id) + if not deleted: + raise HTTPException(status_code=404, detail="Invite not found") + return {"status": "ok", "deleted": True, "invite_id": invite_id} diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index f6fe65e..16e2529 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -5,12 +5,16 @@ from fastapi.security import OAuth2PasswordRequestForm from ..db import ( verify_user_password, + create_user, create_user_if_missing, set_last_login, get_user_by_username, set_user_password, set_jellyfin_auth_cache, set_user_jellyseerr_id, + get_signup_invite_by_code, + increment_signup_invite_use, + get_user_profile, get_user_activity, get_user_activity_summary, get_user_request_stats, @@ -80,13 +84,60 @@ def _extract_jellyseerr_user_id(response: dict) -> int | None: return None +def _is_user_expired(user: dict | None) -> bool: + if not user: + return False + expires_at = user.get("expires_at") + if not expires_at: + return False + try: + parsed = datetime.fromisoformat(str(expires_at).replace("Z", "+00:00")) + except ValueError: + return False + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed <= datetime.now(timezone.utc) + + +def _assert_user_can_login(user: dict | None) -> None: + if not user: + return + if user.get("is_blocked"): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked") + if _is_user_expired(user): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired") + + +def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict: + return { + "code": invite.get("code"), + "label": invite.get("label"), + "description": invite.get("description"), + "enabled": bool(invite.get("enabled")), + "expires_at": invite.get("expires_at"), + "max_uses": invite.get("max_uses"), + "use_count": invite.get("use_count", 0), + "remaining_uses": invite.get("remaining_uses"), + "is_expired": bool(invite.get("is_expired")), + "is_usable": bool(invite.get("is_usable")), + "profile": ( + { + "id": profile.get("id"), + "name": profile.get("name"), + "description": profile.get("description"), + } + if profile + else None + ), + } + + @router.post("/login") async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict: user = verify_user_password(form_data.username, form_data.password) if not user: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") - if user.get("is_blocked"): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked") + _assert_user_can_login(user) token = create_access_token(user["username"], user["role"]) set_last_login(user["username"]) return { @@ -107,8 +158,7 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di username = form_data.username password = form_data.password user = get_user_by_username(username) - if user and user.get("is_blocked"): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked") + _assert_user_can_login(user) if user and _has_valid_jellyfin_cache(user, password): token = create_access_token(username, "user") set_last_login(username) @@ -121,8 +171,7 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials") create_user_if_missing(username, "jellyfin-user", role="user", auth_provider="jellyfin") user = get_user_by_username(username) - if user and user.get("is_blocked"): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked") + _assert_user_can_login(user) try: users = await client.get_users() if isinstance(users, list): @@ -167,8 +216,7 @@ async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) -> jellyseerr_user_id=jellyseerr_user_id, ) user = get_user_by_username(form_data.username) - if user and user.get("is_blocked"): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked") + _assert_user_can_login(user) if jellyseerr_user_id is not None: set_user_jellyseerr_id(form_data.username, jellyseerr_user_id) token = create_access_token(form_data.username, "user") @@ -181,6 +229,107 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict: return current_user +@router.get("/invites/{code}") +async def invite_details(code: str) -> dict: + invite = get_signup_invite_by_code(code.strip()) + if not invite: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found") + profile = None + profile_id = invite.get("profile_id") + if profile_id is not None: + profile = get_user_profile(int(profile_id)) + if profile and not profile.get("is_active", True): + invite = {**invite, "is_usable": False} + return {"invite": _public_invite_payload(invite, profile)} + + +@router.post("/signup") +async def signup(payload: dict) -> dict: + if not isinstance(payload, dict): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload") + invite_code = str(payload.get("invite_code") or "").strip() + username = str(payload.get("username") or "").strip() + password = str(payload.get("password") or "") + if not invite_code: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite code is required") + if not username: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username is required") + if len(password.strip()) < 8: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Password must be at least 8 characters.", + ) + if get_user_by_username(username): + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already exists") + + invite = get_signup_invite_by_code(invite_code) + if not invite: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found") + if not invite.get("enabled"): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite is disabled") + if invite.get("is_expired"): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite has expired") + remaining_uses = invite.get("remaining_uses") + if remaining_uses is not None and int(remaining_uses) <= 0: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite has no remaining uses") + + profile = None + profile_id = invite.get("profile_id") + if profile_id is not None: + profile = get_user_profile(int(profile_id)) + if not profile: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite profile not found") + if not profile.get("is_active", True): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite profile is disabled") + + invite_role = invite.get("role") + profile_role = profile.get("role") if profile else None + role = invite_role if invite_role in {"user", "admin"} else profile_role + if role not in {"user", "admin"}: + role = "user" + + auto_search_enabled = ( + bool(profile.get("auto_search_enabled", True)) + if profile is not None + else True + ) + + expires_at = None + account_expires_days = profile.get("account_expires_days") if profile else None + if isinstance(account_expires_days, int) and account_expires_days > 0: + expires_at = (datetime.now(timezone.utc) + timedelta(days=account_expires_days)).isoformat() + + try: + create_user( + username, + password.strip(), + role=role, + auth_provider="local", + auto_search_enabled=auto_search_enabled, + profile_id=int(profile_id) if profile_id is not None else None, + expires_at=expires_at, + invited_by_code=invite.get("code"), + ) + except Exception as exc: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc + + increment_signup_invite_use(int(invite["id"])) + created_user = get_user_by_username(username) + _assert_user_can_login(created_user) + token = create_access_token(username, role) + set_last_login(username) + return { + "access_token": token, + "token_type": "bearer", + "user": { + "username": username, + "role": role, + "profile_id": created_user.get("profile_id") if created_user else None, + "expires_at": created_user.get("expires_at") if created_user else None, + }, + } + + @router.get("/profile") async def profile(current_user: dict = Depends(get_current_user)) -> dict: username = current_user.get("username") or "" diff --git a/frontend/app/admin/invites/page.tsx b/frontend/app/admin/invites/page.tsx new file mode 100644 index 0000000..781b361 --- /dev/null +++ b/frontend/app/admin/invites/page.tsx @@ -0,0 +1,419 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { useRouter } from 'next/navigation' +import AdminShell from '../../ui/AdminShell' +import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth' + +type ProfileOption = { + id: number + name: string +} + +type Invite = { + id: number + code: string + label?: string | null + description?: string | null + profile_id?: number | null + profile?: ProfileOption | null + role?: 'user' | 'admin' | null + max_uses?: number | null + use_count: number + remaining_uses?: number | null + enabled: boolean + expires_at?: string | null + is_expired?: boolean + is_usable?: boolean + created_at?: string | null +} + +type InviteForm = { + code: string + label: string + description: string + profile_id: string + role: '' | 'user' | 'admin' + max_uses: string + enabled: boolean + expires_at: string +} + +const defaultForm = (): InviteForm => ({ + code: '', + label: '', + description: '', + profile_id: '', + role: '', + max_uses: '', + enabled: true, + expires_at: '', +}) + +const formatDate = (value?: string | null) => { + if (!value) return 'Never' + const date = new Date(value) + if (Number.isNaN(date.valueOf())) return value + return date.toLocaleString() +} + +export default function AdminInvitesPage() { + const router = useRouter() + const [invites, setInvites] = useState([]) + const [profiles, setProfiles] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [status, setStatus] = useState(null) + const [editingId, setEditingId] = useState(null) + const [form, setForm] = useState(defaultForm()) + + const signupBaseUrl = useMemo(() => { + if (typeof window === 'undefined') return '/signup' + return `${window.location.origin}/signup` + }, []) + + const handleAuthResponse = (response: Response) => { + if (response.status === 401) { + clearToken() + router.push('/login') + return true + } + if (response.status === 403) { + router.push('/') + return true + } + return false + } + + const loadData = async () => { + if (!getToken()) { + router.push('/login') + return + } + setLoading(true) + setError(null) + try { + const baseUrl = getApiBase() + const [inviteRes, profileRes] = await Promise.all([ + authFetch(`${baseUrl}/admin/invites`), + authFetch(`${baseUrl}/admin/profiles`), + ]) + if (!inviteRes.ok) { + if (handleAuthResponse(inviteRes)) return + throw new Error(`Failed to load invites (${inviteRes.status})`) + } + if (!profileRes.ok) { + if (handleAuthResponse(profileRes)) return + throw new Error(`Failed to load profiles (${profileRes.status})`) + } + const [inviteData, profileData] = await Promise.all([inviteRes.json(), profileRes.json()]) + setInvites(Array.isArray(inviteData?.invites) ? inviteData.invites : []) + const profileRows = Array.isArray(profileData?.profiles) ? profileData.profiles : [] + setProfiles( + profileRows.map((profile: any) => ({ + id: Number(profile.id ?? 0), + name: String(profile.name ?? 'Unnamed'), + })) + ) + } catch (err) { + console.error(err) + setError('Could not load invites.') + } finally { + setLoading(false) + } + } + + useEffect(() => { + void loadData() + }, []) + + const resetEditor = () => { + setEditingId(null) + setForm(defaultForm()) + } + + const editInvite = (invite: Invite) => { + setEditingId(invite.id) + setForm({ + code: invite.code ?? '', + label: invite.label ?? '', + description: invite.description ?? '', + profile_id: + typeof invite.profile_id === 'number' && invite.profile_id > 0 + ? String(invite.profile_id) + : '', + role: (invite.role ?? '') as '' | 'user' | 'admin', + max_uses: typeof invite.max_uses === 'number' ? String(invite.max_uses) : '', + enabled: invite.enabled !== false, + expires_at: invite.expires_at ?? '', + }) + setStatus(null) + setError(null) + } + + const saveInvite = async (event: React.FormEvent) => { + event.preventDefault() + setSaving(true) + setError(null) + setStatus(null) + try { + const baseUrl = getApiBase() + const payload = { + code: form.code || null, + label: form.label || null, + description: form.description || null, + profile_id: form.profile_id || null, + role: form.role || null, + max_uses: form.max_uses || null, + enabled: form.enabled, + expires_at: form.expires_at || null, + } + const url = + editingId == null ? `${baseUrl}/admin/invites` : `${baseUrl}/admin/invites/${editingId}` + const response = await authFetch(url, { + method: editingId == null ? 'POST' : 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + if (!response.ok) { + if (handleAuthResponse(response)) return + const text = await response.text() + throw new Error(text || 'Save failed') + } + setStatus(editingId == null ? 'Invite created.' : 'Invite updated.') + resetEditor() + await loadData() + } catch (err) { + console.error(err) + setError(err instanceof Error ? err.message : 'Could not save invite.') + } finally { + setSaving(false) + } + } + + const deleteInvite = async (invite: Invite) => { + if (!window.confirm(`Delete invite "${invite.code}"?`)) return + setError(null) + setStatus(null) + try { + const baseUrl = getApiBase() + const response = await authFetch(`${baseUrl}/admin/invites/${invite.id}`, { + method: 'DELETE', + }) + if (!response.ok) { + if (handleAuthResponse(response)) return + const text = await response.text() + throw new Error(text || 'Delete failed') + } + if (editingId === invite.id) resetEditor() + setStatus(`Deleted invite ${invite.code}.`) + await loadData() + } catch (err) { + console.error(err) + setError(err instanceof Error ? err.message : 'Could not delete invite.') + } + } + + const copyInviteLink = async (invite: Invite) => { + const url = `${signupBaseUrl}?code=${encodeURIComponent(invite.code)}` + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(url) + setStatus(`Copied invite link for ${invite.code}.`) + } else { + window.prompt('Copy invite link', url) + } + } catch (err) { + console.error(err) + window.prompt('Copy invite link', url) + } + } + + return ( + + + + + } + > +
+ {error &&
{error}
} + {status &&
{status}
} +
+
+

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

+

+ Link an invite to a profile to apply account defaults at sign-up. +

+
+
+ + +
+