Compare commits

..

2 Commits

19 changed files with 345 additions and 4252 deletions

View File

@@ -1 +1 @@
2602261523
2602262059

View File

@@ -1,5 +1,4 @@
from datetime import datetime, timezone
from typing import Dict, Any, Optional
from typing import Dict, Any
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import OAuth2PasswordBearer
@@ -9,21 +8,6 @@ from .security import safe_decode_token, TokenError
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
def _is_expired(expires_at: str | None) -> bool:
if not isinstance(expires_at, str) or not expires_at.strip():
return False
candidate = expires_at.strip()
if candidate.endswith("Z"):
candidate = candidate[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(candidate)
except ValueError:
return False
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed <= datetime.now(timezone.utc)
def _extract_client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
@@ -38,7 +22,7 @@ def _extract_client_ip(request: Request) -> str:
return "unknown"
def _load_current_user_from_token(token: str, request: Optional[Request] = None) -> Dict[str, Any]:
def get_current_user(token: str = Depends(oauth2_scheme), request: Request = None) -> Dict[str, Any]:
try:
payload = safe_decode_token(token)
except TokenError as exc:
@@ -53,8 +37,6 @@ def _load_current_user_from_token(token: str, request: Optional[Request] = None)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
if user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
if _is_expired(user.get("expires_at")):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
if request is not None:
ip = _extract_client_ip(request)
@@ -67,38 +49,10 @@ def _load_current_user_from_token(token: str, request: Optional[Request] = None)
"auth_provider": user.get("auth_provider", "local"),
"jellyseerr_user_id": user.get("jellyseerr_user_id"),
"auto_search_enabled": bool(user.get("auto_search_enabled", True)),
"profile_id": user.get("profile_id"),
"expires_at": user.get("expires_at"),
"is_expired": bool(user.get("is_expired", False)),
}
def get_current_user(token: str = Depends(oauth2_scheme), request: Request = None) -> Dict[str, Any]:
return _load_current_user_from_token(token, request)
def get_current_user_event_stream(request: Request) -> Dict[str, Any]:
"""EventSource cannot send Authorization headers, so allow a query token here only."""
token = None
auth_header = request.headers.get("authorization", "")
if auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip()
if not token:
token = request.query_params.get("access_token")
if not token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
return _load_current_user_from_token(token, None)
def require_admin(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
if user.get("role") != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
def require_admin_event_stream(
user: Dict[str, Any] = Depends(get_current_user_event_stream),
) -> Dict[str, Any]:
if user.get("role") != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user

View File

@@ -1,2 +1,3 @@
BUILD_NUMBER = "2602261523"
BUILD_NUMBER = "2602262059"
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,28 +24,6 @@ def _connect() -> sqlite3.Connection:
return sqlite3.connect(_db_path())
def _parse_datetime_value(value: Optional[str]) -> Optional[datetime]:
if not isinstance(value, str) or not value.strip():
return None
candidate = value.strip()
if candidate.endswith("Z"):
candidate = candidate[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(candidate)
except ValueError:
return None
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
def _is_datetime_in_past(value: Optional[str]) -> bool:
parsed = _parse_datetime_value(value)
if parsed is None:
return False
return parsed <= datetime.now(timezone.utc)
def _normalize_title_value(title: Optional[str]) -> Optional[str]:
if not isinstance(title, str):
return None
@@ -172,61 +150,11 @@ def init_db() -> None:
last_login_at TEXT,
is_blocked INTEGER NOT NULL DEFAULT 0,
auto_search_enabled INTEGER NOT NULL DEFAULT 1,
profile_id INTEGER,
expires_at TEXT,
invited_by_code TEXT,
invited_at TEXT,
jellyfin_password_hash TEXT,
last_jellyfin_auth_at TEXT
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS user_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT,
role TEXT NOT NULL DEFAULT 'user',
auto_search_enabled INTEGER NOT NULL DEFAULT 1,
account_expires_days INTEGER,
is_active INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS signup_invites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
label TEXT,
description TEXT,
profile_id INTEGER,
role TEXT,
max_uses INTEGER,
use_count INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
expires_at TEXT,
created_by TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_signup_invites_enabled
ON signup_invites (enabled)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_signup_invites_expires_at
ON signup_invites (expires_at)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS settings (
@@ -341,40 +269,6 @@ def init_db() -> None:
conn.execute("ALTER TABLE users ADD COLUMN auto_search_enabled INTEGER NOT NULL DEFAULT 1")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN profile_id INTEGER")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN expires_at TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN invited_by_code TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE users ADD COLUMN invited_at TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_users_profile_id
ON users (profile_id)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_users_expires_at
ON users (expires_at)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE requests_cache ADD COLUMN requested_by_id INTEGER")
except sqlite3.OperationalError:
@@ -497,44 +391,16 @@ def create_user(
role: str = "user",
auth_provider: str = "local",
jellyseerr_user_id: Optional[int] = None,
auto_search_enabled: bool = True,
profile_id: Optional[int] = None,
expires_at: Optional[str] = None,
invited_by_code: Optional[str] = None,
) -> None:
created_at = datetime.now(timezone.utc).isoformat()
password_hash = hash_password(password)
with _connect() as conn:
conn.execute(
"""
INSERT INTO users (
username,
password_hash,
role,
auth_provider,
jellyseerr_user_id,
created_at,
auto_search_enabled,
profile_id,
expires_at,
invited_by_code,
invited_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO users (username, password_hash, role, auth_provider, jellyseerr_user_id, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
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,
),
(username, password_hash, role, auth_provider, jellyseerr_user_id, created_at),
)
@@ -544,44 +410,16 @@ def create_user_if_missing(
role: str = "user",
auth_provider: str = "local",
jellyseerr_user_id: Optional[int] = None,
auto_search_enabled: bool = True,
profile_id: Optional[int] = None,
expires_at: Optional[str] = None,
invited_by_code: Optional[str] = None,
) -> bool:
created_at = datetime.now(timezone.utc).isoformat()
password_hash = hash_password(password)
with _connect() as conn:
cursor = conn.execute(
"""
INSERT OR IGNORE INTO users (
username,
password_hash,
role,
auth_provider,
jellyseerr_user_id,
created_at,
auto_search_enabled,
profile_id,
expires_at,
invited_by_code,
invited_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT OR IGNORE INTO users (username, password_hash, role, auth_provider, jellyseerr_user_id, created_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
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,
),
(username, password_hash, role, auth_provider, jellyseerr_user_id, created_at),
)
return cursor.rowcount > 0
@@ -591,9 +429,7 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
row = conn.execute(
"""
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, auto_search_enabled,
profile_id, expires_at, invited_by_code, invited_at,
jellyfin_password_hash, last_jellyfin_auth_at
created_at, last_login_at, is_blocked, auto_search_enabled, jellyfin_password_hash, last_jellyfin_auth_at
FROM users
WHERE username = ? COLLATE NOCASE
""",
@@ -612,13 +448,8 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
"last_login_at": row[7],
"is_blocked": bool(row[8]),
"auto_search_enabled": bool(row[9]),
"profile_id": row[10],
"expires_at": row[11],
"invited_by_code": row[12],
"invited_at": row[13],
"is_expired": _is_datetime_in_past(row[11]),
"jellyfin_password_hash": row[14],
"last_jellyfin_auth_at": row[15],
"jellyfin_password_hash": row[10],
"last_jellyfin_auth_at": row[11],
}
@@ -627,9 +458,7 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
row = conn.execute(
"""
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, auto_search_enabled,
profile_id, expires_at, invited_by_code, invited_at,
jellyfin_password_hash, last_jellyfin_auth_at
created_at, last_login_at, is_blocked, auto_search_enabled, jellyfin_password_hash, last_jellyfin_auth_at
FROM users
WHERE id = ?
""",
@@ -648,22 +477,15 @@ def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
"last_login_at": row[7],
"is_blocked": bool(row[8]),
"auto_search_enabled": bool(row[9]),
"profile_id": row[10],
"expires_at": row[11],
"invited_by_code": row[12],
"invited_at": row[13],
"is_expired": _is_datetime_in_past(row[11]),
"jellyfin_password_hash": row[14],
"last_jellyfin_auth_at": row[15],
"jellyfin_password_hash": row[10],
"last_jellyfin_auth_at": row[11],
}
def get_all_users() -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, username, role, auth_provider, jellyseerr_user_id, created_at,
last_login_at, is_blocked, auto_search_enabled, profile_id, expires_at,
invited_by_code, invited_at
SELECT id, username, role, auth_provider, jellyseerr_user_id, created_at, last_login_at, is_blocked, auto_search_enabled
FROM users
ORDER BY username COLLATE NOCASE
"""
@@ -681,11 +503,6 @@ def get_all_users() -> list[Dict[str, Any]]:
"last_login_at": row[6],
"is_blocked": bool(row[7]),
"auto_search_enabled": bool(row[8]),
"profile_id": row[9],
"expires_at": row[10],
"invited_by_code": row[11],
"invited_at": row[12],
"is_expired": _is_datetime_in_past(row[10]),
}
)
return results
@@ -763,340 +580,81 @@ def set_auto_search_enabled_for_non_admin_users(enabled: bool) -> int:
return cursor.rowcount
def set_user_profile_id(username: str, profile_id: Optional[int]) -> None:
with _connect() as conn:
conn.execute(
"""
UPDATE users SET profile_id = ? WHERE username = ? COLLATE NOCASE
""",
(profile_id, username),
)
def set_user_expires_at(username: str, expires_at: Optional[str]) -> None:
with _connect() as conn:
conn.execute(
"""
UPDATE users SET expires_at = ? WHERE username = ? COLLATE NOCASE
""",
(expires_at, username),
)
def _row_to_user_profile(row: Any) -> Dict[str, Any]:
return {
"id": row[0],
"name": row[1],
"description": row[2],
"role": row[3],
"auto_search_enabled": bool(row[4]),
"account_expires_days": row[5],
"is_active": bool(row[6]),
"created_at": row[7],
"updated_at": row[8],
}
def list_user_profiles() -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, name, description, role, auto_search_enabled, account_expires_days, is_active, created_at, updated_at
FROM user_profiles
ORDER BY name COLLATE NOCASE
"""
).fetchall()
return [_row_to_user_profile(row) for row in rows]
def get_user_profile(profile_id: int) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT id, name, description, role, auto_search_enabled, account_expires_days, is_active, created_at, updated_at
FROM user_profiles
WHERE id = ?
""",
(profile_id,),
).fetchone()
if not row:
return None
return _row_to_user_profile(row)
def create_user_profile(
name: str,
description: Optional[str] = None,
role: str = "user",
auto_search_enabled: bool = True,
account_expires_days: Optional[int] = None,
is_active: bool = True,
) -> Dict[str, Any]:
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cursor = conn.execute(
"""
INSERT INTO user_profiles (
name, description, role, auto_search_enabled, account_expires_days, is_active, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
name,
description,
role,
1 if auto_search_enabled else 0,
account_expires_days,
1 if is_active else 0,
timestamp,
timestamp,
),
)
profile_id = int(cursor.lastrowid)
profile = get_user_profile(profile_id)
if not profile:
raise RuntimeError("Profile creation failed")
return profile
def update_user_profile(
profile_id: int,
*,
name: str,
description: Optional[str],
role: str,
auto_search_enabled: bool,
account_expires_days: Optional[int],
is_active: bool,
) -> Optional[Dict[str, Any]]:
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cursor = conn.execute(
"""
UPDATE user_profiles
SET name = ?, description = ?, role = ?, auto_search_enabled = ?,
account_expires_days = ?, is_active = ?, updated_at = ?
WHERE id = ?
""",
(
name,
description,
role,
1 if auto_search_enabled else 0,
account_expires_days,
1 if is_active else 0,
timestamp,
profile_id,
),
)
if cursor.rowcount <= 0:
return None
return get_user_profile(profile_id)
def delete_user_profile(profile_id: int) -> bool:
with _connect() as conn:
users_count = conn.execute(
"SELECT COUNT(*) FROM users WHERE profile_id = ?",
(profile_id,),
).fetchone()
invites_count = conn.execute(
"SELECT COUNT(*) FROM signup_invites WHERE profile_id = ?",
(profile_id,),
).fetchone()
if int((users_count or [0])[0] or 0) > 0:
raise ValueError("Profile is assigned to existing users.")
if int((invites_count or [0])[0] or 0) > 0:
raise ValueError("Profile is assigned to existing invites.")
cursor = conn.execute(
"DELETE FROM user_profiles WHERE id = ?",
(profile_id,),
)
return cursor.rowcount > 0
def _row_to_signup_invite(row: Any) -> Dict[str, Any]:
max_uses = row[6]
use_count = int(row[7] or 0)
expires_at = row[9]
is_expired = _is_datetime_in_past(expires_at)
remaining_uses = None if max_uses is None else max(int(max_uses) - use_count, 0)
return {
"id": row[0],
"code": row[1],
"label": row[2],
"description": row[3],
"profile_id": row[4],
"role": row[5],
"max_uses": max_uses,
"use_count": use_count,
"enabled": bool(row[8]),
"expires_at": expires_at,
"created_by": row[10],
"created_at": row[11],
"updated_at": row[12],
"is_expired": is_expired,
"remaining_uses": remaining_uses,
"is_usable": bool(row[8]) and not is_expired and (remaining_uses is None or remaining_uses > 0),
}
def list_signup_invites() -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, created_by, created_at, updated_at
FROM signup_invites
ORDER BY created_at DESC, id DESC
"""
).fetchall()
return [_row_to_signup_invite(row) for row in rows]
def get_signup_invite_by_id(invite_id: int) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, created_by, created_at, updated_at
FROM signup_invites
WHERE id = ?
""",
(invite_id,),
).fetchone()
if not row:
return None
return _row_to_signup_invite(row)
def get_signup_invite_by_code(code: str) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT id, code, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, created_by, created_at, updated_at
FROM signup_invites
WHERE code = ? COLLATE NOCASE
""",
(code,),
).fetchone()
if not row:
return None
return _row_to_signup_invite(row)
def create_signup_invite(
*,
code: str,
label: Optional[str] = None,
description: Optional[str] = None,
profile_id: Optional[int] = None,
role: Optional[str] = None,
max_uses: Optional[int] = None,
enabled: bool = True,
expires_at: Optional[str] = None,
created_by: Optional[str] = None,
) -> Dict[str, Any]:
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cursor = conn.execute(
"""
INSERT INTO signup_invites (
code, label, description, profile_id, role, max_uses, use_count, enabled,
expires_at, created_by, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?)
""",
(
code,
label,
description,
profile_id,
role,
max_uses,
1 if enabled else 0,
expires_at,
created_by,
timestamp,
timestamp,
),
)
invite_id = int(cursor.lastrowid)
invite = get_signup_invite_by_id(invite_id)
if not invite:
raise RuntimeError("Invite creation failed")
return invite
def update_signup_invite(
invite_id: int,
*,
code: str,
label: Optional[str],
description: Optional[str],
profile_id: Optional[int],
role: Optional[str],
max_uses: Optional[int],
enabled: bool,
expires_at: Optional[str],
) -> Optional[Dict[str, Any]]:
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cursor = conn.execute(
"""
UPDATE signup_invites
SET code = ?, label = ?, description = ?, profile_id = ?, role = ?, max_uses = ?,
enabled = ?, expires_at = ?, updated_at = ?
WHERE id = ?
""",
(
code,
label,
description,
profile_id,
role,
max_uses,
1 if enabled else 0,
expires_at,
timestamp,
invite_id,
),
)
if cursor.rowcount <= 0:
return None
return get_signup_invite_by_id(invite_id)
def delete_signup_invite(invite_id: int) -> bool:
with _connect() as conn:
cursor = conn.execute(
"DELETE FROM signup_invites WHERE id = ?",
(invite_id,),
)
return cursor.rowcount > 0
def increment_signup_invite_use(invite_id: int) -> None:
timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
UPDATE signup_invites
SET use_count = use_count + 1, updated_at = ?
WHERE id = ?
""",
(timestamp, invite_id),
)
def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any]]:
user = get_user_by_username(username)
if not user:
# Resolve case-insensitive duplicates safely by only considering local-provider rows.
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, auto_search_enabled,
jellyfin_password_hash, last_jellyfin_auth_at
FROM users
WHERE username = ? COLLATE NOCASE
ORDER BY
CASE WHEN username = ? THEN 0 ELSE 1 END,
id ASC
""",
(username, username),
).fetchall()
if not rows:
return None
if not verify_password(password, user["password_hash"]):
for row in rows:
provider = str(row[4] or "local").lower()
if provider != "local":
continue
if not verify_password(password, row[2]):
continue
return {
"id": row[0],
"username": row[1],
"password_hash": row[2],
"role": row[3],
"auth_provider": row[4],
"jellyseerr_user_id": row[5],
"created_at": row[6],
"last_login_at": row[7],
"is_blocked": bool(row[8]),
"auto_search_enabled": bool(row[9]),
"jellyfin_password_hash": row[10],
"last_jellyfin_auth_at": row[11],
}
return None
return user
def get_users_by_username_ci(username: str) -> list[Dict[str, Any]]:
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, auto_search_enabled,
jellyfin_password_hash, last_jellyfin_auth_at
FROM users
WHERE username = ? COLLATE NOCASE
ORDER BY
CASE WHEN username = ? THEN 0 ELSE 1 END,
id ASC
""",
(username, username),
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
results.append(
{
"id": row[0],
"username": row[1],
"password_hash": row[2],
"role": row[3],
"auth_provider": row[4],
"jellyseerr_user_id": row[5],
"created_at": row[6],
"last_login_at": row[7],
"is_blocked": bool(row[8]),
"auto_search_enabled": bool(row[9]),
"jellyfin_password_hash": row[10],
"last_jellyfin_auth_at": row[11],
}
)
return results
def set_user_password(username: str, password: str) -> None:
@@ -1991,29 +1549,6 @@ def clear_history() -> Dict[str, int]:
return {"actions": actions, "snapshots": snapshots}
def clear_user_objects_nuclear() -> Dict[str, int]:
with _connect() as conn:
# Preserve admin accounts, but remove invite/profile references so profile rows can be deleted safely.
admin_reset = conn.execute(
"""
UPDATE users
SET profile_id = NULL,
invited_by_code = NULL,
invited_at = NULL
WHERE role = 'admin'
"""
).rowcount
users = conn.execute("DELETE FROM users WHERE role != 'admin'").rowcount
invites = conn.execute("DELETE FROM signup_invites").rowcount
profiles = conn.execute("DELETE FROM user_profiles").rowcount
return {
"users": users,
"invites": invites,
"profiles": profiles,
"adminsReset": admin_reset,
}
def cleanup_history(days: int) -> Dict[str, int]:
if days <= 0:
return {"actions": 0, "snapshots": 0}

