Build 2602260214: invites profiles and expiry admin controls

This commit is contained in:
2026-02-26 02:15:21 +13:00
parent 9be0ec75ec
commit f78382c019
14 changed files with 2795 additions and 31 deletions

View File

@@ -1 +1 @@
2602260022 2602260214

View File

@@ -1,3 +1,4 @@
from datetime import datetime, timezone
from typing import Dict, Any from typing import Dict, Any
from fastapi import Depends, HTTPException, status, Request from fastapi import Depends, HTTPException, status, Request
@@ -8,6 +9,21 @@ from .security import safe_decode_token, TokenError
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login") 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: def _extract_client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for") forwarded = request.headers.get("x-forwarded-for")
if forwarded: 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") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
if user.get("is_blocked"): if user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User 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: if request is not None:
ip = _extract_client_ip(request) 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"), "auth_provider": user.get("auth_provider", "local"),
"jellyseerr_user_id": user.get("jellyseerr_user_id"), "jellyseerr_user_id": user.get("jellyseerr_user_id"),
"auto_search_enabled": bool(user.get("auto_search_enabled", True)), "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)),
} }

View File

@@ -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' 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'

View File

@@ -24,6 +24,28 @@ def _connect() -> sqlite3.Connection:
return sqlite3.connect(_db_path()) 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]: def _normalize_title_value(title: Optional[str]) -> Optional[str]:
if not isinstance(title, str): if not isinstance(title, str):
return None return None
@@ -150,11 +172,61 @@ def init_db() -> None:
last_login_at TEXT, last_login_at TEXT,
is_blocked INTEGER NOT NULL DEFAULT 0, is_blocked INTEGER NOT NULL DEFAULT 0,
auto_search_enabled INTEGER NOT NULL DEFAULT 1, 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, jellyfin_password_hash TEXT,
last_jellyfin_auth_at 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( conn.execute(
""" """
CREATE TABLE IF NOT EXISTS settings ( 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") conn.execute("ALTER TABLE users ADD COLUMN auto_search_enabled INTEGER NOT NULL DEFAULT 1")
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass 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: try:
conn.execute("ALTER TABLE requests_cache ADD COLUMN requested_by_id INTEGER") conn.execute("ALTER TABLE requests_cache ADD COLUMN requested_by_id INTEGER")
except sqlite3.OperationalError: except sqlite3.OperationalError:
@@ -391,16 +497,44 @@ def create_user(
role: str = "user", role: str = "user",
auth_provider: str = "local", auth_provider: str = "local",
jellyseerr_user_id: Optional[int] = None, 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: ) -> None:
created_at = datetime.now(timezone.utc).isoformat() created_at = datetime.now(timezone.utc).isoformat()
password_hash = hash_password(password) password_hash = hash_password(password)
with _connect() as conn: with _connect() as conn:
conn.execute( conn.execute(
""" """
INSERT INTO users (username, password_hash, role, auth_provider, jellyseerr_user_id, created_at) INSERT INTO users (
VALUES (?, ?, ?, ?, ?, ?) 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", role: str = "user",
auth_provider: str = "local", auth_provider: str = "local",
jellyseerr_user_id: Optional[int] = None, 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: ) -> bool:
created_at = datetime.now(timezone.utc).isoformat() created_at = datetime.now(timezone.utc).isoformat()
password_hash = hash_password(password) password_hash = hash_password(password)
with _connect() as conn: with _connect() as conn:
cursor = conn.execute( cursor = conn.execute(
""" """
INSERT OR IGNORE INTO users (username, password_hash, role, auth_provider, jellyseerr_user_id, created_at) INSERT OR IGNORE INTO users (
VALUES (?, ?, ?, ?, ?, ?) 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 return cursor.rowcount > 0
@@ -429,7 +591,9 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
row = conn.execute( row = conn.execute(
""" """
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id, 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 FROM users
WHERE username = ? COLLATE NOCASE WHERE username = ? COLLATE NOCASE
""", """,
@@ -448,8 +612,13 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
"last_login_at": row[7], "last_login_at": row[7],
"is_blocked": bool(row[8]), "is_blocked": bool(row[8]),
"auto_search_enabled": bool(row[9]), "auto_search_enabled": bool(row[9]),
"jellyfin_password_hash": row[10], "profile_id": row[10],
"last_jellyfin_auth_at": row[11], "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( row = conn.execute(
""" """
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id, 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 FROM users
WHERE id = ? WHERE id = ?
""", """,
@@ -477,15 +648,22 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
"last_login_at": row[7], "last_login_at": row[7],
"is_blocked": bool(row[8]), "is_blocked": bool(row[8]),
"auto_search_enabled": bool(row[9]), "auto_search_enabled": bool(row[9]),
"jellyfin_password_hash": row[10], "profile_id": row[10],
"last_jellyfin_auth_at": row[11], "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]]: def get_all_users() -> list[Dict[str, Any]]:
with _connect() as conn: with _connect() as conn:
rows = conn.execute( 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 FROM users
ORDER BY username COLLATE NOCASE ORDER BY username COLLATE NOCASE
""" """
@@ -503,6 +681,11 @@ def get_all_users() -> list[Dict[str, Any]]:
"last_login_at": row[6], "last_login_at": row[6],
"is_blocked": bool(row[7]), "is_blocked": bool(row[7]),
"auto_search_enabled": bool(row[8]), "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 return results
@@ -580,6 +763,333 @@ def set_auto_search_enabled_for_non_admin_users(enabled: bool) -> int:
return cursor.rowcount 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]]: def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any]]:
user = get_user_by_username(username) user = get_user_by_username(username)
if not user: if not user:

View File

@@ -2,6 +2,9 @@ from typing import Any, Dict, List, Optional
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
import ipaddress import ipaddress
import os import os
import secrets
import sqlite3
import string
from urllib.parse import urlparse, urlunparse from urllib.parse import urlparse, urlunparse
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
@@ -26,6 +29,8 @@ from ..db import (
set_user_blocked, set_user_blocked,
set_user_auto_search_enabled, set_user_auto_search_enabled,
set_auto_search_enabled_for_non_admin_users, set_auto_search_enabled_for_non_admin_users,
set_user_profile_id,
set_user_expires_at,
set_user_password, set_user_password,
set_user_role, set_user_role,
run_integrity_check, run_integrity_check,
@@ -36,6 +41,16 @@ from ..db import (
update_request_cache_title, update_request_cache_title,
repair_request_cache_titles, repair_request_cache_titles,
delete_non_admin_users, 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 ..runtime import get_runtime_settings
from ..clients.sonarr import SonarrClient from ..clients.sonarr import SonarrClient
@@ -226,6 +241,105 @@ def _normalize_quality_profiles(profiles: Any) -> List[Dict[str, Any]]:
return results 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") @router.get("/settings")
async def list_settings() -> Dict[str, Any]: async def list_settings() -> Dict[str, Any]:
overrides = get_settings_overrides() overrides = get_settings_overrides()
@@ -607,12 +721,12 @@ async def clear_logs() -> Dict[str, Any]:
@router.get("/users") @router.get("/users")
async def list_users() -> Dict[str, Any]: 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} return {"users": users}
@router.get("/users/summary") @router.get("/users/summary")
async def list_users_summary() -> Dict[str, Any]: 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]] = [] results: list[Dict[str, Any]] = []
for user in users: for user in users:
username = user.get("username") or "" 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} 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") @router.post("/users/auto-search/bulk")
async def update_users_auto_search_bulk(payload: Dict[str, Any]) -> Dict[str, Any]: async def update_users_auto_search_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
enabled = payload.get("enabled") if isinstance(payload, dict) else None 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") @router.post("/users/{username}/password")
async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[str, Any]: 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 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()) set_user_password(username, new_password.strip())
return {"status": "ok", "username": username} 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}

View File

@@ -5,12 +5,16 @@ from fastapi.security import OAuth2PasswordRequestForm
from ..db import ( from ..db import (
verify_user_password, verify_user_password,
create_user,
create_user_if_missing, create_user_if_missing,
set_last_login, set_last_login,
get_user_by_username, get_user_by_username,
set_user_password, set_user_password,
set_jellyfin_auth_cache, set_jellyfin_auth_cache,
set_user_jellyseerr_id, set_user_jellyseerr_id,
get_signup_invite_by_code,
increment_signup_invite_use,
get_user_profile,
get_user_activity, get_user_activity,
get_user_activity_summary, get_user_activity_summary,
get_user_request_stats, get_user_request_stats,
@@ -80,13 +84,60 @@ def _extract_jellyseerr_user_id(response: dict) -> int | None:
return 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") @router.post("/login")
async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict: async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
user = verify_user_password(form_data.username, form_data.password) user = verify_user_password(form_data.username, form_data.password)
if not user: if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if user.get("is_blocked"): _assert_user_can_login(user)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
token = create_access_token(user["username"], user["role"]) token = create_access_token(user["username"], user["role"])
set_last_login(user["username"]) set_last_login(user["username"])
return { return {
@@ -107,8 +158,7 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
username = form_data.username username = form_data.username
password = form_data.password password = form_data.password
user = get_user_by_username(username) user = get_user_by_username(username)
if user and user.get("is_blocked"): _assert_user_can_login(user)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
if user and _has_valid_jellyfin_cache(user, password): if user and _has_valid_jellyfin_cache(user, password):
token = create_access_token(username, "user") token = create_access_token(username, "user")
set_last_login(username) 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") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
create_user_if_missing(username, "jellyfin-user", role="user", auth_provider="jellyfin") create_user_if_missing(username, "jellyfin-user", role="user", auth_provider="jellyfin")
user = get_user_by_username(username) user = get_user_by_username(username)
if user and user.get("is_blocked"): _assert_user_can_login(user)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
try: try:
users = await client.get_users() users = await client.get_users()
if isinstance(users, list): if isinstance(users, list):
@@ -167,8 +216,7 @@ async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) ->
jellyseerr_user_id=jellyseerr_user_id, jellyseerr_user_id=jellyseerr_user_id,
) )
user = get_user_by_username(form_data.username) user = get_user_by_username(form_data.username)
if user and user.get("is_blocked"): _assert_user_can_login(user)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
if jellyseerr_user_id is not None: if jellyseerr_user_id is not None:
set_user_jellyseerr_id(form_data.username, jellyseerr_user_id) set_user_jellyseerr_id(form_data.username, jellyseerr_user_id)
token = create_access_token(form_data.username, "user") 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 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") @router.get("/profile")
async def profile(current_user: dict = Depends(get_current_user)) -> dict: async def profile(current_user: dict = Depends(get_current_user)) -> dict:
username = current_user.get("username") or "" username = current_user.get("username") or ""

View File

@@ -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<Invite[]>([])
const [profiles, setProfiles] = useState<ProfileOption[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<string | null>(null)
const [editingId, setEditingId] = useState<number | null>(null)
const [form, setForm] = useState<InviteForm>(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 (
<AdminShell
title="Invites"
subtitle="Create invite-based sign-up links for Magent accounts."
actions={
<div className="admin-inline-actions">
<button type="button" onClick={loadData} disabled={loading}>
{loading ? 'Loading…' : 'Reload'}
</button>
<button type="button" className="ghost-button" onClick={resetEditor}>
New invite
</button>
</div>
}
>
<section className="admin-section">
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
<div className="admin-split-grid">
<div className="admin-panel">
<h2>{editingId == null ? 'Create invite' : 'Edit invite'}</h2>
<p className="lede">
Link an invite to a profile to apply account defaults at sign-up.
</p>
<form onSubmit={saveInvite} className="admin-form compact-form">
<div className="admin-fields-grid">
<label>
Code (optional)
<input
value={form.code}
onChange={(e) => setForm((current) => ({ ...current, code: e.target.value }))}
placeholder="Leave blank to auto-generate"
/>
</label>
<label>
Label
<input
value={form.label}
onChange={(e) => setForm((current) => ({ ...current, label: e.target.value }))}
placeholder="Staff invite batch"
/>
</label>
</div>
<label>
Description
<textarea
rows={3}
value={form.description}
onChange={(e) =>
setForm((current) => ({ ...current, description: e.target.value }))
}
placeholder="Optional note shown on the signup page"
/>
</label>
<div className="admin-fields-grid">
<label>
Profile
<select
value={form.profile_id}
onChange={(e) =>
setForm((current) => ({ ...current, profile_id: e.target.value }))
}
>
<option value="">None</option>
{profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}
</option>
))}
</select>
</label>
<label>
Role override
<select
value={form.role}
onChange={(e) =>
setForm((current) => ({
...current,
role: e.target.value as '' | 'user' | 'admin',
}))
}
>
<option value="">Use profile/default</option>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</label>
</div>
<div className="admin-fields-grid">
<label>
Max uses
<input
value={form.max_uses}
onChange={(e) =>
setForm((current) => ({ ...current, max_uses: e.target.value }))
}
inputMode="numeric"
placeholder="Blank = unlimited"
/>
</label>
<label>
Invite expiry (ISO datetime)
<input
value={form.expires_at}
onChange={(e) =>
setForm((current) => ({ ...current, expires_at: e.target.value }))
}
placeholder="2026-03-01T12:00:00+00:00"
/>
</label>
</div>
<label className="inline-checkbox">
<input
type="checkbox"
checked={form.enabled}
onChange={(e) =>
setForm((current) => ({ ...current, enabled: e.target.checked }))
}
/>
Invite is enabled
</label>
<div className="admin-inline-actions">
<button type="submit" disabled={saving}>
{saving ? 'Saving…' : editingId == null ? 'Create invite' : 'Save invite'}
</button>
{editingId != null && (
<button type="button" className="ghost-button" onClick={resetEditor}>
Cancel edit
</button>
)}
</div>
</form>
</div>
<div className="admin-panel">
<h2>Existing invites</h2>
<p className="lede">Each invite can be copied as a direct sign-up link.</p>
{loading ? (
<div className="status-banner">Loading invites</div>
) : invites.length === 0 ? (
<div className="status-banner">No invites created yet.</div>
) : (
<div className="admin-list">
{invites.map((invite) => (
<div key={invite.id} className="admin-list-item">
<div className="admin-list-item-main">
<div className="admin-list-item-title-row">
<code className="invite-code">{invite.code}</code>
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
{invite.is_usable ? 'Usable' : 'Unavailable'}
</span>
{invite.profile?.name && <span className="small-pill">{invite.profile.name}</span>}
</div>
{invite.label && <p className="admin-list-item-text">{invite.label}</p>}
{invite.description && (
<p className="admin-list-item-text admin-list-item-text--muted">
{invite.description}
</p>
)}
<div className="admin-meta-row">
<span>Uses: {invite.use_count}{typeof invite.max_uses === 'number' ? ` / ${invite.max_uses}` : ''}</span>
<span>Remaining: {invite.remaining_uses ?? 'Unlimited'}</span>
<span>Expires: {formatDate(invite.expires_at)}</span>
<span>Created: {formatDate(invite.created_at)}</span>
</div>
</div>
<div className="admin-inline-actions">
<button type="button" className="ghost-button" onClick={() => copyInviteLink(invite)}>
Copy link
</button>
<button type="button" className="ghost-button" onClick={() => editInvite(invite)}>
Edit
</button>
<button type="button" onClick={() => deleteInvite(invite)}>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
</section>
</AdminShell>
)
}

View File

@@ -0,0 +1,335 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import AdminShell from '../../ui/AdminShell'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
type Profile = {
id: number
name: string
description?: string | null
role: 'user' | 'admin'
auto_search_enabled: boolean
account_expires_days?: number | null
is_active: boolean
assigned_users?: number
assigned_invites?: number
}
type ProfileForm = {
name: string
description: string
role: 'user' | 'admin'
auto_search_enabled: boolean
account_expires_days: string
is_active: boolean
}
const defaultForm = (): ProfileForm => ({
name: '',
description: '',
role: 'user',
auto_search_enabled: true,
account_expires_days: '',
is_active: true,
})
export default function AdminProfilesPage() {
const router = useRouter()
const [profiles, setProfiles] = useState<Profile[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<string | null>(null)
const [editingId, setEditingId] = useState<number | null>(null)
const [form, setForm] = useState<ProfileForm>(defaultForm())
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 loadProfiles = async () => {
if (!getToken()) {
router.push('/login')
return
}
setLoading(true)
setError(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/profiles`)
if (!response.ok) {
if (handleAuthResponse(response)) return
throw new Error(`Failed to load profiles (${response.status})`)
}
const data = await response.json()
setProfiles(Array.isArray(data?.profiles) ? data.profiles : [])
} catch (err) {
console.error(err)
setError('Could not load profiles.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void loadProfiles()
}, [])
const resetEditor = () => {
setEditingId(null)
setForm(defaultForm())
}
const editProfile = (profile: Profile) => {
setEditingId(profile.id)
setForm({
name: profile.name ?? '',
description: profile.description ?? '',
role: profile.role ?? 'user',
auto_search_enabled: Boolean(profile.auto_search_enabled),
account_expires_days:
typeof profile.account_expires_days === 'number' ? String(profile.account_expires_days) : '',
is_active: profile.is_active !== false,
})
setStatus(null)
setError(null)
}
const saveProfile = async (event: React.FormEvent) => {
event.preventDefault()
setSaving(true)
setError(null)
setStatus(null)
try {
const baseUrl = getApiBase()
const payload = {
name: form.name,
description: form.description || null,
role: form.role,
auto_search_enabled: form.auto_search_enabled,
account_expires_days: form.account_expires_days || null,
is_active: form.is_active,
}
const url =
editingId == null
? `${baseUrl}/admin/profiles`
: `${baseUrl}/admin/profiles/${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 ? 'Profile created.' : 'Profile updated.')
resetEditor()
await loadProfiles()
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not save profile.')
} finally {
setSaving(false)
}
}
const deleteProfile = async (profile: Profile) => {
if (!window.confirm(`Delete profile "${profile.name}"?`)) return
setError(null)
setStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/profiles/${profile.id}`, {
method: 'DELETE',
})
if (!response.ok) {
if (handleAuthResponse(response)) return
const text = await response.text()
throw new Error(text || 'Delete failed')
}
if (editingId === profile.id) resetEditor()
setStatus(`Deleted profile "${profile.name}".`)
await loadProfiles()
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not delete profile.')
}
}
return (
<AdminShell
title="Profiles"
subtitle="Reusable account templates for invite-based sign-up."
actions={
<div className="admin-inline-actions">
<button type="button" onClick={loadProfiles} disabled={loading}>
{loading ? 'Loading…' : 'Reload'}
</button>
<button type="button" className="ghost-button" onClick={resetEditor}>
New profile
</button>
</div>
}
>
<section className="admin-section">
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
<div className="admin-split-grid">
<div className="admin-panel">
<h2>{editingId == null ? 'Create profile' : 'Edit profile'}</h2>
<p className="lede">
Profiles define defaults applied when a user signs up using an invite.
</p>
<form onSubmit={saveProfile} className="admin-form compact-form">
<label>
Profile name
<input
value={form.name}
onChange={(e) => setForm((current) => ({ ...current, name: e.target.value }))}
placeholder="Standard users"
required
/>
</label>
<label>
Description
<textarea
rows={3}
value={form.description}
onChange={(e) =>
setForm((current) => ({ ...current, description: e.target.value }))
}
placeholder="Default invite settings for normal users"
/>
</label>
<div className="admin-fields-grid">
<label>
Role
<select
value={form.role}
onChange={(e) =>
setForm((current) => ({
...current,
role: e.target.value as 'user' | 'admin',
}))
}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</label>
<label>
Account expiry (days)
<input
value={form.account_expires_days}
onChange={(e) =>
setForm((current) => ({
...current,
account_expires_days: e.target.value,
}))
}
inputMode="numeric"
placeholder="Blank = no expiry"
/>
</label>
</div>
<label className="inline-checkbox">
<input
type="checkbox"
checked={form.auto_search_enabled}
onChange={(e) =>
setForm((current) => ({
...current,
auto_search_enabled: e.target.checked,
}))
}
/>
Allow auto search/download by default
</label>
<label className="inline-checkbox">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) =>
setForm((current) => ({ ...current, is_active: e.target.checked }))
}
/>
Profile is active
</label>
<div className="admin-inline-actions">
<button type="submit" disabled={saving}>
{saving ? 'Saving…' : editingId == null ? 'Create profile' : 'Save profile'}
</button>
{editingId != null && (
<button type="button" className="ghost-button" onClick={resetEditor}>
Cancel edit
</button>
)}
</div>
</form>
</div>
<div className="admin-panel">
<h2>Existing profiles</h2>
<p className="lede">Assign these to invites so sign-up accounts get consistent defaults.</p>
{loading ? (
<div className="status-banner">Loading profiles</div>
) : profiles.length === 0 ? (
<div className="status-banner">No profiles created yet.</div>
) : (
<div className="admin-list">
{profiles.map((profile) => (
<div key={profile.id} className="admin-list-item">
<div className="admin-list-item-main">
<div className="admin-list-item-title-row">
<strong>{profile.name}</strong>
<span className={`small-pill ${profile.is_active ? '' : 'is-muted'}`}>
{profile.is_active ? 'Active' : 'Disabled'}
</span>
<span className="small-pill">{profile.role}</span>
</div>
{profile.description && (
<p className="admin-list-item-text">{profile.description}</p>
)}
<div className="admin-meta-row">
<span>Auto search: {profile.auto_search_enabled ? 'On' : 'Off'}</span>
<span>
Account expiry:{' '}
{typeof profile.account_expires_days === 'number'
? `${profile.account_expires_days} days`
: 'Never'}
</span>
<span>Users: {profile.assigned_users ?? 0}</span>
<span>Invites: {profile.assigned_invites ?? 0}</span>
</div>
</div>
<div className="admin-inline-actions">
<button type="button" className="ghost-button" onClick={() => editProfile(profile)}>
Edit
</button>
<button type="button" onClick={() => deleteProfile(profile)}>
Delete
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
</section>
</AdminShell>
)
}

View File

@@ -3895,3 +3895,237 @@ button:hover:not(:disabled) {
border-radius: 14px; border-radius: 14px;
} }
} }
.admin-inline-actions {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.admin-split-grid {
display: grid;
grid-template-columns: minmax(300px, 420px) minmax(0, 1fr);
gap: 14px;
align-items: start;
}
.admin-panel {
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.02);
border-radius: 10px;
padding: 14px;
}
.admin-panel h2 {
margin: 0 0 6px;
font-size: 1rem;
}
.admin-panel .lede {
margin: 0 0 12px;
}
.compact-form {
gap: 12px;
}
.compact-form textarea {
min-height: 84px;
resize: vertical;
}
.admin-fields-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.inline-checkbox {
display: flex;
align-items: center;
gap: 8px;
}
.inline-checkbox input[type='checkbox'] {
width: 16px;
height: 16px;
}
.admin-list {
display: grid;
gap: 10px;
}
.admin-list-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: start;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.015);
border-radius: 10px;
padding: 10px 12px;
}
.admin-list-item-main {
min-width: 0;
display: grid;
gap: 6px;
}
.admin-list-item-title-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.admin-list-item-text {
margin: 0;
color: #d0d6df;
line-height: 1.35;
}
.admin-list-item-text--muted {
color: #9ea7b6;
}
.admin-meta-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
color: #9ea7b6;
font-size: 0.83rem;
}
.small-pill {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.09);
background: rgba(255, 255, 255, 0.03);
font-size: 0.72rem;
color: #d0d6df;
}
.small-pill.is-muted {
color: #9ea7b6;
border-color: rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.015);
}
.invite-code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
padding: 3px 8px;
border-radius: 8px;
border: 1px solid rgba(111, 149, 198, 0.2);
background: rgba(111, 149, 198, 0.07);
color: #c8d7ec;
}
.invite-lookup-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
}
.invite-summary {
display: grid;
gap: 8px;
border: 1px solid rgba(111, 149, 198, 0.18);
background: rgba(111, 149, 198, 0.06);
border-radius: 10px;
padding: 10px 12px;
}
.invite-summary.is-disabled {
border-color: rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
}
.invite-summary p {
margin: 0;
}
.invite-summary-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.user-bulk-toolbar--stacked {
align-items: stretch;
}
.user-bulk-groups {
display: grid;
gap: 10px;
width: 100%;
}
.user-bulk-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-end;
}
.user-bulk-group > label {
display: grid;
gap: 6px;
min-width: 220px;
flex: 1 1 220px;
}
.user-bulk-label {
font-size: 0.78rem;
color: #9ea7b6;
}
.user-bulk-group input,
.user-bulk-group select {
width: 100%;
}
.user-detail-actions--stacked {
display: grid;
gap: 8px;
margin-top: 10px;
}
.user-detail-actions--stacked > label {
display: grid;
gap: 6px;
}
@media (max-width: 980px) {
.admin-split-grid {
grid-template-columns: 1fr;
}
.admin-fields-grid {
grid-template-columns: 1fr;
}
.admin-list-item {
grid-template-columns: 1fr;
}
.invite-lookup-row {
grid-template-columns: 1fr;
}
.user-bulk-group {
align-items: stretch;
}
.user-bulk-group > label {
min-width: 100%;
flex-basis: 100%;
}
}

View File

@@ -85,6 +85,9 @@ export default function LoginPage() {
> >
Sign in with Magent account Sign in with Magent account
</button> </button>
<a className="ghost-button" href="/signup">
Have an invite? Create a Magent account
</a>
</form> </form>
</main> </main>
) )

View File

@@ -0,0 +1,223 @@
'use client'
import { Suspense, useEffect, useMemo, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import BrandingLogo from '../ui/BrandingLogo'
import { clearToken, getApiBase, setToken } from '../lib/auth'
type InviteInfo = {
code: string
label?: string | null
description?: string | null
enabled: boolean
is_expired?: boolean
is_usable?: boolean
expires_at?: string | null
max_uses?: number | null
use_count?: number | null
remaining_uses?: number | null
profile?: {
id: number
name: string
description?: string | null
} | null
}
const formatDate = (value?: string | null) => {
if (!value) return 'Never'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
function SignupPageContent() {
const router = useRouter()
const searchParams = useSearchParams()
const [inviteCode, setInviteCode] = useState(searchParams.get('code') ?? '')
const [invite, setInvite] = useState<InviteInfo | null>(null)
const [inviteLoading, setInviteLoading] = useState(false)
const [loading, setLoading] = useState(false)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<string | null>(null)
const canSubmit = useMemo(() => {
return Boolean(invite?.is_usable && username.trim() && password && !loading)
}, [invite, username, password, loading])
const lookupInvite = async (code: string) => {
const trimmed = code.trim()
if (!trimmed) {
setInvite(null)
return
}
setInviteLoading(true)
setError(null)
setStatus(null)
try {
const baseUrl = getApiBase()
const response = await fetch(`${baseUrl}/auth/invites/${encodeURIComponent(trimmed)}`)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Invite not found')
}
const data = await response.json()
setInvite(data?.invite ?? null)
setStatus('Invite loaded.')
} catch (err) {
console.error(err)
setInvite(null)
setError('Invite code not found or unavailable.')
} finally {
setInviteLoading(false)
}
}
useEffect(() => {
const initialCode = searchParams.get('code') ?? ''
if (initialCode) {
setInviteCode(initialCode)
void lookupInvite(initialCode)
}
}, [searchParams])
const submit = async (event: React.FormEvent) => {
event.preventDefault()
if (password !== confirmPassword) {
setError('Passwords do not match.')
return
}
if (!inviteCode.trim()) {
setError('Invite code is required.')
return
}
if (!invite?.is_usable) {
setError('Invite is not usable. Refresh invite details or ask an admin for a new code.')
return
}
setLoading(true)
setError(null)
setStatus(null)
try {
clearToken()
const baseUrl = getApiBase()
const response = await fetch(`${baseUrl}/auth/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
invite_code: inviteCode,
username: username.trim(),
password,
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Sign-up failed')
}
const data = await response.json()
if (data?.access_token) {
setToken(data.access_token)
window.location.href = '/'
return
}
throw new Error('Sign-up did not return a token')
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Unable to create account.')
} finally {
setLoading(false)
}
}
return (
<main className="card auth-card">
<BrandingLogo className="brand-logo brand-logo--login" />
<h1>Create account</h1>
<p className="lede">Use an invite code from your admin to create a Magent account.</p>
<form onSubmit={submit} className="auth-form">
<label>
Invite code
<div className="invite-lookup-row">
<input
value={inviteCode}
onChange={(e) => setInviteCode(e.target.value)}
placeholder="Paste your invite code"
autoCapitalize="characters"
/>
<button
type="button"
className="ghost-button"
disabled={inviteLoading}
onClick={() => void lookupInvite(inviteCode)}
>
{inviteLoading ? 'Checking…' : 'Check invite'}
</button>
</div>
</label>
{invite && (
<div className={`invite-summary ${invite.is_usable ? '' : 'is-disabled'}`}>
<div className="invite-summary-row">
<strong>{invite.label || invite.code}</strong>
<span className={`small-pill ${invite.is_usable ? '' : 'is-muted'}`}>
{invite.is_usable ? 'Usable' : 'Unavailable'}
</span>
</div>
{invite.description && <p>{invite.description}</p>}
<div className="admin-meta-row">
<span>Code: {invite.code}</span>
<span>Expires: {formatDate(invite.expires_at)}</span>
<span>Remaining uses: {invite.remaining_uses ?? 'Unlimited'}</span>
<span>Profile: {invite.profile?.name || 'None'}</span>
</div>
</div>
)}
<label>
Username
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
/>
</label>
<label>
Confirm password
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
/>
</label>
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
<div className="auth-actions">
<button type="submit" disabled={!canSubmit}>
{loading ? 'Creating account…' : 'Create account'}
</button>
</div>
<button type="button" className="ghost-button" disabled={loading} onClick={() => router.push('/login')}>
Back to sign in
</button>
</form>
</main>
)
}
export default function SignupPage() {
return (
<Suspense fallback={<main className="card auth-card">Loading sign-up</main>}>
<SignupPageContent />
</Suspense>
)
}

View File

@@ -27,6 +27,8 @@ const NAV_GROUPS = [
items: [ items: [
{ href: '/admin/site', label: 'Site' }, { href: '/admin/site', label: 'Site' },
{ href: '/users', label: 'Users' }, { href: '/users', label: 'Users' },
{ href: '/admin/profiles', label: 'Profiles' },
{ href: '/admin/invites', label: 'Invites' },
{ href: '/admin/logs', label: 'Activity log' }, { href: '/admin/logs', label: 'Activity log' },
{ href: '/admin/maintenance', label: 'Maintenance' }, { href: '/admin/maintenance', label: 'Maintenance' },
], ],

View File

@@ -26,6 +26,15 @@ type AdminUser = {
is_blocked?: boolean is_blocked?: boolean
auto_search_enabled?: boolean auto_search_enabled?: boolean
jellyseerr_user_id?: number | null jellyseerr_user_id?: number | null
profile_id?: number | null
expires_at?: string | null
is_expired?: boolean
}
type UserProfileOption = {
id: number
name: string
is_active?: boolean
} }
const formatDateTime = (value?: string | null) => { const formatDateTime = (value?: string | null) => {
@@ -35,6 +44,22 @@ const formatDateTime = (value?: string | null) => {
return date.toLocaleString() return date.toLocaleString()
} }
const toLocalDateTimeInput = (value?: string | null) => {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return ''
const offsetMs = date.getTimezoneOffset() * 60_000
const local = new Date(date.getTime() - offsetMs)
return local.toISOString().slice(0, 16)
}
const fromLocalDateTimeInput = (value: string) => {
if (!value.trim()) return null
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return null
return date.toISOString()
}
const normalizeStats = (stats: any): UserStats => ({ const normalizeStats = (stats: any): UserStats => ({
total: Number(stats?.total ?? 0), total: Number(stats?.total ?? 0),
ready: Number(stats?.ready ?? 0), ready: Number(stats?.ready ?? 0),
@@ -55,6 +80,36 @@ export default function UserDetailPage() {
const [stats, setStats] = useState<UserStats | null>(null) const [stats, setStats] = useState<UserStats | null>(null)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [profiles, setProfiles] = useState<UserProfileOption[]>([])
const [profileSelection, setProfileSelection] = useState('')
const [expiryInput, setExpiryInput] = useState('')
const [savingProfile, setSavingProfile] = useState(false)
const [savingExpiry, setSavingExpiry] = useState(false)
const [actionStatus, setActionStatus] = useState<string | null>(null)
const loadProfiles = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/profiles`)
if (!response.ok) {
return
}
const data = await response.json()
if (!Array.isArray(data?.profiles)) {
setProfiles([])
return
}
setProfiles(
data.profiles.map((profile: any) => ({
id: Number(profile.id ?? 0),
name: String(profile.name ?? 'Unnamed profile'),
is_active: Boolean(profile.is_active ?? true),
}))
)
} catch (err) {
console.error(err)
}
}
const loadUser = async () => { const loadUser = async () => {
if (!idParam) return if (!idParam) return
@@ -80,8 +135,15 @@ export default function UserDetailPage() {
throw new Error('Could not load user.') throw new Error('Could not load user.')
} }
const data = await response.json() const data = await response.json()
setUser(data?.user ?? null) const nextUser = data?.user ?? null
setUser(nextUser)
setStats(normalizeStats(data?.stats)) setStats(normalizeStats(data?.stats))
setProfileSelection(
nextUser?.profile_id == null || Number.isNaN(Number(nextUser?.profile_id))
? ''
: String(nextUser.profile_id)
)
setExpiryInput(toLocalDateTimeInput(nextUser?.expires_at))
setError(null) setError(null)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@@ -94,6 +156,7 @@ export default function UserDetailPage() {
const toggleUserBlock = async (blocked: boolean) => { const toggleUserBlock = async (blocked: boolean) => {
if (!user) return if (!user) return
try { try {
setActionStatus(null)
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch( const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/${blocked ? 'block' : 'unblock'}`, `${baseUrl}/admin/users/${encodeURIComponent(user.username)}/${blocked ? 'block' : 'unblock'}`,
@@ -103,6 +166,7 @@ export default function UserDetailPage() {
throw new Error('Update failed') throw new Error('Update failed')
} }
await loadUser() await loadUser()
setActionStatus(blocked ? 'User blocked.' : 'User unblocked.')
} catch (err) { } catch (err) {
console.error(err) console.error(err)
setError('Could not update user access.') setError('Could not update user access.')
@@ -112,6 +176,7 @@ export default function UserDetailPage() {
const updateUserRole = async (role: string) => { const updateUserRole = async (role: string) => {
if (!user) return if (!user) return
try { try {
setActionStatus(null)
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch( const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/role`, `${baseUrl}/admin/users/${encodeURIComponent(user.username)}/role`,
@@ -125,6 +190,7 @@ export default function UserDetailPage() {
throw new Error('Update failed') throw new Error('Update failed')
} }
await loadUser() await loadUser()
setActionStatus(`Role updated to ${role}.`)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
setError('Could not update user role.') setError('Could not update user role.')
@@ -134,6 +200,7 @@ export default function UserDetailPage() {
const updateAutoSearchEnabled = async (enabled: boolean) => { const updateAutoSearchEnabled = async (enabled: boolean) => {
if (!user) return if (!user) return
try { try {
setActionStatus(null)
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch( const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/auto-search`, `${baseUrl}/admin/users/${encodeURIComponent(user.username)}/auto-search`,
@@ -147,18 +214,114 @@ export default function UserDetailPage() {
throw new Error('Update failed') throw new Error('Update failed')
} }
await loadUser() await loadUser()
setActionStatus(`Auto search/download ${enabled ? 'enabled' : 'disabled'}.`)
} catch (err) { } catch (err) {
console.error(err) console.error(err)
setError('Could not update auto search access.') setError('Could not update auto search access.')
} }
} }
const applyProfileToUser = async (profileOverride?: string | null) => {
if (!user) return
const profileValue = profileOverride ?? profileSelection
setSavingProfile(true)
setError(null)
setActionStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/profile`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile_id: profileValue || null }),
}
)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Profile update failed')
}
await loadUser()
setActionStatus(profileValue ? 'Profile applied to user.' : 'Profile assignment cleared.')
} catch (err) {
console.error(err)
setError('Could not update user profile.')
} finally {
setSavingProfile(false)
}
}
const saveUserExpiry = async () => {
if (!user) return
const expiresAt = fromLocalDateTimeInput(expiryInput)
if (expiryInput.trim() && !expiresAt) {
setError('Invalid expiry date/time.')
return
}
setSavingExpiry(true)
setError(null)
setActionStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/expiry`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ expires_at: expiresAt }),
}
)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Expiry update failed')
}
await loadUser()
setActionStatus(expiresAt ? 'User expiry updated.' : 'User expiry cleared.')
} catch (err) {
console.error(err)
setError('Could not update user expiry.')
} finally {
setSavingExpiry(false)
}
}
const clearUserExpiry = async () => {
if (!user) return
setSavingExpiry(true)
setError(null)
setActionStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/expiry`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clear: true }),
}
)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Expiry clear failed')
}
setExpiryInput('')
await loadUser()
setActionStatus('User expiry cleared.')
} catch (err) {
console.error(err)
setError('Could not clear user expiry.')
} finally {
setSavingExpiry(false)
}
}
useEffect(() => { useEffect(() => {
if (!getToken()) { if (!getToken()) {
router.push('/login') router.push('/login')
return return
} }
void loadUser() void loadUser()
void loadProfiles()
}, [router, idParam]) }, [router, idParam])
if (loading) { if (loading) {
@@ -177,6 +340,7 @@ export default function UserDetailPage() {
> >
<section className="admin-section"> <section className="admin-section">
{error && <div className="error-banner">{error}</div>} {error && <div className="error-banner">{error}</div>}
{actionStatus && <div className="status-banner">{actionStatus}</div>}
{!user ? ( {!user ? (
<div className="status-banner">No user data found.</div> <div className="status-banner">No user data found.</div>
) : ( ) : (
@@ -196,6 +360,10 @@ export default function UserDetailPage() {
</span> </span>
<span className="user-detail-chip">Role: {user.role}</span> <span className="user-detail-chip">Role: {user.role}</span>
<span className="user-detail-chip">Login type: {user.auth_provider || 'local'}</span> <span className="user-detail-chip">Login type: {user.auth_provider || 'local'}</span>
<span className="user-detail-chip">Profile: {user.profile_id ?? 'None'}</span>
<span className={`user-detail-chip ${user.is_expired ? 'is-blocked' : ''}`}>
Expiry: {user.expires_at ? formatDateTime(user.expires_at) : 'Never'}
</span>
<span className="user-detail-chip">Last login: {formatDateTime(user.last_login_at)}</span> <span className="user-detail-chip">Last login: {formatDateTime(user.last_login_at)}</span>
</div> </div>
</div> </div>
@@ -227,6 +395,63 @@ export default function UserDetailPage() {
{user.is_blocked ? 'Allow access' : 'Block access'} {user.is_blocked ? 'Allow access' : 'Block access'}
</button> </button>
</div> </div>
<div className="user-detail-actions user-detail-actions--stacked">
<label className="admin-select">
<span>Assigned profile</span>
<select
value={profileSelection}
onChange={(event) => setProfileSelection(event.target.value)}
disabled={savingProfile}
>
<option value="">None</option>
{profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}{profile.is_active === false ? ' (disabled)' : ''}
</option>
))}
</select>
</label>
<div className="admin-inline-actions">
<button type="button" onClick={() => void applyProfileToUser()} disabled={savingProfile}>
{savingProfile ? 'Applying...' : 'Apply profile defaults'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => {
setProfileSelection('')
void applyProfileToUser('')
}}
disabled={savingProfile}
>
Clear profile
</button>
</div>
</div>
<div className="user-detail-actions user-detail-actions--stacked">
<label>
<span className="user-bulk-label">Account expiry</span>
<input
type="datetime-local"
value={expiryInput}
onChange={(event) => setExpiryInput(event.target.value)}
disabled={savingExpiry}
/>
</label>
<div className="admin-inline-actions">
<button type="button" onClick={saveUserExpiry} disabled={savingExpiry}>
{savingExpiry ? 'Saving...' : 'Save expiry'}
</button>
<button
type="button"
className="ghost-button"
onClick={clearUserExpiry}
disabled={savingExpiry}
>
Clear expiry
</button>
</div>
</div>
{user.role === 'admin' && ( {user.role === 'admin' && (
<div className="user-detail-helper"> <div className="user-detail-helper">
Admins always have auto search/download access. Admins always have auto search/download access.

View File

@@ -14,9 +14,18 @@ type AdminUser = {
lastLoginAt?: string | null lastLoginAt?: string | null
isBlocked?: boolean isBlocked?: boolean
autoSearchEnabled?: boolean autoSearchEnabled?: boolean
profileId?: number | null
expiresAt?: string | null
isExpired?: boolean
stats?: UserStats stats?: UserStats
} }
type UserProfileOption = {
id: number
name: string
isActive?: boolean
}
type UserStats = { type UserStats = {
total: number total: number
ready: number ready: number
@@ -43,6 +52,13 @@ const formatLastRequest = (value?: string | null) => {
return date.toLocaleString() return date.toLocaleString()
} }
const formatExpiry = (value?: string | null) => {
if (!value) return 'Never'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
const emptyStats: UserStats = { const emptyStats: UserStats = {
total: 0, total: 0,
ready: 0, ready: 0,
@@ -76,6 +92,35 @@ export default function UsersPage() {
const [jellyseerrSyncBusy, setJellyseerrSyncBusy] = useState(false) const [jellyseerrSyncBusy, setJellyseerrSyncBusy] = useState(false)
const [jellyseerrResyncBusy, setJellyseerrResyncBusy] = useState(false) const [jellyseerrResyncBusy, setJellyseerrResyncBusy] = useState(false)
const [bulkAutoSearchBusy, setBulkAutoSearchBusy] = useState(false) const [bulkAutoSearchBusy, setBulkAutoSearchBusy] = useState(false)
const [profiles, setProfiles] = useState<UserProfileOption[]>([])
const [bulkProfileId, setBulkProfileId] = useState('')
const [bulkProfileBusy, setBulkProfileBusy] = useState(false)
const [bulkExpiryDays, setBulkExpiryDays] = useState('')
const [bulkExpiryBusy, setBulkExpiryBusy] = useState(false)
const loadProfiles = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/profiles`)
if (!response.ok) {
return
}
const data = await response.json()
if (!Array.isArray(data?.profiles)) {
setProfiles([])
return
}
setProfiles(
data.profiles.map((profile: any) => ({
id: Number(profile.id ?? 0),
name: String(profile.name ?? 'Unnamed profile'),
isActive: Boolean(profile.is_active ?? true),
}))
)
} catch (err) {
console.error(err)
}
}
const loadUsers = async () => { const loadUsers = async () => {
try { try {
@@ -103,6 +148,12 @@ export default function UsersPage() {
lastLoginAt: user.last_login_at ?? null, lastLoginAt: user.last_login_at ?? null,
isBlocked: Boolean(user.is_blocked), isBlocked: Boolean(user.is_blocked),
autoSearchEnabled: Boolean(user.auto_search_enabled ?? true), autoSearchEnabled: Boolean(user.auto_search_enabled ?? true),
profileId:
user.profile_id == null || Number.isNaN(Number(user.profile_id))
? null
: Number(user.profile_id),
expiresAt: user.expires_at ?? null,
isExpired: Boolean(user.is_expired),
id: Number(user.id ?? 0), id: Number(user.id ?? 0),
stats: normalizeStats(user.stats ?? emptyStats), stats: normalizeStats(user.stats ?? emptyStats),
})) }))
@@ -238,12 +289,110 @@ export default function UsersPage() {
} }
} }
const bulkApplyProfile = async () => {
setBulkProfileBusy(true)
setJellyseerrSyncStatus(null)
setError(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/users/profile/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
profile_id: bulkProfileId || null,
scope: 'non-admin-users',
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Bulk profile update failed')
}
const data = await response.json()
setJellyseerrSyncStatus(
bulkProfileId
? `Applied profile ${bulkProfileId} to ${data?.updated ?? 0} non-admin users.`
: `Cleared profile assignment for ${data?.updated ?? 0} non-admin users.`
)
await loadUsers()
} catch (err) {
console.error(err)
setError('Could not apply profile to all users.')
} finally {
setBulkProfileBusy(false)
}
}
const bulkSetExpiryDays = async () => {
if (!bulkExpiryDays.trim()) {
setError('Enter expiry days before applying bulk expiry.')
return
}
setBulkExpiryBusy(true)
setJellyseerrSyncStatus(null)
setError(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/users/expiry/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
days: bulkExpiryDays,
scope: 'non-admin-users',
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Bulk expiry update failed')
}
const data = await response.json()
setJellyseerrSyncStatus(
`Set expiry for ${data?.updated ?? 0} non-admin users (${bulkExpiryDays} days).`
)
await loadUsers()
} catch (err) {
console.error(err)
setError('Could not set expiry for all users.')
} finally {
setBulkExpiryBusy(false)
}
}
const bulkClearExpiry = async () => {
setBulkExpiryBusy(true)
setJellyseerrSyncStatus(null)
setError(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/users/expiry/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clear: true,
scope: 'non-admin-users',
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Bulk expiry clear failed')
}
const data = await response.json()
setJellyseerrSyncStatus(`Cleared expiry for ${data?.updated ?? 0} non-admin users.`)
await loadUsers()
} catch (err) {
console.error(err)
setError('Could not clear expiry for all users.')
} finally {
setBulkExpiryBusy(false)
}
}
useEffect(() => { useEffect(() => {
if (!getToken()) { if (!getToken()) {
router.push('/login') router.push('/login')
return return
} }
void loadUsers() void loadUsers()
void loadProfiles()
}, [router]) }, [router])
if (loading) { if (loading) {
@@ -274,11 +423,11 @@ export default function UsersPage() {
<section className="admin-section"> <section className="admin-section">
{error && <div className="error-banner">{error}</div>} {error && <div className="error-banner">{error}</div>}
{jellyseerrSyncStatus && <div className="status-banner">{jellyseerrSyncStatus}</div>} {jellyseerrSyncStatus && <div className="status-banner">{jellyseerrSyncStatus}</div>}
<div className="user-bulk-toolbar"> <div className="user-bulk-toolbar">
<div className="user-bulk-summary"> <div className="user-bulk-summary">
<strong>Auto search/download</strong> <strong>Auto search/download</strong>
<span> <span>
{autoSearchEnabledCount} of {nonAdminUsers.length} non-admin users enabled {autoSearchEnabledCount} of {nonAdminUsers.length} non-admin users enabled
</span> </span>
</div> </div>
<div className="user-bulk-actions"> <div className="user-bulk-actions">
@@ -299,6 +448,57 @@ export default function UsersPage() {
</button> </button>
</div> </div>
</div> </div>
<div className="user-bulk-toolbar user-bulk-toolbar--stacked">
<div className="user-bulk-summary">
<strong>Profiles and expiry</strong>
<span>Apply invite profile defaults and account expiry to all non-admin users.</span>
</div>
<div className="user-bulk-groups">
<div className="user-bulk-group">
<label className="admin-select">
<span>Profile</span>
<select
value={bulkProfileId}
onChange={(e) => setBulkProfileId(e.target.value)}
disabled={bulkProfileBusy}
>
<option value="">None / clear assignment</option>
{profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}{profile.isActive === false ? ' (disabled)' : ''}
</option>
))}
</select>
</label>
<button type="button" onClick={bulkApplyProfile} disabled={bulkProfileBusy}>
{bulkProfileBusy ? 'Applying...' : 'Apply profile to all users'}
</button>
</div>
<div className="user-bulk-group">
<label>
<span className="user-bulk-label">Expiry days</span>
<input
value={bulkExpiryDays}
onChange={(e) => setBulkExpiryDays(e.target.value)}
inputMode="numeric"
placeholder="e.g. 30"
disabled={bulkExpiryBusy}
/>
</label>
<button type="button" onClick={bulkSetExpiryDays} disabled={bulkExpiryBusy}>
{bulkExpiryBusy ? 'Working...' : 'Set expiry for all users'}
</button>
<button
type="button"
className="ghost-button"
onClick={bulkClearExpiry}
disabled={bulkExpiryBusy}
>
{bulkExpiryBusy ? 'Working...' : 'Clear expiry for all users'}
</button>
</div>
</div>
</div>
{users.length === 0 ? ( {users.length === 0 ? (
<div className="status-banner">No users found yet.</div> <div className="status-banner">No users found yet.</div>
) : ( ) : (
@@ -322,6 +522,14 @@ export default function UsersPage() {
<span className={`user-grid-pill ${user.autoSearchEnabled === false ? 'is-disabled' : ''}`}> <span className={`user-grid-pill ${user.autoSearchEnabled === false ? 'is-disabled' : ''}`}>
Auto search {user.autoSearchEnabled === false ? 'Off' : 'On'} Auto search {user.autoSearchEnabled === false ? 'Off' : 'On'}
</span> </span>
<span className={`user-grid-pill ${user.isExpired ? 'is-blocked' : ''}`}>
{user.expiresAt
? `Expiry ${user.isExpired ? 'expired' : formatExpiry(user.expiresAt)}`
: 'Expiry Never'}
</span>
<span className="user-grid-pill">
Profile {user.profileId ?? 'None'}
</span>
</div> </div>
<div className="user-grid-stats"> <div className="user-grid-stats">
<div> <div>