diff --git a/README.md b/README.md index d1e4aa5..66f8744 100644 --- a/README.md +++ b/README.md @@ -64,10 +64,10 @@ QBIT_URL="http://localhost:8080" QBIT_USERNAME="..." QBIT_PASSWORD="..." SQLITE_PATH="data/magent.db" -JWT_SECRET="change-me" +JWT_SECRET="replace-with-a-long-random-secret" JWT_EXP_MINUTES="720" -ADMIN_USERNAME="admin" -ADMIN_PASSWORD="adminadmin" +ADMIN_USERNAME="set-a-real-admin-username" +ADMIN_PASSWORD="set-a-long-unique-admin-password" ``` ## Screenshots @@ -112,10 +112,10 @@ $env:QBIT_URL="http://localhost:8080" $env:QBIT_USERNAME="..." $env:QBIT_PASSWORD="..." $env:SQLITE_PATH="data/magent.db" -$env:JWT_SECRET="change-me" +$env:JWT_SECRET="replace-with-a-long-random-secret" $env:JWT_EXP_MINUTES="720" -$env:ADMIN_USERNAME="admin" -$env:ADMIN_PASSWORD="adminadmin" +$env:ADMIN_USERNAME="set-a-real-admin-username" +$env:ADMIN_PASSWORD="set-a-long-unique-admin-password" ``` ### Frontend (Next.js) diff --git a/backend/app/config.py b/backend/app/config.py index 902d658..642fb9f 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -8,10 +8,10 @@ class Settings(BaseSettings): app_name: str = "Magent" cors_allow_origin: str = "http://localhost:3000" sqlite_path: str = Field(default="data/magent.db", validation_alias=AliasChoices("SQLITE_PATH")) - jwt_secret: str = Field(default="change-me", validation_alias=AliasChoices("JWT_SECRET")) + jwt_secret: Optional[str] = Field(default=None, validation_alias=AliasChoices("JWT_SECRET")) jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES")) - admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME")) - admin_password: str = Field(default="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD")) + admin_username: Optional[str] = Field(default=None, validation_alias=AliasChoices("ADMIN_USERNAME")) + admin_password: Optional[str] = Field(default=None, validation_alias=AliasChoices("ADMIN_PASSWORD")) log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL")) log_file: str = Field(default="data/magent.log", validation_alias=AliasChoices("LOG_FILE")) requests_sync_ttl_minutes: int = Field( @@ -102,7 +102,7 @@ class Settings(BaseSettings): ) discord_webhook_url: Optional[str] = Field( - default="https://discord.com/api/webhooks/1464141924775629033/O_rvCAmIKowR04tyAN54IuMPcQFEiT-ustU3udDaMTlF62PmoI6w4-52H3ZQcjgHQOgt", + default=None, validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"), ) diff --git a/backend/app/db.py b/backend/app/db.py index 72cac98..2272ee4 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -331,6 +331,8 @@ def verify_user_password(username: str, password: str) -> Optional[Dict[str, Any user = get_user_by_username(username) if not user: return None + if user.get("auth_provider") != "local": + return None if not verify_password(password, user["password_hash"]): return None return user diff --git a/backend/app/main.py b/backend/app/main.py index 4d9a876..d714c6d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,4 +1,5 @@ import asyncio +from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -22,7 +23,48 @@ from .services.jellyfin_sync import run_daily_jellyfin_sync from .logging_config import configure_logging from .runtime import get_runtime_settings -app = FastAPI(title=settings.app_name) + +def validate_security_settings() -> None: + issues = [] + jwt_secret = (settings.jwt_secret or "").strip() + admin_username = (settings.admin_username or "").strip() + admin_password = (settings.admin_password or "").strip() + if not jwt_secret: + issues.append("JWT_SECRET is required") + if not admin_username: + issues.append("ADMIN_USERNAME is required") + if not admin_password: + issues.append("ADMIN_PASSWORD is required") + if jwt_secret == "change-me": + issues.append("JWT_SECRET must not use the default placeholder") + if admin_password == "adminadmin": + issues.append("ADMIN_PASSWORD must not use the default placeholder") + if issues: + raise RuntimeError("Unsafe Magent security configuration: " + "; ".join(issues)) + + +@asynccontextmanager +async def lifespan(_: FastAPI): + validate_security_settings() + init_db() + runtime = get_runtime_settings() + configure_logging(runtime.log_level, runtime.log_file) + background_tasks = [ + asyncio.create_task(run_daily_jellyfin_sync(), name="daily-jellyfin-sync"), + asyncio.create_task(startup_warmup_requests_cache(), name="startup-warmup-requests-cache"), + asyncio.create_task(run_requests_delta_loop(), name="requests-delta-loop"), + asyncio.create_task(run_daily_requests_full_sync(), name="daily-requests-full-sync"), + asyncio.create_task(run_daily_db_cleanup(), name="daily-db-cleanup"), + ] + try: + yield + finally: + for task in background_tasks: + task.cancel() + await asyncio.gather(*background_tasks, return_exceptions=True) + + +app = FastAPI(title=settings.app_name, lifespan=lifespan) app.add_middleware( CORSMiddleware, @@ -37,17 +79,6 @@ app.add_middleware( async def health() -> dict: return {"status": "ok"} -@app.on_event("startup") -async def startup() -> None: - init_db() - runtime = get_runtime_settings() - configure_logging(runtime.log_level, runtime.log_file) - asyncio.create_task(run_daily_jellyfin_sync()) - asyncio.create_task(startup_warmup_requests_cache()) - asyncio.create_task(run_requests_delta_loop()) - asyncio.create_task(run_daily_requests_full_sync()) - asyncio.create_task(run_daily_db_cleanup()) - app.include_router(requests_router) app.include_router(auth_router) diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index 97f0d67..bdec72d 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -1,3 +1,5 @@ +from secrets import token_urlsafe + from fastapi import APIRouter, HTTPException, status, Depends from fastapi.security import OAuth2PasswordRequestForm @@ -21,6 +23,12 @@ router = APIRouter(prefix="/auth", tags=["auth"]) async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict: user = verify_user_password(form_data.username, form_data.password) if not user: + existing = get_user_by_username(form_data.username) + if existing and existing.get("auth_provider") != "local": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Use the {existing.get('auth_provider')} sign-in flow for this account", + ) raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials") if user.get("is_blocked"): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked") @@ -45,10 +53,12 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc if not isinstance(response, dict) or not response.get("User"): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials") - create_user_if_missing(form_data.username, "jellyfin-user", role="user", auth_provider="jellyfin") + create_user_if_missing(form_data.username, token_urlsafe(32), role="user", auth_provider="jellyfin") user = get_user_by_username(form_data.username) if user and user.get("is_blocked"): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked") + if user and user.get("auth_provider") == "jellyfin": + set_user_password(form_data.username, token_urlsafe(32)) try: users = await client.get_users() if isinstance(users, list): @@ -57,7 +67,7 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di continue name = user.get("Name") if isinstance(name, str) and name: - create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin") + create_user_if_missing(name, token_urlsafe(32), role="user", auth_provider="jellyfin") except Exception: pass token = create_access_token(form_data.username, "user") @@ -78,10 +88,12 @@ async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) -> raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc if not isinstance(response, dict): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyseerr credentials") - create_user_if_missing(form_data.username, "jellyseerr-user", role="user", auth_provider="jellyseerr") + create_user_if_missing(form_data.username, token_urlsafe(32), role="user", auth_provider="jellyseerr") user = get_user_by_username(form_data.username) if user and user.get("is_blocked"): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked") + if user and user.get("auth_provider") == "jellyseerr": + set_user_password(form_data.username, token_urlsafe(32)) token = create_access_token(form_data.username, "user") set_last_login(form_data.username) return {"access_token": token, "token_type": "bearer", "user": {"username": form_data.username, "role": "user"}} diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index 015ef47..9d8796b 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -939,15 +939,15 @@ async def _ensure_request_access( ) -> None: if user.get("role") == "admin": return - runtime = get_runtime_settings() - mode = (runtime.requests_data_source or "prefer_cache").lower() cached = get_request_cache_payload(request_id) - if mode != "always_js" and cached is not None: - logger.debug("access cache hit: request_id=%s mode=%s", request_id, mode) + if cached is not None: + logger.debug("access cache hit: request_id=%s", request_id) if _request_matches_user(cached, user.get("username", "")): return raise HTTPException(status_code=403, detail="Request not accessible for this user") - logger.debug("access cache miss: request_id=%s mode=%s", request_id, mode) + if not client.configured(): + raise HTTPException(status_code=403, detail="Request access cannot be verified") + logger.debug("access cache miss: request_id=%s", request_id) details = await _get_request_details(client, request_id) if details is None or not _request_matches_user(details, user.get("username", "")): raise HTTPException(status_code=403, detail="Request not accessible for this user") @@ -1067,8 +1067,7 @@ async def _resolve_root_folder_path(client: Any, root_folder: str, service_name: async def get_snapshot(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> Snapshot: runtime = get_runtime_settings() client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) - if client.configured(): - await _ensure_request_access(client, int(request_id), user) + await _ensure_request_access(client, int(request_id), user) return await build_snapshot(request_id) @@ -1327,8 +1326,7 @@ async def search_requests( async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> TriageResult: runtime = get_runtime_settings() client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) - if client.configured(): - await _ensure_request_access(client, int(request_id), user) + await _ensure_request_access(client, int(request_id), user) snapshot = await build_snapshot(request_id) return triage_snapshot(snapshot) @@ -1337,8 +1335,7 @@ async def ai_triage(request_id: str, user: Dict[str, str] = Depends(get_current_ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict: runtime = get_runtime_settings() client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) - if client.configured(): - await _ensure_request_access(client, int(request_id), user) + await _ensure_request_access(client, int(request_id), user) snapshot = await build_snapshot(request_id) prowlarr_results: List[Dict[str, Any]] = [] prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key) @@ -1368,8 +1365,7 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict: runtime = get_runtime_settings() client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) - if client.configured(): - await _ensure_request_access(client, int(request_id), user) + await _ensure_request_access(client, int(request_id), user) snapshot = await build_snapshot(request_id) arr_item = snapshot.raw.get("arr", {}).get("item") if not isinstance(arr_item, dict): @@ -1418,8 +1414,7 @@ async def action_search_auto(request_id: str, user: Dict[str, str] = Depends(get async def action_resume(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict: runtime = get_runtime_settings() client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) - if client.configured(): - await _ensure_request_access(client, int(request_id), user) + await _ensure_request_access(client, int(request_id), user) snapshot = await build_snapshot(request_id) queue = snapshot.raw.get("arr", {}).get("queue") download_ids = _download_ids(_queue_records(queue)) @@ -1465,8 +1460,7 @@ async def action_resume(request_id: str, user: Dict[str, str] = Depends(get_curr async def action_readd(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict: runtime = get_runtime_settings() client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) - if client.configured(): - await _ensure_request_access(client, int(request_id), user) + await _ensure_request_access(client, int(request_id), user) snapshot = await build_snapshot(request_id) jelly = snapshot.raw.get("jellyseerr") or {} media = jelly.get("media") or {} @@ -1578,8 +1572,7 @@ async def request_history( ) -> dict: runtime = get_runtime_settings() client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) - if client.configured(): - await _ensure_request_access(client, int(request_id), user) + await _ensure_request_access(client, int(request_id), user) snapshots = await asyncio.to_thread(get_recent_snapshots, request_id, limit) return {"snapshots": snapshots} @@ -1590,8 +1583,7 @@ async def request_actions( ) -> dict: runtime = get_runtime_settings() client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) - if client.configured(): - await _ensure_request_access(client, int(request_id), user) + await _ensure_request_access(client, int(request_id), user) actions = await asyncio.to_thread(get_recent_actions, request_id, limit) return {"actions": actions} @@ -1602,8 +1594,7 @@ async def action_grab( ) -> dict: runtime = get_runtime_settings() client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) - if client.configured(): - await _ensure_request_access(client, int(request_id), user) + await _ensure_request_access(client, int(request_id), user) snapshot = await build_snapshot(request_id) guid = payload.get("guid") indexer_id = payload.get("indexerId") diff --git a/backend/app/routers/status.py b/backend/app/routers/status.py index 692dde1..6898178 100644 --- a/backend/app/routers/status.py +++ b/backend/app/routers/status.py @@ -1,3 +1,4 @@ +import asyncio from typing import Any, Dict import httpx from fastapi import APIRouter, Depends @@ -38,53 +39,45 @@ async def services_status() -> Dict[str, Any]: ) jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) - services = [] - services.append( - await _check( + checks = [ + _check( "Jellyseerr", jellyseerr.configured(), lambda: jellyseerr.get_recent_requests(take=1, skip=0), - ) - ) - services.append( - await _check( + ), + _check( "Sonarr", sonarr.configured(), sonarr.get_system_status, - ) - ) - services.append( - await _check( + ), + _check( "Radarr", radarr.configured(), radarr.get_system_status, - ) - ) - prowlarr_status = await _check( - "Prowlarr", - prowlarr.configured(), - prowlarr.get_health, - ) + ), + _check( + "Prowlarr", + prowlarr.configured(), + prowlarr.get_health, + ), + _check( + "qBittorrent", + qbittorrent.configured(), + qbittorrent.get_app_version, + ), + _check( + "Jellyfin", + jellyfin.configured(), + jellyfin.get_system_info, + ), + ] + services = await asyncio.gather(*checks) + prowlarr_status = next(service for service in services if service["name"] == "Prowlarr") if prowlarr_status.get("status") == "up": health = prowlarr_status.get("detail") if isinstance(health, list) and health: prowlarr_status["status"] = "degraded" prowlarr_status["message"] = "Health warnings" - services.append(prowlarr_status) - services.append( - await _check( - "qBittorrent", - qbittorrent.configured(), - qbittorrent.get_app_version, - ) - ) - services.append( - await _check( - "Jellyfin", - jellyfin.configured(), - jellyfin.get_system_info, - ) - ) overall = "up" if any(s.get("status") == "down" for s in services): diff --git a/backend/app/security.py b/backend/app/security.py index 5632c8b..5871735 100644 --- a/backend/app/security.py +++ b/backend/app/security.py @@ -19,6 +19,8 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: def create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str: + if not settings.jwt_secret: + raise ValueError("JWT_SECRET is not configured") minutes = expires_minutes or settings.jwt_exp_minutes expires = datetime.now(timezone.utc) + timedelta(minutes=minutes) payload: Dict[str, Any] = {"sub": subject, "role": role, "exp": expires} @@ -26,6 +28,8 @@ def create_access_token(subject: str, role: str, expires_minutes: Optional[int] def decode_token(token: str) -> Dict[str, Any]: + if not settings.jwt_secret: + raise ValueError("JWT_SECRET is not configured") return jwt.decode(token, settings.jwt_secret, algorithms=[_ALGORITHM]) diff --git a/frontend/app/admin/SettingsPage.tsx b/frontend/app/admin/SettingsPage.tsx index 2db337d..ca027e8 100644 --- a/frontend/app/admin/SettingsPage.tsx +++ b/frontend/app/admin/SettingsPage.tsx @@ -2,7 +2,14 @@ import { useEffect, useMemo, useState } from 'react' import { useRouter } from 'next/navigation' -import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' +import { + authFetch, + authFetchOrThrow, + ForbiddenError, + getApiBase, + getToken, + UnauthorizedError, +} from '../lib/auth' import AdminShell from '../ui/AdminShell' type AdminSetting = { @@ -109,17 +116,8 @@ export default function SettingsPage({ section }: SettingsPageProps) { const loadSettings = async () => { const baseUrl = getApiBase() - const response = await authFetch(`${baseUrl}/admin/settings`) + const response = await authFetchOrThrow(`${baseUrl}/admin/settings`) if (!response.ok) { - if (response.status === 401) { - clearToken() - router.push('/login') - return - } - if (response.status === 403) { - router.push('/') - return - } throw new Error('Failed to load settings') } const data = await response.json() @@ -199,6 +197,14 @@ export default function SettingsPage({ section }: SettingsPageProps) { await loadArtworkPrefetchStatus() } } catch (err) { + if (err instanceof UnauthorizedError) { + router.push('/login') + return + } + if (err instanceof ForbiddenError) { + router.push('/') + return + } console.error(err) setStatus('Could not load admin settings.') } finally { diff --git a/frontend/app/feedback/page.tsx b/frontend/app/feedback/page.tsx index ddb3936..ed76558 100644 --- a/frontend/app/feedback/page.tsx +++ b/frontend/app/feedback/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' -import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' +import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth' type Profile = { username?: string @@ -24,15 +24,17 @@ export default function FeedbackPage() { const load = async () => { try { const baseUrl = getApiBase() - const response = await authFetch(`${baseUrl}/auth/me`) + const response = await authFetchOrThrow(`${baseUrl}/auth/me`) if (!response.ok) { - clearToken() - router.push('/login') - return + throw new Error('Could not load profile.') } const data = await response.json() setProfile({ username: data?.username }) } catch (error) { + if (error instanceof UnauthorizedError) { + router.push('/login') + return + } console.error(error) } } @@ -49,7 +51,7 @@ export default function FeedbackPage() { setSubmitting(true) try { const baseUrl = getApiBase() - const response = await authFetch(`${baseUrl}/feedback`, { + const response = await authFetchOrThrow(`${baseUrl}/feedback`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -58,17 +60,16 @@ export default function FeedbackPage() { }), }) if (!response.ok) { - if (response.status === 401) { - clearToken() - router.push('/login') - return - } const text = await response.text() throw new Error(text || `Request failed: ${response.status}`) } setMessage('') setStatus('Thanks! Your message has been sent.') } catch (error) { + if (error instanceof UnauthorizedError) { + router.push('/login') + return + } console.error(error) setStatus('That did not send. Please try again.') } finally { diff --git a/frontend/app/lib/auth.ts b/frontend/app/lib/auth.ts index 4e9bc55..b9f7bf3 100644 --- a/frontend/app/lib/auth.ts +++ b/frontend/app/lib/auth.ts @@ -23,3 +23,37 @@ export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => { } return fetch(input, { ...init, headers }) } + +export class UnauthorizedError extends Error { + constructor() { + super('Unauthorized') + this.name = 'UnauthorizedError' + } +} + +export class ForbiddenError extends Error { + constructor() { + super('Forbidden') + this.name = 'ForbiddenError' + } +} + +export const authFetchOrThrow = async (input: RequestInfo | URL, init?: RequestInit) => { + const response = await authFetch(input, init) + if (response.status === 401) { + clearToken() + throw new UnauthorizedError() + } + if (response.status === 403) { + throw new ForbiddenError() + } + return response +} + +export const readResponseText = async (response: Response) => { + try { + return (await response.text()).trim() + } catch { + return '' + } +} diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index ac85ce3..569c01f 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -2,7 +2,13 @@ import { useRouter } from 'next/navigation' import { useEffect, useState } from 'react' -import { authFetch, getApiBase, getToken, clearToken } from './lib/auth' +import { + authFetchOrThrow, + ForbiddenError, + getApiBase, + getToken, + UnauthorizedError, +} from './lib/auth' export default function HomePage() { const router = useRouter() @@ -52,13 +58,8 @@ export default function HomePage() { setRecentError(null) try { const baseUrl = getApiBase() - const meResponse = await authFetch(`${baseUrl}/auth/me`) + const meResponse = await authFetchOrThrow(`${baseUrl}/auth/me`) if (!meResponse.ok) { - if (meResponse.status === 401) { - clearToken() - router.push('/login') - return - } throw new Error(`Auth failed: ${meResponse.status}`) } const me = await meResponse.json() @@ -66,15 +67,10 @@ export default function HomePage() { setRole(userRole) setAuthReady(true) const take = userRole === 'admin' ? 50 : 6 - const response = await authFetch( + const response = await authFetchOrThrow( `${baseUrl}/requests/recent?take=${take}&days=${recentDays}` ) if (!response.ok) { - if (response.status === 401) { - clearToken() - router.push('/login') - return - } throw new Error(`Recent requests failed: ${response.status}`) } const data = await response.json() @@ -99,6 +95,14 @@ export default function HomePage() { ) } } catch (error) { + if (error instanceof UnauthorizedError) { + router.push('/login') + return + } + if (error instanceof ForbiddenError) { + router.push('/') + return + } console.error(error) setRecentError('Recent requests are not available right now.') } finally { @@ -107,7 +111,7 @@ export default function HomePage() { } load() - }, [recentDays]) + }, [recentDays, router]) useEffect(() => { if (!authReady) { @@ -118,18 +122,21 @@ export default function HomePage() { setServicesError(null) try { const baseUrl = getApiBase() - const response = await authFetch(`${baseUrl}/status/services`) + const response = await authFetchOrThrow(`${baseUrl}/status/services`) if (!response.ok) { - if (response.status === 401) { - clearToken() - router.push('/login') - return - } throw new Error(`Service status failed: ${response.status}`) } const data = await response.json() setServicesStatus(data) } catch (error) { + if (error instanceof UnauthorizedError) { + router.push('/login') + return + } + if (error instanceof ForbiddenError) { + router.push('/') + return + } console.error(error) setServicesError('Service status is not available right now.') } finally { @@ -145,13 +152,8 @@ export default function HomePage() { const runSearch = async (term: string) => { try { const baseUrl = getApiBase() - const response = await authFetch(`${baseUrl}/requests/search?query=${encodeURIComponent(term)}`) + const response = await authFetchOrThrow(`${baseUrl}/requests/search?query=${encodeURIComponent(term)}`) if (!response.ok) { - if (response.status === 401) { - clearToken() - router.push('/login') - return - } throw new Error(`Search failed: ${response.status}`) } const data = await response.json() @@ -168,6 +170,14 @@ export default function HomePage() { setSearchError(null) } } catch (error) { + if (error instanceof UnauthorizedError) { + router.push('/login') + return + } + if (error instanceof ForbiddenError) { + router.push('/') + return + } console.error(error) setSearchError('Search failed. Try a request ID instead.') setSearchResults([]) diff --git a/frontend/app/profile/page.tsx b/frontend/app/profile/page.tsx index f1beac7..9dbf8b3 100644 --- a/frontend/app/profile/page.tsx +++ b/frontend/app/profile/page.tsx @@ -2,7 +2,13 @@ import { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' -import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' +import { + authFetchOrThrow, + getApiBase, + getToken, + readResponseText, + UnauthorizedError, +} from '../lib/auth' type ProfileInfo = { username: string @@ -26,9 +32,8 @@ export default function ProfilePage() { const load = async () => { try { const baseUrl = getApiBase() - const response = await authFetch(`${baseUrl}/auth/me`) + const response = await authFetchOrThrow(`${baseUrl}/auth/me`) if (!response.ok) { - clearToken() router.push('/login') return } @@ -39,6 +44,10 @@ export default function ProfilePage() { auth_provider: data?.auth_provider ?? 'local', }) } catch (err) { + if (err instanceof UnauthorizedError) { + router.push('/login') + return + } console.error(err) setStatus('Could not load your profile.') } finally { @@ -57,7 +66,7 @@ export default function ProfilePage() { } try { const baseUrl = getApiBase() - const response = await authFetch(`${baseUrl}/auth/password`, { + const response = await authFetchOrThrow(`${baseUrl}/auth/password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -66,13 +75,17 @@ export default function ProfilePage() { }), }) if (!response.ok) { - const text = await response.text() + const text = await readResponseText(response) throw new Error(text || 'Update failed') } setCurrentPassword('') setNewPassword('') setStatus('Password updated.') } catch (err) { + if (err instanceof UnauthorizedError) { + router.push('/login') + return + } console.error(err) setStatus('Could not update password. Check your current password.') } diff --git a/frontend/app/requests/[id]/page.tsx b/frontend/app/requests/[id]/page.tsx index 73cc0a1..a7cd3ce 100644 --- a/frontend/app/requests/[id]/page.tsx +++ b/frontend/app/requests/[id]/page.tsx @@ -2,7 +2,15 @@ import { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' -import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth' +import { + authFetch, + authFetchOrThrow, + clearToken, + getApiBase, + getToken, + readResponseText, + UnauthorizedError, +} from '../../lib/auth' type TimelineHop = { service: string @@ -502,16 +510,11 @@ export default function RequestTimelinePage({ params }: { params: { id: string } setModalMessage(null) } try { - const response = await authFetch(`${baseUrl}/requests/${snapshot.request_id}/${path}`, { + const response = await authFetchOrThrow(`${baseUrl}/requests/${snapshot.request_id}/${path}`, { method: 'POST', }) if (!response.ok) { - if (response.status === 401) { - clearToken() - router.push('/login') - return - } - const text = await response.text() + const text = await readResponseText(response) throw new Error(text || `Request failed: ${response.status}`) } const data = await response.json() @@ -538,6 +541,10 @@ export default function RequestTimelinePage({ params }: { params: { id: string } setModalMessage(message) } } catch (error) { + if (error instanceof UnauthorizedError) { + router.push('/login') + return + } console.error(error) const message = `${action.label} failed. Check the backend logs.` setActionMessage(message) @@ -582,7 +589,7 @@ export default function RequestTimelinePage({ params }: { params: { id: string } if (!ok) return const baseUrl = getApiBase() try { - const response = await authFetch( + const response = await authFetchOrThrow( `${baseUrl}/requests/${snapshot.request_id}/actions/grab`, { method: 'POST', @@ -595,17 +602,16 @@ export default function RequestTimelinePage({ params }: { params: { id: string } } ) if (!response.ok) { - if (response.status === 401) { - clearToken() - router.push('/login') - return - } - const text = await response.text() + const text = await readResponseText(response) throw new Error(text || `Request failed: ${response.status}`) } setActionMessage('Download sent to Sonarr/Radarr.') setModalMessage('Download sent to Sonarr/Radarr.') } catch (error) { + if (error instanceof UnauthorizedError) { + router.push('/login') + return + } console.error(error) const message = 'Download failed. Check the logs.' setActionMessage(message) diff --git a/frontend/app/ui/HeaderIdentity.tsx b/frontend/app/ui/HeaderIdentity.tsx index cbb8054..088b13d 100644 --- a/frontend/app/ui/HeaderIdentity.tsx +++ b/frontend/app/ui/HeaderIdentity.tsx @@ -1,7 +1,7 @@ 'use client' import { useEffect, useState } from 'react' -import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' +import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth' export default function HeaderIdentity() { const [identity, setIdentity] = useState(null) @@ -16,9 +16,8 @@ export default function HeaderIdentity() { const load = async () => { try { const baseUrl = getApiBase() - const response = await authFetch(`${baseUrl}/auth/me`) + const response = await authFetchOrThrow(`${baseUrl}/auth/me`) if (!response.ok) { - clearToken() setIdentity(null) return } @@ -27,6 +26,10 @@ export default function HeaderIdentity() { setIdentity(`${data.username}${data.role ? ` (${data.role})` : ''}`) } } catch (err) { + if (err instanceof UnauthorizedError) { + setIdentity(null) + return + } console.error(err) setIdentity(null) } diff --git a/frontend/app/users/page.tsx b/frontend/app/users/page.tsx index 0171d4a..677f762 100644 --- a/frontend/app/users/page.tsx +++ b/frontend/app/users/page.tsx @@ -2,7 +2,13 @@ import { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' -import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' +import { + authFetchOrThrow, + ForbiddenError, + getApiBase, + getToken, + UnauthorizedError, +} from '../lib/auth' import AdminShell from '../ui/AdminShell' type AdminUser = { @@ -29,17 +35,8 @@ export default function UsersPage() { const loadUsers = async () => { try { const baseUrl = getApiBase() - const response = await authFetch(`${baseUrl}/admin/users`) + const response = await authFetchOrThrow(`${baseUrl}/admin/users`) if (!response.ok) { - if (response.status === 401) { - clearToken() - router.push('/login') - return - } - if (response.status === 403) { - router.push('/') - return - } throw new Error('Could not load users.') } const data = await response.json() @@ -58,6 +55,14 @@ export default function UsersPage() { } setError(null) } catch (err) { + if (err instanceof UnauthorizedError) { + router.push('/login') + return + } + if (err instanceof ForbiddenError) { + router.push('/') + return + } console.error(err) setError('Could not load user list.') } finally { @@ -68,7 +73,7 @@ export default function UsersPage() { const toggleUserBlock = async (username: string, blocked: boolean) => { try { const baseUrl = getApiBase() - const response = await authFetch( + const response = await authFetchOrThrow( `${baseUrl}/admin/users/${encodeURIComponent(username)}/${blocked ? 'block' : 'unblock'}`, { method: 'POST' } ) @@ -77,6 +82,14 @@ export default function UsersPage() { } await loadUsers() } catch (err) { + if (err instanceof UnauthorizedError) { + router.push('/login') + return + } + if (err instanceof ForbiddenError) { + router.push('/') + return + } console.error(err) setError('Could not update user access.') } @@ -85,7 +98,7 @@ export default function UsersPage() { const updateUserRole = async (username: string, role: string) => { try { const baseUrl = getApiBase() - const response = await authFetch( + const response = await authFetchOrThrow( `${baseUrl}/admin/users/${encodeURIComponent(username)}/role`, { method: 'POST', @@ -98,6 +111,14 @@ export default function UsersPage() { } await loadUsers() } catch (err) { + if (err instanceof UnauthorizedError) { + router.push('/login') + return + } + if (err instanceof ForbiddenError) { + router.push('/') + return + } console.error(err) setError('Could not update user role.') } diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index 6080add..4f11a03 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,2 +1,5 @@ /// /// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index d15de4f..b6225a4 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2019", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": false, "skipLibCheck": true, "strict": true, @@ -12,8 +16,20 @@ "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", - "incremental": true + "incremental": true, + "plugins": [ + { + "name": "next" + } + ] }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] }