View File

@@ -13,13 +13,12 @@ from .routers.requests import (
run_daily_db_cleanup,
)
from .routers.auth import router as auth_router
from .routers.admin import router as admin_router, events_router as admin_events_router
from .routers.admin import router as admin_router
from .routers.images import router as images_router
from .routers.branding import router as branding_router
from .routers.status import router as status_router
from .routers.feedback import router as feedback_router
from .routers.site import router as site_router
from .routers.events import router as events_router
from .services.jellyfin_sync import run_daily_jellyfin_sync
from .logging_config import configure_logging
from .runtime import get_runtime_settings
@@ -54,10 +53,8 @@ async def startup() -> None:
app.include_router(requests_router)
app.include_router(auth_router)
app.include_router(admin_router)
app.include_router(admin_events_router)
app.include_router(images_router)
app.include_router(branding_router)
app.include_router(status_router)
app.include_router(feedback_router)
app.include_router(site_router)
app.include_router(events_router)

View File

@@ -1,18 +1,12 @@
from typing import Any, Dict, List, Optional
from datetime import datetime, timedelta, timezone
import asyncio
import ipaddress
import json
import os
import secrets
import sqlite3
import string
from urllib.parse import urlparse, urlunparse
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File, Request
from fastapi.responses import StreamingResponse
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
from ..auth import require_admin, get_current_user, require_admin_event_stream
from ..auth import require_admin, get_current_user
from ..config import settings as env_settings
from ..db import (
delete_setting,
@@ -32,29 +26,16 @@ from ..db import (
set_user_blocked,
set_user_auto_search_enabled,
set_auto_search_enabled_for_non_admin_users,
set_user_profile_id,
set_user_expires_at,
set_user_password,
set_user_role,
run_integrity_check,
vacuum_db,
clear_requests_cache,
clear_history,
clear_user_objects_nuclear,
cleanup_history,
update_request_cache_title,
repair_request_cache_titles,
delete_non_admin_users,
list_user_profiles,
get_user_profile,
create_user_profile,
update_user_profile,
delete_user_profile,
list_signup_invites,
get_signup_invite_by_id,
create_signup_invite,
update_signup_invite,
delete_signup_invite,
)
from ..runtime import get_runtime_settings
from ..clients.sonarr import SonarrClient
@@ -69,7 +50,6 @@ from ..services.user_cache import (
match_jellyseerr_user_id,
save_jellyfin_users_cache,
save_jellyseerr_users_cache,
clear_user_import_caches,
)
import logging
from ..logging_config import configure_logging
@@ -77,7 +57,6 @@ from ..routers import requests as requests_router
from ..routers.branding import save_branding_image
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
events_router = APIRouter(prefix="/admin/events", tags=["admin"])
logger = logging.getLogger(__name__)
SENSITIVE_KEYS = {
@@ -136,36 +115,6 @@ SETTING_KEYS: List[str] = [
"site_banner_tone",
]
def _admin_live_state_snapshot() -> Dict[str, Any]:
return {
"type": "admin_live_state",
"requestsSync": requests_router.get_requests_sync_state(),
"artworkPrefetch": requests_router.get_artwork_prefetch_state(),
}
def _sse_encode(data: Dict[str, Any]) -> str:
payload = json.dumps(data, ensure_ascii=True, separators=(",", ":"), default=str)
return f"data: {payload}\n\n"
def _read_log_tail_lines(lines: int) -> List[str]:
runtime = get_runtime_settings()
log_file = runtime.log_file
if not log_file:
raise HTTPException(status_code=400, detail="Log file not configured")
if not os.path.isabs(log_file):
log_file = os.path.join(os.getcwd(), log_file)
if not os.path.exists(log_file):
raise HTTPException(status_code=404, detail="Log file not found")
lines = max(1, min(lines, 1000))
from collections import deque
with open(log_file, "r", encoding="utf-8", errors="replace") as handle:
tail = deque(handle, maxlen=lines)
return list(tail)
def _normalize_username(value: str) -> str:
normalized = value.strip().lower()
if "@" in normalized:
@@ -277,105 +226,6 @@ def _normalize_quality_profiles(profiles: Any) -> List[Dict[str, Any]]:
return results
def _normalize_optional_text(value: Any) -> Optional[str]:
if value is None:
return None
if not isinstance(value, str):
value = str(value)
trimmed = value.strip()
return trimmed if trimmed else None
def _parse_optional_positive_int(value: Any, field_name: str) -> Optional[int]:
if value is None or value == "":
return None
try:
parsed = int(value)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail=f"{field_name} must be a number") from exc
if parsed <= 0:
raise HTTPException(status_code=400, detail=f"{field_name} must be greater than 0")
return parsed
def _parse_optional_profile_id(value: Any) -> Optional[int]:
if value is None or value == "":
return None
try:
parsed = int(value)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="profile_id must be a number") from exc
if parsed <= 0:
raise HTTPException(status_code=400, detail="profile_id must be greater than 0")
profile = get_user_profile(parsed)
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
return parsed
def _parse_optional_expires_at(value: Any) -> Optional[str]:
if value is None or value == "":
return None
if not isinstance(value, str):
raise HTTPException(status_code=400, detail="expires_at must be an ISO datetime string")
candidate = value.strip()
if not candidate:
return None
try:
parsed = datetime.fromisoformat(candidate.replace("Z", "+00:00"))
except ValueError as exc:
raise HTTPException(status_code=400, detail="expires_at must be a valid ISO datetime") from exc
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.isoformat()
def _normalize_invite_code(value: Optional[str]) -> str:
raw = (value or "").strip().upper()
filtered = "".join(ch for ch in raw if ch.isalnum())
if len(filtered) < 6:
raise HTTPException(status_code=400, detail="Invite code must be at least 6 letters/numbers.")
return filtered
def _generate_invite_code(length: int = 12) -> str:
alphabet = string.ascii_uppercase + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
def _normalize_role_or_none(value: Any) -> Optional[str]:
if value is None:
return None
if not isinstance(value, str):
value = str(value)
role = value.strip().lower()
if not role:
return None
if role not in {"user", "admin"}:
raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'")
return role
def _calculate_profile_expiry(profile: Dict[str, Any]) -> Optional[str]:
expires_days = profile.get("account_expires_days")
if isinstance(expires_days, int) and expires_days > 0:
return (datetime.now(timezone.utc) + timedelta(days=expires_days)).isoformat()
return None
def _apply_profile_defaults_to_user(username: str, profile: Dict[str, Any]) -> Dict[str, Any]:
set_user_profile_id(username, int(profile["id"]))
role = profile.get("role") or "user"
if role in {"user", "admin"}:
set_user_role(username, role)
set_user_auto_search_enabled(username, bool(profile.get("auto_search_enabled", True)))
set_user_expires_at(username, _calculate_profile_expiry(profile))
refreshed = get_user_by_username(username)
if not refreshed:
raise HTTPException(status_code=404, detail="User not found")
return refreshed
@router.get("/settings")
async def list_settings() -> Dict[str, Any]:
overrides = get_settings_overrides()
@@ -644,65 +494,22 @@ async def requests_sync_status() -> Dict[str, Any]:
return {"status": "ok", "sync": requests_router.get_requests_sync_state()}
@events_router.get("/stream")
async def admin_events_stream(
request: Request,
include_logs: bool = False,
log_lines: int = 200,
_: Dict[str, Any] = Depends(require_admin_event_stream),
) -> StreamingResponse:
async def event_generator():
# Advise client reconnect timing once per stream.
yield "retry: 2000\n\n"
last_snapshot: Optional[str] = None
heartbeat_counter = 0
log_refresh_counter = 5 if include_logs else 0
latest_logs_payload: Optional[Dict[str, Any]] = None
while True:
if await request.is_disconnected():
break
snapshot_payload = _admin_live_state_snapshot()
if include_logs:
log_refresh_counter += 1
if log_refresh_counter >= 5:
log_refresh_counter = 0
try:
latest_logs_payload = {
"lines": _read_log_tail_lines(log_lines),
"count": max(1, min(int(log_lines or 200), 1000)),
}
except HTTPException as exc:
latest_logs_payload = {
"error": str(exc.detail) if exc.detail else "Could not read logs",
}
except Exception as exc:
latest_logs_payload = {"error": str(exc)}
snapshot_payload["logs"] = latest_logs_payload
snapshot = _sse_encode(snapshot_payload)
if snapshot != last_snapshot:
last_snapshot = snapshot
yield snapshot
heartbeat_counter = 0
else:
heartbeat_counter += 1
# Keep the stream alive through proxies even when state is unchanged.
if heartbeat_counter >= 15:
yield ": ping\n\n"
heartbeat_counter = 0
await asyncio.sleep(1.0)
headers = {
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
}
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
@router.get("/logs")
async def read_logs(lines: int = 200) -> Dict[str, Any]:
return {"lines": _read_log_tail_lines(lines)}
runtime = get_runtime_settings()
log_file = runtime.log_file
if not log_file:
raise HTTPException(status_code=400, detail="Log file not configured")
if not os.path.isabs(log_file):
log_file = os.path.join(os.getcwd(), log_file)
if not os.path.exists(log_file):
raise HTTPException(status_code=404, detail="Log file not found")
lines = max(1, min(lines, 1000))
from collections import deque
with open(log_file, "r", encoding="utf-8", errors="replace") as handle:
tail = deque(handle, maxlen=lines)
return {"lines": list(tail)}
@router.get("/requests/cache")
@@ -768,23 +575,9 @@ async def repair_database() -> Dict[str, Any]:
async def flush_database() -> Dict[str, Any]:
cleared = clear_requests_cache()
history = clear_history()
user_objects = clear_user_objects_nuclear()
user_caches = clear_user_import_caches()
delete_setting("requests_sync_last_at")
logger.warning(
"Database flush executed: requests_cache=%s history=%s user_objects=%s user_caches=%s",
cleared,
history,
user_objects,
user_caches,
)
return {
"status": "ok",
"requestsCleared": cleared,
"historyCleared": history,
"userObjectsCleared": user_objects,
"userCachesCleared": user_caches,
}
logger.warning("Database flush executed: requests_cache=%s history=%s", cleared, history)
return {"status": "ok", "requestsCleared": cleared, "historyCleared": history}
@router.post("/maintenance/cleanup")
@@ -814,12 +607,12 @@ async def clear_logs() -> Dict[str, Any]:
@router.get("/users")
async def list_users() -> Dict[str, Any]:
users = get_all_users()
users = [user for user in get_all_users() if user.get("role") == "admin" or user.get("auth_provider") == "jellyseerr"]
return {"users": users}
@router.get("/users/summary")
async def list_users_summary() -> Dict[str, Any]:
users = get_all_users()
users = [user for user in get_all_users() if user.get("role") == "admin" or user.get("auth_provider") == "jellyseerr"]
results: list[Dict[str, Any]] = []
for user in users:
username = user.get("username") or ""
@@ -881,57 +674,6 @@ async def update_user_auto_search(username: str, payload: Dict[str, Any]) -> Dic
return {"status": "ok", "username": username, "auto_search_enabled": enabled}
@router.post("/users/{username}/profile")
async def update_user_profile_assignment(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
user = get_user_by_username(username)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
profile_id = payload.get("profile_id")
if profile_id in (None, ""):
set_user_profile_id(username, None)
refreshed = get_user_by_username(username)
return {"status": "ok", "user": refreshed}
try:
parsed_profile_id = int(profile_id)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="profile_id must be a number") from exc
profile = get_user_profile(parsed_profile_id)
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
if not profile.get("is_active", True):
raise HTTPException(status_code=400, detail="Profile is disabled")
refreshed = _apply_profile_defaults_to_user(username, profile)
return {"status": "ok", "user": refreshed, "applied_profile_id": parsed_profile_id}
@router.post("/users/{username}/expiry")
async def update_user_expiry(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
user = get_user_by_username(username)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
clear = payload.get("clear")
if clear is True:
set_user_expires_at(username, None)
refreshed = get_user_by_username(username)
return {"status": "ok", "user": refreshed}
if "days" in payload and payload.get("days") not in (None, ""):
days = _parse_optional_positive_int(payload.get("days"), "days")
expires_at = None
if days is not None:
expires_at = (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
set_user_expires_at(username, expires_at)
refreshed = get_user_by_username(username)
return {"status": "ok", "user": refreshed}
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
set_user_expires_at(username, expires_at)
refreshed = get_user_by_username(username)
return {"status": "ok", "user": refreshed}
@router.post("/users/auto-search/bulk")
async def update_users_auto_search_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
enabled = payload.get("enabled") if isinstance(payload, dict) else None
@@ -946,68 +688,6 @@ async def update_users_auto_search_bulk(payload: Dict[str, Any]) -> Dict[str, An
}
@router.post("/users/profile/bulk")
async def update_users_profile_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
scope = str(payload.get("scope") or "non-admin-users").strip().lower()
if scope not in {"non-admin-users", "all-users"}:
raise HTTPException(status_code=400, detail="Invalid scope")
profile_id_value = payload.get("profile_id")
if profile_id_value in (None, ""):
users = get_all_users()
updated = 0
for user in users:
if scope == "non-admin-users" and user.get("role") == "admin":
continue
set_user_profile_id(user["username"], None)
updated += 1
return {"status": "ok", "updated": updated, "scope": scope, "profile_id": None}
try:
profile_id = int(profile_id_value)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="profile_id must be a number") from exc
profile = get_user_profile(profile_id)
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
if not profile.get("is_active", True):
raise HTTPException(status_code=400, detail="Profile is disabled")
users = get_all_users()
updated = 0
for user in users:
if scope == "non-admin-users" and user.get("role") == "admin":
continue
_apply_profile_defaults_to_user(user["username"], profile)
updated += 1
return {"status": "ok", "updated": updated, "scope": scope, "profile_id": profile_id}
@router.post("/users/expiry/bulk")
async def update_users_expiry_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
scope = str(payload.get("scope") or "non-admin-users").strip().lower()
if scope not in {"non-admin-users", "all-users"}:
raise HTTPException(status_code=400, detail="Invalid scope")
clear = payload.get("clear")
expires_at: Optional[str] = None
if clear is True:
expires_at = None
elif "days" in payload and payload.get("days") not in (None, ""):
days = _parse_optional_positive_int(payload.get("days"), "days")
expires_at = (datetime.now(timezone.utc) + timedelta(days=int(days or 0))).isoformat() if days else None
else:
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
users = get_all_users()
updated = 0
for user in users:
if scope == "non-admin-users" and user.get("role") == "admin":
continue
set_user_expires_at(user["username"], expires_at)
updated += 1
return {"status": "ok", "updated": updated, "scope": scope, "expires_at": expires_at}
@router.post("/users/{username}/password")
async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
new_password = payload.get("password") if isinstance(payload, dict) else None
@@ -1022,211 +702,3 @@ async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[s
)
set_user_password(username, new_password.strip())
return {"status": "ok", "username": username}
@router.get("/profiles")
async def get_profiles() -> Dict[str, Any]:
profiles = list_user_profiles()
users = get_all_users()
invites = list_signup_invites()
user_counts: Dict[int, int] = {}
invite_counts: Dict[int, int] = {}
for user in users:
profile_id = user.get("profile_id")
if isinstance(profile_id, int):
user_counts[profile_id] = user_counts.get(profile_id, 0) + 1
for invite in invites:
profile_id = invite.get("profile_id")
if isinstance(profile_id, int):
invite_counts[profile_id] = invite_counts.get(profile_id, 0) + 1
enriched = []
for profile in profiles:
pid = int(profile["id"])
enriched.append(
{
**profile,
"assigned_users": user_counts.get(pid, 0),
"assigned_invites": invite_counts.get(pid, 0),
}
)
return {"profiles": enriched}
@router.post("/profiles")
async def create_profile(payload: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
name = _normalize_optional_text(payload.get("name"))
if not name:
raise HTTPException(status_code=400, detail="Profile name is required")
role = _normalize_role_or_none(payload.get("role")) or "user"
auto_search_enabled = payload.get("auto_search_enabled")
if auto_search_enabled is None:
auto_search_enabled = True
if not isinstance(auto_search_enabled, bool):
raise HTTPException(status_code=400, detail="auto_search_enabled must be true or false")
is_active = payload.get("is_active")
if is_active is None:
is_active = True
if not isinstance(is_active, bool):
raise HTTPException(status_code=400, detail="is_active must be true or false")
account_expires_days = _parse_optional_positive_int(
payload.get("account_expires_days"), "account_expires_days"
)
try:
profile = create_user_profile(
name=name,
description=_normalize_optional_text(payload.get("description")),
role=role,
auto_search_enabled=auto_search_enabled,
account_expires_days=account_expires_days,
is_active=is_active,
)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="A profile with that name already exists") from exc
return {"status": "ok", "profile": profile}
@router.put("/profiles/{profile_id}")
async def edit_profile(profile_id: int, payload: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
existing = get_user_profile(profile_id)
if not existing:
raise HTTPException(status_code=404, detail="Profile not found")
name = _normalize_optional_text(payload.get("name"))
if not name:
raise HTTPException(status_code=400, detail="Profile name is required")
role = _normalize_role_or_none(payload.get("role")) or "user"
auto_search_enabled = payload.get("auto_search_enabled")
if not isinstance(auto_search_enabled, bool):
raise HTTPException(status_code=400, detail="auto_search_enabled must be true or false")
is_active = payload.get("is_active")
if not isinstance(is_active, bool):
raise HTTPException(status_code=400, detail="is_active must be true or false")
account_expires_days = _parse_optional_positive_int(
payload.get("account_expires_days"), "account_expires_days"
)
try:
profile = update_user_profile(
profile_id,
name=name,
description=_normalize_optional_text(payload.get("description")),
role=role,
auto_search_enabled=auto_search_enabled,
account_expires_days=account_expires_days,
is_active=is_active,
)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="A profile with that name already exists") from exc
if not profile:
raise HTTPException(status_code=404, detail="Profile not found")
return {"status": "ok", "profile": profile}
@router.delete("/profiles/{profile_id}")
async def remove_profile(profile_id: int) -> Dict[str, Any]:
try:
deleted = delete_user_profile(profile_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not deleted:
raise HTTPException(status_code=404, detail="Profile not found")
return {"status": "ok", "deleted": True, "profile_id": profile_id}
@router.get("/invites")
async def get_invites() -> Dict[str, Any]:
invites = list_signup_invites()
profiles = {profile["id"]: profile for profile in list_user_profiles()}
results = []
for invite in invites:
profile = profiles.get(invite.get("profile_id"))
results.append(
{
**invite,
"profile": (
{
"id": profile.get("id"),
"name": profile.get("name"),
}
if profile
else None
),
}
)
return {"invites": results}
@router.post("/invites")
async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
raw_code = _normalize_optional_text(payload.get("code"))
code = _normalize_invite_code(raw_code) if raw_code else _generate_invite_code()
profile_id = _parse_optional_profile_id(payload.get("profile_id"))
enabled = payload.get("enabled")
if enabled is None:
enabled = True
if not isinstance(enabled, bool):
raise HTTPException(status_code=400, detail="enabled must be true or false")
role = _normalize_role_or_none(payload.get("role"))
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
try:
invite = create_signup_invite(
code=code,
label=_normalize_optional_text(payload.get("label")),
description=_normalize_optional_text(payload.get("description")),
profile_id=profile_id,
role=role,
max_uses=max_uses,
enabled=enabled,
expires_at=expires_at,
created_by=current_user.get("username"),
)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="An invite with that code already exists") from exc
return {"status": "ok", "invite": invite}
@router.put("/invites/{invite_id}")
async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="Invalid payload")
existing = get_signup_invite_by_id(invite_id)
if not existing:
raise HTTPException(status_code=404, detail="Invite not found")
code = _normalize_invite_code(_normalize_optional_text(payload.get("code")) or existing["code"])
profile_id = _parse_optional_profile_id(payload.get("profile_id"))
enabled = payload.get("enabled")
if not isinstance(enabled, bool):
raise HTTPException(status_code=400, detail="enabled must be true or false")
role = _normalize_role_or_none(payload.get("role"))
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
try:
invite = update_signup_invite(
invite_id,
code=code,
label=_normalize_optional_text(payload.get("label")),
description=_normalize_optional_text(payload.get("description")),
profile_id=profile_id,
role=role,
max_uses=max_uses,
enabled=enabled,
expires_at=expires_at,
)
except sqlite3.IntegrityError as exc:
raise HTTPException(status_code=409, detail="An invite with that code already exists") from exc
if not invite:
raise HTTPException(status_code=404, detail="Invite not found")
return {"status": "ok", "invite": invite}
@router.delete("/invites/{invite_id}")
async def remove_invite(invite_id: int) -> Dict[str, Any]:
deleted = delete_signup_invite(invite_id)
if not deleted:
raise HTTPException(status_code=404, detail="Invite not found")
return {"status": "ok", "deleted": True, "invite_id": invite_id}

View File

@@ -5,16 +5,13 @@ from fastapi.security import OAuth2PasswordRequestForm
from ..db import (
verify_user_password,
create_user,
create_user_if_missing,
set_last_login,
get_user_by_username,
get_users_by_username_ci,
set_user_password,
set_jellyfin_auth_cache,
set_user_jellyseerr_id,
get_signup_invite_by_code,
increment_signup_invite_use,
get_user_profile,
get_user_activity,
get_user_activity_summary,
get_user_request_stats,
@@ -84,60 +81,30 @@ def _extract_jellyseerr_user_id(response: dict) -> int | None:
return None
def _is_user_expired(user: dict | None) -> bool:
if not user:
return False
expires_at = user.get("expires_at")
if not expires_at:
return False
try:
parsed = datetime.fromisoformat(str(expires_at).replace("Z", "+00:00"))
except ValueError:
return False
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed <= datetime.now(timezone.utc)
def _assert_user_can_login(user: dict | None) -> None:
if not user:
return
if user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
if _is_user_expired(user):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
return {
"code": invite.get("code"),
"label": invite.get("label"),
"description": invite.get("description"),
"enabled": bool(invite.get("enabled")),
"expires_at": invite.get("expires_at"),
"max_uses": invite.get("max_uses"),
"use_count": invite.get("use_count", 0),
"remaining_uses": invite.get("remaining_uses"),
"is_expired": bool(invite.get("is_expired")),
"is_usable": bool(invite.get("is_usable")),
"profile": (
{
"id": profile.get("id"),
"name": profile.get("name"),
"description": profile.get("description"),
}
if profile
else None
),
}
@router.post("/login")
async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
# Provider placeholder passwords must never be accepted by the local-login endpoint.
if form_data.password in {"jellyfin-user", "jellyseerr-user"}:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
matching_users = get_users_by_username_ci(form_data.username)
has_external_match = any(
str(user.get("auth_provider") or "local").lower() != "local" for user in matching_users
)
if has_external_match:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This account uses external sign-in. Use the external sign-in option.",
)
user = verify_user_password(form_data.username, form_data.password)
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
_assert_user_can_login(user)
if str(user.get("auth_provider") or "local").lower() != "local":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="This account uses external sign-in. Use the external sign-in option.",
)
if user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
token = create_access_token(user["username"], user["role"])
set_last_login(user["username"])
return {
@@ -158,7 +125,8 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
username = form_data.username
password = form_data.password
user = get_user_by_username(username)
_assert_user_can_login(user)
if user and user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
if user and _has_valid_jellyfin_cache(user, password):
token = create_access_token(username, "user")
set_last_login(username)
@@ -171,7 +139,8 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
create_user_if_missing(username, "jellyfin-user", role="user", auth_provider="jellyfin")
user = get_user_by_username(username)
_assert_user_can_login(user)
if user and user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
try:
users = await client.get_users()
if isinstance(users, list):
@@ -216,7 +185,8 @@ async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) ->
jellyseerr_user_id=jellyseerr_user_id,
)
user = get_user_by_username(form_data.username)
_assert_user_can_login(user)
if user and user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
if jellyseerr_user_id is not None:
set_user_jellyseerr_id(form_data.username, jellyseerr_user_id)
token = create_access_token(form_data.username, "user")
@@ -229,107 +199,6 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict:
return current_user
@router.get("/invites/{code}")
async def invite_details(code: str) -> dict:
invite = get_signup_invite_by_code(code.strip())
if not invite:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
profile = None
profile_id = invite.get("profile_id")
if profile_id is not None:
profile = get_user_profile(int(profile_id))
if profile and not profile.get("is_active", True):
invite = {**invite, "is_usable": False}
return {"invite": _public_invite_payload(invite, profile)}
@router.post("/signup")
async def signup(payload: dict) -> dict:
if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
invite_code = str(payload.get("invite_code") or "").strip()
username = str(payload.get("username") or "").strip()
password = str(payload.get("password") or "")
if not invite_code:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite code is required")
if not username:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Username is required")
if len(password.strip()) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password must be at least 8 characters.",
)
if get_user_by_username(username):
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="User already exists")
invite = get_signup_invite_by_code(invite_code)
if not invite:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invite not found")
if not invite.get("enabled"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite is disabled")
if invite.get("is_expired"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite has expired")
remaining_uses = invite.get("remaining_uses")
if remaining_uses is not None and int(remaining_uses) <= 0:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite has no remaining uses")
profile = None
profile_id = invite.get("profile_id")
if profile_id is not None:
profile = get_user_profile(int(profile_id))
if not profile:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invite profile not found")
if not profile.get("is_active", True):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invite profile is disabled")
invite_role = invite.get("role")
profile_role = profile.get("role") if profile else None
role = invite_role if invite_role in {"user", "admin"} else profile_role
if role not in {"user", "admin"}:
role = "user"
auto_search_enabled = (
bool(profile.get("auto_search_enabled", True))
if profile is not None
else True
)
expires_at = None
account_expires_days = profile.get("account_expires_days") if profile else None
if isinstance(account_expires_days, int) and account_expires_days > 0:
expires_at = (datetime.now(timezone.utc) + timedelta(days=account_expires_days)).isoformat()
try:
create_user(
username,
password.strip(),
role=role,
auth_provider="local",
auto_search_enabled=auto_search_enabled,
profile_id=int(profile_id) if profile_id is not None else None,
expires_at=expires_at,
invited_by_code=invite.get("code"),
)
except Exception as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
increment_signup_invite_use(int(invite["id"]))
created_user = get_user_by_username(username)
_assert_user_can_login(created_user)
token = create_access_token(username, role)
set_last_login(username)
return {
"access_token": token,
"token_type": "bearer",
"user": {
"username": username,
"role": role,
"profile_id": created_user.get("profile_id") if created_user else None,
"expires_at": created_user.get("expires_at") if created_user else None,
},
}
@router.get("/profile")
async def profile(current_user: dict = Depends(get_current_user)) -> dict:
username = current_user.get("username") or ""

View File

@@ -1,112 +0,0 @@
from __future__ import annotations
import asyncio
import json
import time
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, Request
from fastapi.responses import StreamingResponse
from ..auth import get_current_user_event_stream
from . import requests as requests_router
from .status import services_status
router = APIRouter(prefix="/events", tags=["events"])
def _sse_json(payload: Dict[str, Any]) -> str:
return f"data: {json.dumps(payload, ensure_ascii=True, separators=(',', ':'), default=str)}\n\n"
@router.get("/stream")
async def events_stream(
request: Request,
recent_days: int = 90,
user: Dict[str, Any] = Depends(get_current_user_event_stream),
) -> StreamingResponse:
recent_days = max(0, min(int(recent_days or 90), 3650))
recent_take = 50 if user.get("role") == "admin" else 6
async def event_generator():
yield "retry: 2000\n\n"
last_recent_signature: Optional[str] = None
last_services_signature: Optional[str] = None
next_recent_at = 0.0
next_services_at = 0.0
heartbeat_counter = 0
while True:
if await request.is_disconnected():
break
now = time.monotonic()
sent_any = False
if now >= next_recent_at:
next_recent_at = now + 15.0
try:
recent_payload = await requests_router.recent_requests(
take=recent_take,
skip=0,
days=recent_days,
user=user,
)
results = recent_payload.get("results") if isinstance(recent_payload, dict) else []
payload = {
"type": "home_recent",
"ts": datetime.now(timezone.utc).isoformat(),
"days": recent_days,
"results": results if isinstance(results, list) else [],
}
except Exception as exc:
payload = {
"type": "home_recent",
"ts": datetime.now(timezone.utc).isoformat(),
"days": recent_days,
"error": str(exc),
}
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
if signature != last_recent_signature:
last_recent_signature = signature
yield _sse_json(payload)
sent_any = True
if now >= next_services_at:
next_services_at = now + 30.0
try:
status_payload = await services_status()
payload = {
"type": "home_services",
"ts": datetime.now(timezone.utc).isoformat(),
"status": status_payload,
}
except Exception as exc:
payload = {
"type": "home_services",
"ts": datetime.now(timezone.utc).isoformat(),
"error": str(exc),
}
signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str)
if signature != last_services_signature:
last_services_signature = signature
yield _sse_json(payload)
sent_any = True
if sent_any:
heartbeat_counter = 0
else:
heartbeat_counter += 1
if heartbeat_counter >= 15:
yield ": ping\n\n"
heartbeat_counter = 0
await asyncio.sleep(1.0)
headers = {
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
}
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)

View File

@@ -3,7 +3,7 @@ import logging
from datetime import datetime, timezone, timedelta
from typing import Any, Dict, List, Optional
from ..db import get_setting, set_setting, delete_setting
from ..db import get_setting, set_setting
logger = logging.getLogger(__name__)
@@ -142,17 +142,3 @@ def save_jellyfin_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any
def get_cached_jellyfin_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]:
return _load_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, max_age_minutes)
def clear_user_import_caches() -> Dict[str, int]:
cleared = 0
for key in (
JELLYSEERR_CACHE_KEY,
JELLYSEERR_CACHE_AT_KEY,
JELLYFIN_CACHE_KEY,
JELLYFIN_CACHE_AT_KEY,
):
delete_setting(key)
cleared += 1
logger.debug("Cleared user import cache keys: %s", cleared)
return {"settingsKeysCleared": cleared}

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell'
@@ -141,9 +141,6 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const [artworkSummaryStatus, setArtworkSummaryStatus] = useState<string | null>(null)
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
const [maintenanceBusy, setMaintenanceBusy] = useState(false)
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
const requestsSyncRef = useRef<any | null>(null)
const artworkPrefetchRef = useRef<any | null>(null)
const loadSettings = useCallback(async () => {
const baseUrl = getApiBase()
@@ -341,14 +338,6 @@ export default function SettingsPage({ section }: SettingsPageProps) {
return false
}
useEffect(() => {
requestsSyncRef.current = requestsSync
}, [requestsSync])
useEffect(() => {
artworkPrefetchRef.current = artworkPrefetch
}, [artworkPrefetch])
const settingDescriptions: Record<string, string> = {
jellyseerr_base_url:
'Base URL for your Jellyseerr server (FQDN or IP). Scheme is optional.',
@@ -587,100 +576,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}
useEffect(() => {
const shouldSubscribe = showRequestsExtras || showArtworkExtras || showLogs
if (!shouldSubscribe) {
setLiveStreamConnected(false)
return
}
const token = getToken()
if (!token) {
setLiveStreamConnected(false)
return
}
const baseUrl = getApiBase()
const params = new URLSearchParams()
params.set('access_token', token)
if (showLogs) {
params.set('include_logs', '1')
params.set('log_lines', String(logsCount))
}
const streamUrl = `${baseUrl}/admin/events/stream?${params.toString()}`
let closed = false
const source = new EventSource(streamUrl)
source.onopen = () => {
if (closed) return
setLiveStreamConnected(true)
}
source.onmessage = (event) => {
if (closed) return
setLiveStreamConnected(true)
try {
const payload = JSON.parse(event.data)
if (!payload || payload.type !== 'admin_live_state') {
return
}
const rawSync =
payload.requestsSync && typeof payload.requestsSync === 'object'
? payload.requestsSync
: null
const nextSync = rawSync?.status === 'idle' ? null : rawSync
const prevSync = requestsSyncRef.current
requestsSyncRef.current = nextSync
setRequestsSync(nextSync)
if (prevSync?.status === 'running' && nextSync?.status && nextSync.status !== 'running') {
setRequestsSyncStatus(nextSync.message || 'Sync complete.')
}
const rawArtwork =
payload.artworkPrefetch && typeof payload.artworkPrefetch === 'object'
? payload.artworkPrefetch
: null
const nextArtwork = rawArtwork?.status === 'idle' ? null : rawArtwork
const prevArtwork = artworkPrefetchRef.current
artworkPrefetchRef.current = nextArtwork
setArtworkPrefetch(nextArtwork)
if (
prevArtwork?.status === 'running' &&
nextArtwork?.status &&
nextArtwork.status !== 'running'
) {
setArtworkPrefetchStatus(nextArtwork.message || 'Artwork caching complete.')
if (showArtworkExtras) {
void loadArtworkSummary()
}
}
if (payload.logs && typeof payload.logs === 'object') {
if (Array.isArray(payload.logs.lines)) {
setLogsLines(payload.logs.lines)
setLogsStatus(null)
} else if (typeof payload.logs.error === 'string' && payload.logs.error.trim()) {
setLogsStatus(payload.logs.error)
}
}
} catch (err) {
console.error(err)
}
}
source.onerror = () => {
if (closed) return
setLiveStreamConnected(false)
}
return () => {
closed = true
setLiveStreamConnected(false)
source.close()
}
}, [loadArtworkSummary, logsCount, showArtworkExtras, showLogs, showRequestsExtras])
useEffect(() => {
if (liveStreamConnected || !artworkPrefetch || artworkPrefetch.status !== 'running') {
if (!artworkPrefetch || artworkPrefetch.status !== 'running') {
return
}
let active = true
@@ -706,7 +602,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
active = false
clearInterval(timer)
}
}, [artworkPrefetch, liveStreamConnected, loadArtworkSummary])
}, [artworkPrefetch, loadArtworkSummary])
useEffect(() => {
if (!artworkPrefetch || artworkPrefetch.status === 'running') {
@@ -719,7 +615,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}, [artworkPrefetch])
useEffect(() => {
if (liveStreamConnected || !requestsSync || requestsSync.status !== 'running') {
if (!requestsSync || requestsSync.status !== 'running') {
return
}
let active = true
@@ -744,7 +640,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
active = false
clearInterval(timer)
}
}, [liveStreamConnected, requestsSync])
}, [requestsSync])
useEffect(() => {
if (!requestsSync || requestsSync.status === 'running') {
@@ -787,15 +683,12 @@ export default function SettingsPage({ section }: SettingsPageProps) {
if (!showLogs) {
return
}
if (liveStreamConnected) {
return
}
void loadLogs()
const timer = setInterval(() => {
void loadLogs()
}, 5000)
return () => clearInterval(timer)
}, [liveStreamConnected, loadLogs, showLogs])
}, [loadLogs, showLogs])
const loadCache = async () => {
setCacheStatus(null)
@@ -870,7 +763,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
setMaintenanceBusy(true)
if (typeof window !== 'undefined') {
const ok = window.confirm(
'This will perform a nuclear reset: clear cached requests/history, wipe non-admin users, invites, and profiles, then re-sync users and requests from Jellyseerr. Continue?'
'This will clear cached requests and history, then re-sync from Jellyseerr. Continue?'
)
if (!ok) {
setMaintenanceBusy(false)
@@ -879,7 +772,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}
try {
const baseUrl = getApiBase()
setMaintenanceStatus('Running nuclear flush...')
setMaintenanceStatus('Flushing database...')
const flushResponse = await authFetch(`${baseUrl}/admin/maintenance/flush`, {
method: 'POST',
})
@@ -887,25 +780,12 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const text = await flushResponse.text()
throw new Error(text || 'Flush failed')
}
const flushData = await flushResponse.json()
const usersCleared = Number(flushData?.userObjectsCleared?.users ?? 0)
setMaintenanceStatus(`Nuclear flush complete. Cleared ${usersCleared} non-admin users. Re-syncing users...`)
const usersResyncResponse = await authFetch(`${baseUrl}/admin/jellyseerr/users/resync`, {
method: 'POST',
})
if (!usersResyncResponse.ok) {
const text = await usersResyncResponse.text()
throw new Error(text || 'User resync failed')
}
const usersResyncData = await usersResyncResponse.json()
setMaintenanceStatus(
`Users re-synced (${usersResyncData?.imported ?? 0} imported). Starting request re-sync...`
)
setMaintenanceStatus('Database flushed. Starting re-sync...')
await syncRequests()
setMaintenanceStatus('Nuclear flush complete. User and request re-sync running now.')
setMaintenanceStatus('Database flushed. Re-sync running now.')
} catch (err) {
console.error(err)
setMaintenanceStatus('Nuclear flush + resync failed.')
setMaintenanceStatus('Flush + resync failed.')
} finally {
setMaintenanceBusy(false)
}
@@ -1572,7 +1452,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<h2>Maintenance</h2>
</div>
<div className="status-banner">
Emergency tools. Use with care: flush + resync now performs a nuclear wipe of non-admin users, invite links, profiles, cached requests, and history before re-syncing Jellyseerr users/requests.
Emergency tools. Use with care: flush will clear saved requests and history.
</div>
{maintenanceStatus && <div className="status-banner">{maintenanceStatus}</div>}
<div className="maintenance-grid">
@@ -1591,7 +1471,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
onClick={runFlushAndResync}
disabled={maintenanceBusy}
>
Nuclear flush + resync
Flush database + resync
</button>
</div>
</section>

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
import { redirect } from 'next/navigation'
export default function AdminProfilesRedirectPage() {
redirect('/admin/invites')
}

View File

@@ -1027,30 +1027,6 @@ button span {
gap: 12px;
}
/* Header account menu layering fix */
.header {
position: relative;
overflow: visible;
}
.header-right {
position: relative;
z-index: 40;
}
.signed-in-menu {
z-index: 50;
}
.signed-in-dropdown {
z-index: 2000;
}
.header-nav {
position: relative;
z-index: 1;
}
.admin-toolbar {
display: flex;
justify-content: space-between;
@@ -3919,776 +3895,3 @@ button:hover:not(:disabled) {
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%;
}
}
/* 1.3 UI layout cleanup: users + invite management */
.user-directory-control-grid {
display: grid;
grid-template-columns: 1.2fr minmax(320px, 0.8fr);
gap: 12px;
margin-top: 12px;
margin-bottom: 12px;
align-items: start;
}
.user-directory-search-panel,
.user-directory-bulk-panel {
display: grid;
gap: 10px;
}
.user-directory-panel-header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.user-directory-panel-header h2 {
margin: 0;
font-size: 0.98rem;
letter-spacing: 0.01em;
}
.user-directory-panel-header .lede {
margin: 4px 0 0;
max-width: 46ch;
}
.user-directory-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 10px;
align-items: end;
}
.user-directory-search {
width: 100%;
}
.user-directory-search > label {
display: grid;
gap: 6px;
}
.user-directory-search input {
width: 100%;
}
.user-bulk-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
}
.user-bulk-summary {
display: grid;
gap: 4px;
}
.user-bulk-summary strong {
font-size: 0.92rem;
}
.user-bulk-summary span {
color: #9ea7b6;
font-size: 0.82rem;
}
.user-bulk-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.user-directory-list {
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.015);
border-radius: 12px;
overflow: hidden;
}
.user-directory-header {
display: grid;
grid-template-columns: 1.3fr 1.35fr 1fr 1.05fr auto;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
color: #9ea7b6;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.user-directory-row {
display: grid;
grid-template-columns: 1.3fr 1.35fr 1fr 1.05fr auto;
gap: 10px;
align-items: center;
padding: 13px 14px;
border-top: 1px solid rgba(255, 255, 255, 0.04);
color: inherit;
text-decoration: none;
transition: background-color 120ms ease, border-color 120ms ease;
}
.user-directory-row:first-of-type {
border-top: 0;
}
.user-directory-row:hover {
background: rgba(255, 255, 255, 0.03);
}
.user-directory-cell {
min-width: 0;
display: grid;
gap: 5px;
}
.user-directory-cell--identity .user-directory-title-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.user-directory-cell--identity strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-directory-subtext {
color: #98a1af;
font-size: 0.78rem;
line-height: 1.3;
}
.user-directory-pill-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.user-directory-stats-inline {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 10px;
color: #b8c0cc;
font-size: 0.78rem;
}
.user-directory-stats-inline strong {
color: #eef2f7;
font-weight: 700;
}
.user-directory-row-chevron {
color: #a9b2c2;
font-size: 0.76rem;
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 999px;
padding: 4px 10px;
white-space: nowrap;
}
.user-detail-page-grid {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.8fr);
gap: 14px;
align-items: start;
}
.user-detail-main-column,
.user-detail-side-column {
display: grid;
gap: 14px;
}
.user-detail-panel {
display: grid;
gap: 12px;
}
.user-detail-panel-header {
display: grid;
gap: 6px;
}
.user-detail-panel-header h2 {
margin: 0;
font-size: 0.98rem;
}
.user-detail-panel-header .lede {
margin: 0;
}
.user-detail-meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.user-detail-meta-item {
display: grid;
gap: 4px;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.015);
border-radius: 10px;
padding: 10px 11px;
}
.user-detail-meta-item .label {
color: #9ea7b6;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.user-detail-meta-item strong {
font-size: 0.9rem;
color: #e7ebf1;
line-height: 1.3;
overflow-wrap: anywhere;
}
.user-detail-control-stack {
display: grid;
gap: 8px;
}
.user-detail-control-stack .toggle {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.015);
}
.user-detail-control-stack > button {
justify-self: start;
}
.user-detail-grid {
gap: 10px;
}
.user-detail-stat {
border-radius: 10px;
}
.invite-admin-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.invite-admin-summary-tile {
min-height: 96px;
}
.admin-segmented {
display: inline-flex;
flex-wrap: wrap;
gap: 6px;
padding: 5px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.02);
border-radius: 12px;
margin-bottom: 12px;
}
.admin-segmented button {
border: 1px solid transparent;
background: transparent;
color: #b6bfcb;
padding: 8px 12px;
border-radius: 9px;
font-size: 0.84rem;
font-weight: 600;
}
.admin-segmented button:hover {
background: rgba(255, 255, 255, 0.03);
color: #e3e8ef;
}
.admin-segmented button.is-active {
background: rgba(96, 132, 179, 0.14);
border-color: rgba(96, 132, 179, 0.25);
color: #e7eef9;
}
.invite-admin-bulk-grid {
grid-template-columns: minmax(360px, 1.2fr) minmax(300px, 0.8fr);
}
.invite-admin-summary-panel {
display: grid;
gap: 10px;
margin-bottom: 12px;
}
.invite-admin-summary-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.invite-admin-summary-header h2 {
margin: 0;
font-size: 0.98rem;
}
.invite-admin-summary-header .lede {
margin: 4px 0 0;
}
.invite-admin-summary-list {
display: grid;
gap: 6px;
}
.invite-admin-summary-row {
display: grid;
grid-template-columns: minmax(150px, 200px) minmax(0, 1fr);
gap: 12px;
align-items: center;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.015);
}
.invite-admin-summary-row .label {
font-size: 0.78rem;
color: #9ea7b6;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.invite-admin-summary-row__value {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 8px 12px;
}
.invite-admin-summary-row__value strong {
color: #eef2f7;
font-size: 1rem;
}
.invite-admin-summary-row__value span {
color: #b3bcc8;
font-size: 0.82rem;
}
.invite-admin-tabbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px 12px;
margin-bottom: 12px;
}
.invite-admin-tabbar .admin-segmented {
margin-bottom: 0;
}
.invite-admin-tab-actions {
justify-content: flex-end;
}
.invite-form-layout {
gap: 10px;
}
.invite-form-row {
display: grid;
grid-template-columns: minmax(150px, 190px) minmax(0, 1fr);
gap: 12px;
align-items: start;
padding: 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.015);
border-radius: 10px;
}
.invite-form-row-label {
display: grid;
gap: 4px;
}
.invite-form-row-label > span {
color: #e6ebf2;
font-size: 0.85rem;
font-weight: 600;
}
.invite-form-row-label > small {
color: #9ea7b6;
font-size: 0.76rem;
line-height: 1.3;
}
.invite-form-row-control {
display: grid;
gap: 8px;
}
.invite-form-row-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.invite-form-row-grid > label {
display: grid;
gap: 6px;
}
.invite-form-row-grid > label > span {
color: #9ea7b6;
font-size: 0.76rem;
}
.invite-form-row-control textarea,
.invite-form-row-control input,
.invite-form-row-control select {
width: 100%;
}
.invite-form-row-control--stacked {
gap: 10px;
}
.admin-panel > h2 + .lede {
margin-top: -2px;
}
@media (max-width: 1180px) {
.user-directory-control-grid {
grid-template-columns: 1fr;
}
.user-detail-page-grid {
grid-template-columns: 1fr;
}
.invite-admin-summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.invite-admin-bulk-grid {
grid-template-columns: 1fr;
}
.invite-form-row {
grid-template-columns: 1fr;
}
.invite-form-row-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 980px) {
.user-bulk-toolbar {
grid-template-columns: 1fr;
align-items: stretch;
}
.user-bulk-actions {
justify-content: flex-start;
}
.user-directory-header {
display: none;
}
.user-directory-row {
grid-template-columns: 1fr;
gap: 8px;
align-items: start;
}
.user-directory-cell {
gap: 6px;
}
.user-directory-row-chevron {
justify-self: start;
}
.user-detail-meta-grid {
grid-template-columns: 1fr;
}
.invite-admin-summary-grid {
grid-template-columns: 1fr;
}
.invite-admin-summary-row {
grid-template-columns: 1fr;
align-items: start;
}
.invite-admin-summary-row__value {
justify-content: flex-start;
}
.invite-admin-tabbar {
align-items: stretch;
}
.invite-admin-tab-actions {
justify-content: flex-start;
}
}

View File

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

View File

@@ -4,24 +4,6 @@ import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { authFetch, getApiBase, getToken, clearToken } from './lib/auth'
const normalizeRecentResults = (items: any[]) =>
items
.filter((item: any) => item?.id)
.map((item: any) => {
const id = item.id
const rawTitle = item.title
const placeholder =
typeof rawTitle === 'string' && rawTitle.trim().toLowerCase() === `request ${id}`
return {
id,
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
year: item.year,
statusLabel: item.statusLabel,
artwork: item.artwork,
createdAt: item.createdAt ?? null,
}
})
export default function HomePage() {
const router = useRouter()
const [query, setQuery] = useState('')
@@ -51,7 +33,6 @@ export default function HomePage() {
const [servicesError, setServicesError] = useState<string | null>(null)
const [serviceTesting, setServiceTesting] = useState<Record<string, boolean>>({})
const [serviceTestResults, setServiceTestResults] = useState<Record<string, string | null>>({})
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
const submit = (event: React.FormEvent) => {
event.preventDefault()
@@ -156,7 +137,25 @@ export default function HomePage() {
}
const data = await response.json()
if (Array.isArray(data?.results)) {
setRecent(normalizeRecentResults(data.results))
setRecent(
data.results
.filter((item: any) => item?.id)
.map((item: any) => {
const id = item.id
const rawTitle = item.title
const placeholder =
typeof rawTitle === 'string' &&
rawTitle.trim().toLowerCase() === `request ${id}`
return {
id,
title: !rawTitle || placeholder ? `Request #${id}` : rawTitle,
year: item.year,
statusLabel: item.statusLabel,
artwork: item.artwork,
createdAt: item.createdAt ?? null,
}
})
)
}
} catch (error) {
console.error(error)
@@ -197,79 +196,10 @@ export default function HomePage() {
}
}
void load()
if (liveStreamConnected) {
return
}
load()
const timer = setInterval(load, 30000)
return () => clearInterval(timer)
}, [authReady, liveStreamConnected, router])
useEffect(() => {
if (!authReady) {
setLiveStreamConnected(false)
return
}
const token = getToken()
if (!token) {
setLiveStreamConnected(false)
return
}
const baseUrl = getApiBase()
const streamUrl = `${baseUrl}/events/stream?access_token=${encodeURIComponent(token)}&recent_days=${encodeURIComponent(String(recentDays))}`
let closed = false
const source = new EventSource(streamUrl)
source.onopen = () => {
if (closed) return
setLiveStreamConnected(true)
}
source.onmessage = (event) => {
if (closed) return
setLiveStreamConnected(true)
try {
const payload = JSON.parse(event.data)
if (!payload || typeof payload !== 'object') {
return
}
if (payload.type === 'home_recent') {
if (Array.isArray(payload.results)) {
setRecent(normalizeRecentResults(payload.results))
setRecentError(null)
setRecentLoading(false)
} else if (typeof payload.error === 'string' && payload.error.trim()) {
setRecentError('Recent requests are not available right now.')
setRecentLoading(false)
}
return
}
if (payload.type === 'home_services') {
if (payload.status && typeof payload.status === 'object') {
setServicesStatus(payload.status)
setServicesError(null)
setServicesLoading(false)
} else if (typeof payload.error === 'string' && payload.error.trim()) {
setServicesError('Service status is not available right now.')
setServicesLoading(false)
}
}
} catch (error) {
console.error(error)
}
}
source.onerror = () => {
if (closed) return
setLiveStreamConnected(false)
}
return () => {
closed = true
setLiveStreamConnected(false)
source.close()
}
}, [authReady, recentDays])
}, [authReady, router])
const runSearch = async (term: string) => {
try {

View File

@@ -1,223 +0,0 @@
'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,7 +27,6 @@ const NAV_GROUPS = [
items: [
{ href: '/admin/site', label: 'Site' },
{ href: '/users', label: 'Users' },
{ href: '/admin/invites', label: 'Invite management' },
{ href: '/admin/logs', label: 'Activity log' },
{ href: '/admin/maintenance', label: 'Maintenance' },
],

View File

@@ -26,15 +26,6 @@ type AdminUser = {
is_blocked?: boolean
auto_search_enabled?: boolean
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) => {
@@ -44,22 +35,6 @@ const formatDateTime = (value?: string | null) => {
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 => ({
total: Number(stats?.total ?? 0),
ready: Number(stats?.ready ?? 0),
@@ -80,36 +55,6 @@ export default function UserDetailPage() {
const [stats, setStats] = useState<UserStats | null>(null)
const [error, setError] = useState<string | null>(null)
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 () => {
if (!idParam) return
@@ -135,15 +80,8 @@ export default function UserDetailPage() {
throw new Error('Could not load user.')
}
const data = await response.json()
const nextUser = data?.user ?? null
setUser(nextUser)
setUser(data?.user ?? null)
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)
} catch (err) {
console.error(err)
@@ -156,7 +94,6 @@ export default function UserDetailPage() {
const toggleUserBlock = async (blocked: boolean) => {
if (!user) return
try {
setActionStatus(null)
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/${blocked ? 'block' : 'unblock'}`,
@@ -166,7 +103,6 @@ export default function UserDetailPage() {
throw new Error('Update failed')
}
await loadUser()
setActionStatus(blocked ? 'User blocked.' : 'User unblocked.')
} catch (err) {
console.error(err)
setError('Could not update user access.')
@@ -176,7 +112,6 @@ export default function UserDetailPage() {
const updateUserRole = async (role: string) => {
if (!user) return
try {
setActionStatus(null)
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/role`,
@@ -190,7 +125,6 @@ export default function UserDetailPage() {
throw new Error('Update failed')
}
await loadUser()
setActionStatus(`Role updated to ${role}.`)
} catch (err) {
console.error(err)
setError('Could not update user role.')
@@ -200,7 +134,6 @@ export default function UserDetailPage() {
const updateAutoSearchEnabled = async (enabled: boolean) => {
if (!user) return
try {
setActionStatus(null)
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/auto-search`,
@@ -214,114 +147,18 @@ export default function UserDetailPage() {
throw new Error('Update failed')
}
await loadUser()
setActionStatus(`Auto search/download ${enabled ? 'enabled' : 'disabled'}.`)
} catch (err) {
console.error(err)
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(() => {
if (!getToken()) {
router.push('/login')
return
}
void loadUser()
void loadProfiles()
}, [router, idParam])
if (loading) {
@@ -340,59 +177,62 @@ export default function UserDetailPage() {
>
<section className="admin-section">
{error && <div className="error-banner">{error}</div>}
{actionStatus && <div className="status-banner">{actionStatus}</div>}
{!user ? (
<div className="status-banner">No user data found.</div>
) : (
<div className="user-detail-page-grid">
<div className="user-detail-main-column">
<div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header">
<>
<div className="user-detail-card">
<div className="user-detail-layout">
<div className="user-detail-identity">
<div className="user-detail-title-row">
<strong className="user-detail-name">{user.username}</strong>
<span className={`user-grid-pill ${user.is_blocked ? 'is-blocked' : ''}`}>
{user.is_blocked ? 'Blocked' : 'Active'}
</span>
<span className={`user-grid-pill ${user.is_expired ? 'is-blocked' : ''}`}>
{user.is_expired ? 'Expired' : user.expires_at ? 'Expiry set' : 'No expiry'}
</div>
<div className="user-detail-meta-pills">
<span className="user-detail-chip">
Jellyseerr ID: {user.jellyseerr_user_id ?? user.id ?? 'Unknown'}
</span>
</div>
<p className="lede">
User identity, access state, and request history for this account.
</p>
</div>
<div className="user-detail-meta-grid">
<div className="user-detail-meta-item">
<span className="label">Jellyseerr ID</span>
<strong>{user.jellyseerr_user_id ?? user.id ?? 'Unknown'}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Role</span>
<strong>{user.role}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Login type</span>
<strong>{user.auth_provider || 'local'}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Assigned profile</span>
<strong>{user.profile_id ?? 'None'}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Last login</span>
<strong>{formatDateTime(user.last_login_at)}</strong>
</div>
<div className="user-detail-meta-item">
<span className="label">Account expiry</span>
<strong>{user.expires_at ? formatDateTime(user.expires_at) : 'Never'}</strong>
<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">Last login: {formatDateTime(user.last_login_at)}</span>
</div>
</div>
<div className="user-detail-controls">
<div className="user-detail-controls-title">User controls</div>
<div className="user-detail-actions">
<label className="toggle">
<input
type="checkbox"
checked={user.role === 'admin'}
onChange={(event) => updateUserRole(event.target.checked ? 'admin' : 'user')}
/>
<span>Make admin</span>
</label>
<label className="toggle">
<input
type="checkbox"
checked={Boolean(user.auto_search_enabled ?? true)}
disabled={user.role === 'admin'}
onChange={(event) => updateAutoSearchEnabled(event.target.checked)}
/>
<span>Allow auto search/download</span>
</label>
<button
type="button"
className="ghost-button"
onClick={() => toggleUserBlock(!user.is_blocked)}
>
{user.is_blocked ? 'Allow access' : 'Block access'}
</button>
</div>
{user.role === 'admin' && (
<div className="user-detail-helper">
Admins always have auto search/download access.
</div>
)}
</div>
<div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header">
<h2>Request statistics</h2>
<p className="lede">Snapshot of request states and recent activity for this user.</p>
</div>
<div className="user-detail-grid">
<div className="user-detail-stat">
@@ -433,120 +273,7 @@ export default function UserDetailPage() {
</div>
</div>
</div>
</div>
<div className="user-detail-side-column">
<div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header">
<h2>Access controls</h2>
<p className="lede">Role, login access, and auto-download behavior.</p>
</div>
<div className="user-detail-control-stack">
<label className="toggle">
<input
type="checkbox"
checked={user.role === 'admin'}
onChange={(event) => updateUserRole(event.target.checked ? 'admin' : 'user')}
/>
<span>Make admin</span>
</label>
<label className="toggle">
<input
type="checkbox"
checked={Boolean(user.auto_search_enabled ?? true)}
disabled={user.role === 'admin'}
onChange={(event) => updateAutoSearchEnabled(event.target.checked)}
/>
<span>Allow auto search/download</span>
</label>
<button
type="button"
className="ghost-button"
onClick={() => toggleUserBlock(!user.is_blocked)}
>
{user.is_blocked ? 'Allow access' : 'Block access'}
</button>
{user.role === 'admin' && (
<div className="user-detail-helper">
Admins always have auto search/download access.
</div>
)}
</div>
</div>
<div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header">
<h2>Profile defaults</h2>
<p className="lede">Assign or clear an invite profile for this user.</p>
</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>
<div className="admin-panel user-detail-panel">
<div className="user-detail-panel-header">
<h2>Account expiry</h2>
<p className="lede">Set a specific expiry date/time for this user account.</p>
</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>
</div>
</div>
</div>
</>
)}
</section>
</AdminShell>

View File

@@ -14,9 +14,6 @@ type AdminUser = {
lastLoginAt?: string | null
isBlocked?: boolean
autoSearchEnabled?: boolean
profileId?: number | null
expiresAt?: string | null
isExpired?: boolean
stats?: UserStats
}
@@ -46,13 +43,6 @@ const formatLastRequest = (value?: string | null) => {
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 = {
total: 0,
ready: 0,
@@ -82,7 +72,6 @@ export default function UsersPage() {
const [users, setUsers] = useState<AdminUser[]>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [query, setQuery] = useState('')
const [jellyseerrSyncStatus, setJellyseerrSyncStatus] = useState<string | null>(null)
const [jellyseerrSyncBusy, setJellyseerrSyncBusy] = useState(false)
const [jellyseerrResyncBusy, setJellyseerrResyncBusy] = useState(false)
@@ -114,12 +103,6 @@ export default function UsersPage() {
lastLoginAt: user.last_login_at ?? null,
isBlocked: Boolean(user.is_blocked),
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),
stats: normalizeStats(user.stats ?? emptyStats),
}))
@@ -136,6 +119,44 @@ export default function UsersPage() {
}
}
const toggleUserBlock = async (username: string, blocked: boolean) => {
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(username)}/${blocked ? 'block' : 'unblock'}`,
{ method: 'POST' }
)
if (!response.ok) {
throw new Error('Update failed')
}
await loadUsers()
} catch (err) {
console.error(err)
setError('Could not update user access.')
}
}
const updateUserRole = async (username: string, role: string) => {
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(username)}/role`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role }),
}
)
if (!response.ok) {
throw new Error('Update failed')
}
await loadUsers()
} catch (err) {
console.error(err)
setError('Could not update user role.')
}
}
const syncJellyseerrUsers = async () => {
setJellyseerrSyncStatus(null)
setJellyseerrSyncBusy(true)
@@ -231,35 +252,13 @@ export default function UsersPage() {
const nonAdminUsers = users.filter((user) => user.role !== 'admin')
const autoSearchEnabledCount = nonAdminUsers.filter((user) => user.autoSearchEnabled !== false).length
const blockedCount = users.filter((user) => user.isBlocked).length
const expiredCount = users.filter((user) => user.isExpired).length
const adminCount = users.filter((user) => user.role === 'admin').length
const normalizedQuery = query.trim().toLowerCase()
const filteredUsers = normalizedQuery
? users.filter((user) => {
const fields = [
user.username,
user.role,
user.authProvider || '',
user.profileId != null ? String(user.profileId) : '',
]
return fields.some((field) => field.toLowerCase().includes(normalizedQuery))
})
: users
const filteredCountLabel =
filteredUsers.length === users.length
? `${users.length} users`
: `${filteredUsers.length} of ${users.length} users`
return (
<AdminShell
title="Users"
subtitle="Directory, access status, and request activity."
subtitle="Manage who can use Magent."
actions={
<div className="admin-inline-actions">
<button type="button" className="ghost-button" onClick={() => router.push('/admin/invites')}>
Invite management
</button>
<>
<button type="button" onClick={loadUsers}>
Reload list
</button>
@@ -269,68 +268,12 @@ export default function UsersPage() {
<button type="button" onClick={resyncJellyseerrUsers} disabled={jellyseerrResyncBusy}>
{jellyseerrResyncBusy ? 'Resyncing Jellyseerr users...' : 'Resync Jellyseerr users'}
</button>
</div>
</>
}
>
<section className="admin-section">
{error && <div className="error-banner">{error}</div>}
{jellyseerrSyncStatus && <div className="status-banner">{jellyseerrSyncStatus}</div>}
<div className="admin-summary-grid user-summary-grid">
<div className="admin-summary-tile">
<span className="label">Total users</span>
<strong>{users.length}</strong>
<small>{adminCount} admin</small>
</div>
<div className="admin-summary-tile">
<span className="label">Auto search</span>
<strong>{autoSearchEnabledCount}</strong>
<small>of {nonAdminUsers.length} non-admin users</small>
</div>
<div className="admin-summary-tile">
<span className="label">Blocked</span>
<strong>{blockedCount}</strong>
<small>{blockedCount ? 'Needs review' : 'No blocked users'}</small>
</div>
<div className="admin-summary-tile">
<span className="label">Expired</span>
<strong>{expiredCount}</strong>
<small>{expiredCount ? 'Access expired' : 'No expiries'}</small>
</div>
</div>
<div className="user-directory-control-grid">
<div className="admin-panel user-directory-search-panel">
<div className="user-directory-panel-header">
<div>
<h2>Directory search</h2>
<p className="lede">
Filter by username, role, login provider, or assigned profile.
</p>
</div>
<span className="small-pill">{filteredCountLabel}</span>
</div>
<div className="user-directory-toolbar">
<div className="user-directory-search">
<label>
<span className="user-bulk-label">Search users</span>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search username, login type, role, profile…"
/>
</label>
</div>
</div>
</div>
<div className="admin-panel user-directory-bulk-panel">
<div className="user-directory-panel-header">
<div>
<h2>Bulk controls</h2>
<p className="lede">
Auto search/download can be enabled or disabled for all non-admin users.
</p>
</div>
</div>
<div className="user-bulk-toolbar">
<div className="user-bulk-summary">
<strong>Auto search/download</strong>
@@ -356,69 +299,54 @@ export default function UsersPage() {
</button>
</div>
</div>
</div>
</div>
{filteredUsers.length === 0 ? (
{users.length === 0 ? (
<div className="status-banner">No users found yet.</div>
) : (
<div className="user-directory-list">
<div className="user-directory-header">
<span>User</span>
<span>Access</span>
<span>Requests</span>
<span>Activity</span>
</div>
{filteredUsers.map((user) => (
<div className="user-grid">
{users.map((user) => (
<Link
key={user.username}
className="user-directory-row"
className="user-grid-card"
href={`/users/${user.id}`}
>
<div className="user-directory-cell user-directory-cell--identity">
<div className="user-directory-title-row">
<div className="user-grid-header">
<div>
<strong>{user.username}</strong>
<span className="user-grid-meta">{user.role}</span>
</div>
<div className="user-directory-subtext">
Login: {user.authProvider || 'local'} Profile: {user.profileId ?? 'None'}
</div>
</div>
<div className="user-directory-cell">
<div className="user-directory-pill-row">
<span className={`user-grid-pill ${user.isBlocked ? 'is-blocked' : ''}`}>
{user.isBlocked ? 'Blocked' : 'Active'}
</span>
<span
className={`user-grid-pill ${user.autoSearchEnabled === false ? 'is-disabled' : ''}`}
>
Auto {user.autoSearchEnabled === false ? 'Off' : 'On'}
</span>
<span className={`user-grid-pill ${user.isExpired ? 'is-blocked' : ''}`}>
{user.expiresAt ? (user.isExpired ? 'Expired' : 'Expiry set') : 'No expiry'}
</div>
<div className="user-grid-subpills">
<span className={`user-grid-pill ${user.autoSearchEnabled === false ? 'is-disabled' : ''}`}>
Auto search {user.autoSearchEnabled === false ? 'Off' : 'On'}
</span>
</div>
<div className="user-directory-subtext">
{user.expiresAt ? `Expires: ${formatExpiry(user.expiresAt)}` : 'No account expiry'}
<div className="user-grid-stats">
<div>
<span className="label">Total</span>
<span className="value">{user.stats?.total ?? 0}</span>
</div>
<div>
<span className="label">Ready</span>
<span className="value">{user.stats?.ready ?? 0}</span>
</div>
<div>
<span className="label">Pending</span>
<span className="value">{user.stats?.pending ?? 0}</span>
</div>
<div>
<span className="label">In progress</span>
<span className="value">{user.stats?.in_progress ?? 0}</span>
</div>
</div>
<div className="user-directory-cell">
<div className="user-directory-stats-inline">
<span><strong>{user.stats?.total ?? 0}</strong> total</span>
<span><strong>{user.stats?.ready ?? 0}</strong> ready</span>
<span><strong>{user.stats?.pending ?? 0}</strong> pending</span>
<span><strong>{user.stats?.in_progress ?? 0}</strong> in progress</span>
</div>
</div>
<div className="user-directory-cell">
<div className="user-directory-subtext">
Last login: {formatLastLogin(user.lastLoginAt)}
</div>
<div className="user-directory-subtext">
<div className="user-grid-footer">
<span className="meta">Login: {user.authProvider || 'local'}</span>
<span className="meta">Last login: {formatLastLogin(user.lastLoginAt)}</span>
<span className="meta">
Last request: {formatLastRequest(user.stats?.last_request_at)}
</div>
</div>
<div className="user-directory-row-chevron" aria-hidden="true">
Open
</span>
</div>
</Link>
))}