From 955a16da6200ee8b7f281c0de2342ef7e62a32c2 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 24 Jul 2026 20:50:51 +0000 Subject: [PATCH] feat(setup): Implement IP-based restrictions for superuser initialization This commit introduces a mechanism to restrict the first-time web setup of superusers to local/private networks by default. A new environment variable, DISPATCHARR_SETUP_ALLOWED_IP, allows specific public IPs to bypass this restriction. The changes include updates to the API views for handling setup requests, utility functions for IP validation, and enhancements to the frontend to inform users about setup restrictions. Additionally, tests have been added to ensure correct behavior under various scenarios, including IP validation and environment variable overrides. --- README.md | 2 + apps/accounts/api_views.py | 50 +++++- apps/accounts/tests/test_accounts.py | 102 ++++++++++- dispatcharr/utils.py | 70 +++++++- docker/docker-compose.aio.yml | 3 + docker/docker-compose.debug.yml | 3 + docker/docker-compose.dev.yml | 3 + docker/docker-compose.yml | 4 + docker/entrypoint.sh | 11 +- frontend/src/App.jsx | 16 +- frontend/src/api.js | 4 + .../src/components/forms/SuperuserForm.jsx | 170 ++++++++++++++---- .../forms/__tests__/SuperuserForm.test.jsx | 156 ++++++++++++---- frontend/src/pages/Login.jsx | 35 +++- frontend/src/pages/__tests__/Login.test.jsx | 20 ++- frontend/src/store/__tests__/auth.test.jsx | 24 ++- frontend/src/store/auth.jsx | 12 +- 17 files changed, 577 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index c3d7d603..2c47a91e 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,8 @@ docker run -d \ > Customize ports and volumes to fit your setup. +First-time web setup is limited to local/private networks by default. If you are installing on a VPS or otherwise reaching the UI from a public IP, either create the admin with `docker exec python manage.py createsuperuser`, or set `DISPATCHARR_SETUP_ALLOWED_IP` to your client IP (see comments in the compose files). The setup page also shows the IP Dispatcharr sees. + --- ### 🐋 Docker Compose Options diff --git a/apps/accounts/api_views.py b/apps/accounts/api_views.py index 7308bbe9..193843dc 100644 --- a/apps/accounts/api_views.py +++ b/apps/accounts/api_views.py @@ -12,7 +12,11 @@ from drf_spectacular.types import OpenApiTypes import json import secrets from .permissions import IsAdmin, Authenticated -from dispatcharr.utils import network_access_allowed +from dispatcharr.utils import ( + SETUP_ALLOWED_IP_ENV, + network_access_allowed, + setup_ip_allowed, +) from .models import User from .serializers import UserSerializer, GroupSerializer, PermissionSerializer @@ -21,6 +25,33 @@ from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView logger = logging.getLogger(__name__) +def _setup_status_payload(request, *, superuser_exists): + """Build initialize-superuser JSON including client IP / setup gate info.""" + payload = {"superuser_exists": superuser_exists} + if superuser_exists: + return payload + + allowed, client_ip = setup_ip_allowed(request) + payload["client_ip"] = client_ip + payload["setup_allowed"] = allowed + return payload + + +def _setup_forbidden_response(client_ip): + return JsonResponse( + { + "error": ( + "Web setup is limited to local networks by default. " + f"Set {SETUP_ALLOWED_IP_ENV} to your IP to allow setup from this " + "network, or create the account with: python manage.py createsuperuser" + ), + "client_ip": client_ip, + "setup_allowed": False, + }, + status=403, + ) + + class LoginRateThrottle(AnonRateThrottle): scope = "login" @@ -123,13 +154,21 @@ class TokenRefreshView(TokenRefreshView): return super().post(request, *args, **kwargs) -@csrf_exempt # In production, consider CSRF protection strategies or ensure this endpoint is only accessible when no superuser exists. +@csrf_exempt # Bootstrap only; POST is IP-gated and closes once an admin exists. def initialize_superuser(request): # If an admin-level user already exists, the system is configured if User.objects.filter(user_level__gte=10).exists(): - return JsonResponse({"superuser_exists": True}) + return JsonResponse(_setup_status_payload(request, superuser_exists=True)) if request.method == "POST": + allowed, client_ip = setup_ip_allowed(request) + if not allowed: + logger.info( + "initialize-superuser POST blocked by setup IP policy: ip=%s", + client_ip, + ) + return _setup_forbidden_response(client_ip) + try: data = json.loads(request.body) username = data.get("username") @@ -146,8 +185,9 @@ def initialize_superuser(request): return JsonResponse({"superuser_exists": True}) except Exception as e: return JsonResponse({"error": str(e)}, status=500) - # For GET requests, indicate no superuser exists - return JsonResponse({"superuser_exists": False}) + + # GET: no admin yet. Include client IP so the UI can help remote / VPS setups. + return JsonResponse(_setup_status_payload(request, superuser_exists=False)) # 🔹 1) Authentication APIs diff --git a/apps/accounts/tests/test_accounts.py b/apps/accounts/tests/test_accounts.py index 629207ba..4594d345 100644 --- a/apps/accounts/tests/test_accounts.py +++ b/apps/accounts/tests/test_accounts.py @@ -1,6 +1,7 @@ from django.test import TestCase from django.contrib.auth import get_user_model from rest_framework.test import APIClient +from unittest.mock import patch User = get_user_model() @@ -37,13 +38,19 @@ class InitializeSuperuserTests(TestCase): User.objects.create_user(username="regular", password="testpass123") response = self.client.get(self.url) self.assertEqual(response.status_code, 200) - self.assertFalse(response.json()["superuser_exists"]) + data = response.json() + self.assertFalse(data["superuser_exists"]) + self.assertIn("client_ip", data) + self.assertIn("setup_allowed", data) def test_returns_false_when_no_users_exist(self): """Empty database should return false""" response = self.client.get(self.url) self.assertEqual(response.status_code, 200) - self.assertFalse(response.json()["superuser_exists"]) + data = response.json() + self.assertFalse(data["superuser_exists"]) + # Django test client is loopback, so local setup should be allowed + self.assertTrue(data["setup_allowed"]) def test_create_superuser_when_none_exists(self): """POST should create superuser when none exists""" @@ -69,4 +76,93 @@ class InitializeSuperuserTests(TestCase): self.assertEqual(response.status_code, 200) self.assertTrue(response.json()["superuser_exists"]) # Should NOT have created a new user - self.assertFalse(User.objects.filter(username="newadmin").exists()) \ No newline at end of file + self.assertFalse(User.objects.filter(username="newadmin").exists()) + + def test_post_blocked_from_public_ip_by_default(self): + """Remote public IPs cannot complete web setup without the env override.""" + response = self.client.post( + self.url, + {"username": "newadmin", "password": "testpass123"}, + format="json", + REMOTE_ADDR="203.0.113.50", + ) + self.assertEqual(response.status_code, 403) + data = response.json() + self.assertEqual(data["client_ip"], "203.0.113.50") + self.assertFalse(data["setup_allowed"]) + self.assertFalse(User.objects.filter(username="newadmin").exists()) + + def test_get_reports_setup_not_allowed_for_public_ip(self): + response = self.client.get(self.url, REMOTE_ADDR="203.0.113.50") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertFalse(data["superuser_exists"]) + self.assertFalse(data["setup_allowed"]) + self.assertEqual(data["client_ip"], "203.0.113.50") + + def test_post_allowed_from_private_lan(self): + response = self.client.post( + self.url, + {"username": "lanadmin", "password": "testpass123"}, + format="json", + REMOTE_ADDR="192.168.1.50", + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(User.objects.filter(username="lanadmin", user_level=10).exists()) + + @patch.dict("os.environ", {"DISPATCHARR_SETUP_ALLOWED_IP": "203.0.113.50"}) + def test_post_allowed_for_env_override_ip(self): + response = self.client.post( + self.url, + {"username": "vpsadmin", "password": "testpass123"}, + format="json", + REMOTE_ADDR="203.0.113.50", + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(User.objects.filter(username="vpsadmin", user_level=10).exists()) + + @patch.dict("os.environ", {"DISPATCHARR_SETUP_ALLOWED_IP": "203.0.113.50"}) + def test_post_blocked_when_env_ip_does_not_match(self): + """When the override is set, only that exact IP may set up (not local).""" + response = self.client.post( + self.url, + {"username": "other", "password": "testpass123"}, + format="json", + REMOTE_ADDR="127.0.0.1", + ) + self.assertEqual(response.status_code, 403) + self.assertFalse(User.objects.filter(username="other").exists()) + + @patch.dict("os.environ", {"DISPATCHARR_SETUP_ALLOWED_IP": "203.0.113.50"}) + def test_x_real_ip_used_for_setup_gate(self): + response = self.client.post( + self.url, + {"username": "proxied", "password": "testpass123"}, + format="json", + REMOTE_ADDR="127.0.0.1", + HTTP_X_REAL_IP="203.0.113.50", + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(User.objects.filter(username="proxied", user_level=10).exists()) + + def test_post_allowed_for_ipv4_mapped_private_address(self): + """Proxies may present LAN clients as ::ffff:x.x.x.x.""" + response = self.client.post( + self.url, + {"username": "mapped", "password": "testpass123"}, + format="json", + REMOTE_ADDR="::ffff:192.168.1.50", + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(User.objects.filter(username="mapped", user_level=10).exists()) + + @patch.dict("os.environ", {"DISPATCHARR_SETUP_ALLOWED_IP": "not-an-ip"}) + def test_invalid_setup_allowed_ip_env_denies_post(self): + response = self.client.post( + self.url, + {"username": "badenv", "password": "testpass123"}, + format="json", + REMOTE_ADDR="127.0.0.1", + ) + self.assertEqual(response.status_code, 403) + self.assertFalse(User.objects.filter(username="badenv").exists()) \ No newline at end of file diff --git a/dispatcharr/utils.py b/dispatcharr/utils.py index 0e93baaf..f767c858 100644 --- a/dispatcharr/utils.py +++ b/dispatcharr/utils.py @@ -1,10 +1,29 @@ # dispatcharr/utils.py -import json import ipaddress -from django.http import JsonResponse +import logging +import os + from django.core.exceptions import ValidationError +from django.http import JsonResponse + from core.models import CoreSettings +logger = logging.getLogger(__name__) + +# Private / loopback ranges used as the default for M3U/EPG ACLs and +# first-time superuser setup (when DISPATCHARR_SETUP_ALLOWED_IP is unset). +LOCAL_NETWORK_CIDRS = [ + "127.0.0.0/8", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "::1/128", + "fc00::/7", + "fe80::/10", +] + +SETUP_ALLOWED_IP_ENV = "DISPATCHARR_SETUP_ALLOWED_IP" + def json_error_response(message, status=400): """Return a standardized error JSON response.""" @@ -32,13 +51,56 @@ def get_client_ip(request): return request.META.get("HTTP_X_REAL_IP") or request.META.get("REMOTE_ADDR") +def setup_ip_allowed(request): + """Whether this client may POST to initialize-superuser. + + Default: private / loopback IPv4 and IPv6 only. + If DISPATCHARR_SETUP_ALLOWED_IP is set, only that single IP is allowed + (for remote / VPS first-time web setup). + + Returns: + tuple[bool, str]: (allowed, client_ip_string) + """ + client_ip_str = get_client_ip(request) or "" + try: + client_ip = ipaddress.ip_address(client_ip_str) + except ValueError: + return False, client_ip_str + + # Some proxies present IPv4 clients as IPv4-mapped IPv6 (::ffff:x.x.x.x). + # Compare using the embedded IPv4 so private-range checks still work. + compare_ip = client_ip.ipv4_mapped if getattr(client_ip, "ipv4_mapped", None) else client_ip + + override = os.environ.get(SETUP_ALLOWED_IP_ENV, "").strip() + if override: + try: + override_ip = ipaddress.ip_address(override) + except ValueError: + logger.warning( + "Invalid %s=%r; denying initialize-superuser POST", + SETUP_ALLOWED_IP_ENV, + override, + ) + return False, client_ip_str + override_compare = ( + override_ip.ipv4_mapped + if getattr(override_ip, "ipv4_mapped", None) + else override_ip + ) + return compare_ip == override_compare, client_ip_str + + for cidr in LOCAL_NETWORK_CIDRS: + if compare_ip in ipaddress.ip_network(cidr): + return True, client_ip_str + return False, client_ip_str + + def network_access_allowed(request, settings_key, user=None): network_access = CoreSettings.get_network_access_settings() - local_cidrs = ["127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "::1/128", "fc00::/7", "fe80::/10"] # Set defaults based on endpoint type if settings_key == "M3U_EPG": # M3U/EPG endpoints: local IPv4 and IPv6 only by default - default_cidrs = local_cidrs + default_cidrs = LOCAL_NETWORK_CIDRS else: # Other endpoints: allow all by default default_cidrs = ["0.0.0.0/0", "::/0"] diff --git a/docker/docker-compose.aio.yml b/docker/docker-compose.aio.yml index d24ee041..9607aba3 100644 --- a/docker/docker-compose.aio.yml +++ b/docker/docker-compose.aio.yml @@ -15,6 +15,9 @@ services: - REDIS_HOST=localhost - CELERY_BROKER_URL=redis://localhost:6379/0 - DISPATCHARR_LOG_LEVEL=info + # First-time web setup from a public IP (optional). When unset, only + # local/private networks may POST to initialize-superuser. + #- DISPATCHARR_SETUP_ALLOWED_IP=203.0.113.50 # Legacy CPU Support (Optional) # Uncomment to enable legacy NumPy build for older CPUs (circa 2009) # that lack support for newer baseline CPU features diff --git a/docker/docker-compose.debug.yml b/docker/docker-compose.debug.yml index c576cfd1..fdde8cf3 100644 --- a/docker/docker-compose.debug.yml +++ b/docker/docker-compose.debug.yml @@ -18,6 +18,9 @@ services: - REDIS_HOST=localhost - CELERY_BROKER_URL=redis://localhost:6379/0 - DISPATCHARR_LOG_LEVEL=trace + # First-time web setup from a public IP (optional). When unset, only + # local/private networks may POST to initialize-superuser. + #- DISPATCHARR_SETUP_ALLOWED_IP=203.0.113.50 # Legacy CPU Support (Optional) # Uncomment to enable legacy NumPy build for older CPUs (circa 2009) # that lack support for newer baseline CPU features diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index e640e843..65fbba9e 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -18,6 +18,9 @@ services: - REDIS_HOST=localhost - CELERY_BROKER_URL=redis://localhost:6379/0 - DISPATCHARR_LOG_LEVEL=debug + # First-time web setup from a public IP (optional). When unset, only + # local/private networks may POST to initialize-superuser. + #- DISPATCHARR_SETUP_ALLOWED_IP=203.0.113.50 # Legacy CPU Support (Optional) # Uncomment to enable legacy NumPy build for older CPUs (circa 2009) # that lack support for newer baseline CPU features diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 58b6e4f5..245c6b1d 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -58,6 +58,10 @@ services: # Logging - DISPATCHARR_LOG_LEVEL=info + # First-time web setup from a public IP (optional). When unset, only + # local/private networks may POST to initialize-superuser. + #- DISPATCHARR_SETUP_ALLOWED_IP=203.0.113.50 + # Legacy CPU Support (Optional) # Uncomment to enable legacy NumPy build for older CPUs (circa 2009) # that lack support for newer baseline CPU features: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 505b64b5..d9574e13 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -183,11 +183,12 @@ variables=( CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY ) -# TLS variables are optional — only propagate when set to avoid noisy warnings -for _tls_var in POSTGRES_SSL POSTGRES_SSL_MODE POSTGRES_SSL_CA_CERT POSTGRES_SSL_CERT POSTGRES_SSL_KEY \ - REDIS_SSL REDIS_SSL_VERIFY REDIS_SSL_CA_CERT REDIS_SSL_CERT REDIS_SSL_KEY; do - if [ -n "${!_tls_var+x}" ]; then - variables+=("$_tls_var") +# Optional variables, only propagate when set to avoid noisy warnings +for _opt_var in POSTGRES_SSL POSTGRES_SSL_MODE POSTGRES_SSL_CA_CERT POSTGRES_SSL_CERT POSTGRES_SSL_KEY \ + REDIS_SSL REDIS_SSL_VERIFY REDIS_SSL_CA_CERT REDIS_SSL_CERT REDIS_SSL_KEY \ + DISPATCHARR_SETUP_ALLOWED_IP; do + if [ -n "${!_opt_var+x}" ]; then + variables+=("$_opt_var") fi done diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 659d8070..d05d59e4 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -43,11 +43,10 @@ const App = () => { const [open, setOpen] = useState(true); const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const isInitialized = useAuthStore((s) => s.isInitialized); - const setIsAuthenticated = useAuthStore((s) => s.setIsAuthenticated); const logout = useAuthStore((s) => s.logout); const initData = useAuthStore((s) => s.initData); const initializeAuth = useAuthStore((s) => s.initializeAuth); - const setSuperuserExists = useAuthStore((s) => s.setSuperuserExists); + const setSuperuserStatus = useAuthStore((s) => s.setSuperuserStatus); const authCheckStarted = useRef(false); const superuserCheckStarted = useRef(false); @@ -64,21 +63,22 @@ const App = () => { async function checkSuperuser() { try { const response = await API.fetchSuperUser(); - if (response && response.superuser_exists === false) { - setSuperuserExists(false); - } + setSuperuserStatus(response); } catch (error) { console.error('Error checking superuser status:', error); + // Preserve the existing fail-open UI behavior if the status check fails. + setSuperuserStatus({ superuser_exists: true }); // If authentication error, redirect to login if (error.status === 401) { - localStorage.removeItem('token'); + localStorage.removeItem('accessToken'); localStorage.removeItem('refreshToken'); + localStorage.removeItem('tokenExpiration'); window.location.href = '/login'; } } } checkSuperuser(); - }, [setSuperuserExists]); + }, [setSuperuserStatus]); // Authentication check useEffect(() => { @@ -170,7 +170,7 @@ const App = () => { } /> ) : ( - } /> + } /> )} { }); }; +function SetupHelpModal({ opened, onClose, clientIp }) { + const ip = clientIp || ''; + + return ( + + + + Web setup is limited to local networks by default. Dispatcharr + currently sees your connection as {ip}. + + + To allow web setup from this IP, set the environment variable and + restart, then reload this page: + + DISPATCHARR_SETUP_ALLOWED_IP={ip} + + Or create the admin account from the host with a management command: + +
+ + If running with Docker: + + + {`docker exec python manage.py createsuperuser`} + +
+
+ + If running locally: + + python manage.py createsuperuser +
+ + Replace {''} with your Docker container + name. After the account exists, this setup page will no longer be + available. + +
+
+ ); +} + function SuperuserForm() { const [formData, setFormData] = useState({ username: '', password: '', email: '', }); - const [_error, setError] = useState(''); - const setSuperuserExists = useAuthStore((s) => s.setSuperuserExists); + const setupClientIp = useAuthStore((s) => s.setupClientIp); + const initialSetupAllowed = useAuthStore((s) => s.setupAllowed); + const [clientIp, setClientIp] = useState(setupClientIp); + const [setupAllowed, setSetupAllowed] = useState( + initialSetupAllowed !== false + ); + const [setupHelpOpened, setSetupHelpOpened] = useState( + initialSetupAllowed === false + ); + const setSuperuserStatus = useAuthStore((s) => s.setSuperuserStatus); const fetchVersion = useSettingsStore((s) => s.fetchVersion); const storedVersion = useSettingsStore((s) => s.version); @@ -50,14 +110,19 @@ function SuperuserForm() { const handleSubmit = async (e) => { e.preventDefault(); try { - console.log(formData); const response = await createSuperUser(formData); - if (response.superuser_exists) { - setSuperuserExists(true); + if (response?.superuser_exists) { + setSuperuserStatus({ superuser_exists: true }); } } catch (err) { - console.log(err); - setError('Failed to create superuser.'); + const body = err?.body; + if (err?.status === 403 || body?.setup_allowed === false) { + if (body?.client_ip) { + setClientIp(body.client_ip); + } + setSetupAllowed(false); + setSetupHelpOpened(true); + } } }; @@ -88,41 +153,68 @@ function SuperuserForm() { Dispatcharr - Welcome! Create your Super User Account to get started. + {setupAllowed + ? 'Welcome! Create your Super User Account to get started.' + : 'Web setup from this network is not enabled yet.'} -
+ + {setupAllowed ? ( + + + + + + + + + setSetupHelpOpened(true)} + > + Remote setup help + + + + + +
+ ) : ( - - - - - - - + )} {storedVersion.version && ( )} + + setSetupHelpOpened(false)} + clientIp={clientIp} + /> ); } diff --git a/frontend/src/components/forms/__tests__/SuperuserForm.test.jsx b/frontend/src/components/forms/__tests__/SuperuserForm.test.jsx index 4c13a456..f24542d9 100644 --- a/frontend/src/components/forms/__tests__/SuperuserForm.test.jsx +++ b/frontend/src/components/forms/__tests__/SuperuserForm.test.jsx @@ -17,22 +17,51 @@ vi.mock('../../../store/settings', () => ({ default: vi.fn() })); // ── @mantine/core ────────────────────────────────────────────────────────────── vi.mock('@mantine/core', () => ({ - Button: ({ children, type, fullWidth }) => ( - ), Center: ({ children, style }) =>
{children}
, + Code: ({ children, block }) => + block ?
{children}
: {children}, Divider: ({ style }) =>
, + Group: ({ children }) =>
{children}
, Image: ({ src, alt }) => {alt}, + Modal: ({ children, opened, title, ...rest }) => + opened ? ( +
+ {title} + {children} +
+ ) : null, Paper: ({ children, style }) =>
{children}
, Stack: ({ children }) =>
{children}
, - Text: ({ children, size, color, align }) => ( - + Text: ({ children, size, color, align, weight, mb }) => ( + {children} ), - TextInput: ({ label, name, value, onChange, required, type }) => ( + TextInput: ({ label, name, value, onChange, required, type, disabled }) => (
({ value={value} onChange={onChange} required={required} + disabled={disabled} />
), @@ -62,15 +92,24 @@ import useSettingsStore from '../../../store/settings'; const setupMocks = ({ version = {}, fetchVersion = vi.fn(), - setSuperuserExists = vi.fn(), + setSuperuserStatus = vi.fn(), + setupStatus = { + superuser_exists: false, + setup_allowed: true, + client_ip: '127.0.0.1', + }, } = {}) => { vi.mocked(useAuthStore).mockImplementation((sel) => - sel({ setSuperuserExists }) + sel({ + setSuperuserStatus, + setupAllowed: setupStatus.setup_allowed, + setupClientIp: setupStatus.client_ip, + }) ); vi.mocked(useSettingsStore).mockImplementation((sel) => sel({ fetchVersion, version }) ); - return { fetchVersion, setSuperuserExists }; + return { fetchVersion, setSuperuserStatus }; }; describe('SuperuserForm', () => { @@ -81,13 +120,13 @@ describe('SuperuserForm', () => { // ── Rendering ────────────────────────────────────────────────────────────── describe('rendering', () => { - it('renders the Dispatcharr title', () => { + it('renders the Dispatcharr title', async () => { setupMocks(); render(); expect(screen.getByText('Dispatcharr')).toBeInTheDocument(); }); - it('renders the welcome message', () => { + it('renders the welcome message', async () => { setupMocks(); render(); expect( @@ -97,19 +136,19 @@ describe('SuperuserForm', () => { ).toBeInTheDocument(); }); - it('renders the logo image', () => { + it('renders the logo image', async () => { setupMocks(); render(); expect(screen.getByAltText('Dispatcharr Logo')).toBeInTheDocument(); }); - it('renders Username input', () => { + it('renders Username input', async () => { setupMocks(); render(); expect(screen.getByTestId('input-username')).toBeInTheDocument(); }); - it('renders Password input with type="password"', () => { + it('renders Password input with type="password"', async () => { setupMocks(); render(); const input = screen.getByTestId('input-password'); @@ -117,7 +156,7 @@ describe('SuperuserForm', () => { expect(input).toHaveAttribute('type', 'password'); }); - it('renders Email input with type="email"', () => { + it('renders Email input with type="email"', async () => { setupMocks(); render(); const input = screen.getByTestId('input-email'); @@ -125,7 +164,7 @@ describe('SuperuserForm', () => { expect(input).toHaveAttribute('type', 'email'); }); - it('renders the Create Account button', () => { + it('renders the Create Account button', async () => { setupMocks(); render(); expect( @@ -133,29 +172,53 @@ describe('SuperuserForm', () => { ).toBeInTheDocument(); }); - it('does not render version text when version is not loaded', () => { + it('does not render version text when version is not loaded', async () => { setupMocks({ version: {} }); render(); expect(screen.queryByText(/^v/)).not.toBeInTheDocument(); }); - it('renders version text when version is loaded', () => { + it('renders version text when version is loaded', async () => { setupMocks({ version: { version: '1.2.3' } }); render(); expect(screen.getByText('v1.2.3')).toBeInTheDocument(); }); + + it('opens setup help modal when setup is not allowed from this IP', async () => { + setupMocks({ + setupStatus: { + superuser_exists: false, + setup_allowed: false, + client_ip: '203.0.113.50', + }, + }); + render(); + await waitFor(() => { + expect(screen.getByTestId('setup-help')).toBeInTheDocument(); + }); + expect( + screen.getByText('Finish setup from this network') + ).toBeInTheDocument(); + expect(screen.getAllByText('203.0.113.50').length).toBeGreaterThan(0); + expect( + screen.queryByRole('button', { name: 'Create Account' }) + ).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: 'View setup instructions' }) + ).toBeInTheDocument(); + }); }); // ── useEffect ───────────────────────────────────────────────────────────── describe('useEffect', () => { - it('calls fetchVersion on mount', () => { + it('calls fetchVersion on mount', async () => { const { fetchVersion } = setupMocks(); render(); expect(fetchVersion).toHaveBeenCalledTimes(1); }); - it('does not call fetchVersion again on re-render with same fetchVersion ref', () => { + it('does not call fetchVersion again on re-render with same fetchVersion ref', async () => { const { fetchVersion } = setupMocks(); const { rerender } = render(); rerender(); @@ -167,7 +230,7 @@ describe('SuperuserForm', () => { // ── Form field interactions ──────────────────────────────────────────────── describe('form field interactions', () => { - it('updates username field when typed', () => { + it('updates username field when typed', async () => { setupMocks(); render(); fireEvent.change(screen.getByTestId('input-username'), { @@ -176,7 +239,7 @@ describe('SuperuserForm', () => { expect(screen.getByTestId('input-username')).toHaveValue('admin'); }); - it('updates password field when typed', () => { + it('updates password field when typed', async () => { setupMocks(); render(); fireEvent.change(screen.getByTestId('input-password'), { @@ -185,7 +248,7 @@ describe('SuperuserForm', () => { expect(screen.getByTestId('input-password')).toHaveValue('secret123'); }); - it('updates email field when typed', () => { + it('updates email field when typed', async () => { setupMocks(); render(); fireEvent.change(screen.getByTestId('input-email'), { @@ -196,7 +259,7 @@ describe('SuperuserForm', () => { ); }); - it('initializes all fields as empty strings', () => { + it('initializes all fields as empty strings', async () => { setupMocks(); render(); expect(screen.getByTestId('input-username')).toHaveValue(''); @@ -238,8 +301,8 @@ describe('SuperuserForm', () => { }); }); - it('calls setSuperuserExists(true) when response.superuser_exists is true', async () => { - const { setSuperuserExists } = setupMocks(); + it('calls setSuperuserStatus when response.superuser_exists is true', async () => { + const { setSuperuserStatus } = setupMocks(); vi.mocked(API.createSuperUser).mockResolvedValue({ superuser_exists: true, }); @@ -253,12 +316,14 @@ describe('SuperuserForm', () => { ); await waitFor(() => { - expect(setSuperuserExists).toHaveBeenCalledWith(true); + expect(setSuperuserStatus).toHaveBeenCalledWith({ + superuser_exists: true, + }); }); }); - it('does not call setSuperuserExists when response.superuser_exists is false', async () => { - const { setSuperuserExists } = setupMocks(); + it('does not call setSuperuserStatus when response.superuser_exists is false', async () => { + const { setSuperuserStatus } = setupMocks(); vi.mocked(API.createSuperUser).mockResolvedValue({ superuser_exists: false, }); @@ -272,7 +337,7 @@ describe('SuperuserForm', () => { expect(API.createSuperUser).toHaveBeenCalled(); }); - expect(setSuperuserExists).not.toHaveBeenCalled(); + expect(setSuperuserStatus).not.toHaveBeenCalled(); }); it('does not throw when API.createSuperUser rejects', async () => { @@ -289,8 +354,8 @@ describe('SuperuserForm', () => { ).resolves.not.toThrow(); }); - it('does not call setSuperuserExists when API throws', async () => { - const { setSuperuserExists } = setupMocks(); + it('does not call setSuperuserStatus when API throws', async () => { + const { setSuperuserStatus } = setupMocks(); vi.mocked(API.createSuperUser).mockRejectedValue(new Error('Network')); render(); @@ -302,7 +367,34 @@ describe('SuperuserForm', () => { expect(API.createSuperUser).toHaveBeenCalled(); }); - expect(setSuperuserExists).not.toHaveBeenCalled(); + expect(setSuperuserStatus).not.toHaveBeenCalled(); + }); + + it('opens setup help modal when create is blocked with 403', async () => { + setupMocks(); + const err = new Error('Forbidden'); + err.status = 403; + err.body = { + client_ip: '198.51.100.10', + setup_allowed: false, + }; + vi.mocked(API.createSuperUser).mockRejectedValue(err); + render(); + + fireEvent.submit( + screen.getByRole('button', { name: 'Create Account' }).closest('form') + ); + + await waitFor(() => { + expect(screen.getByTestId('setup-help')).toBeInTheDocument(); + }); + expect( + screen.getByText('Finish setup from this network') + ).toBeInTheDocument(); + expect(screen.getAllByText('198.51.100.10').length).toBeGreaterThan(0); + expect( + screen.getByRole('button', { name: 'View setup instructions' }) + ).toBeInTheDocument(); }); it('submits with empty email when email field is left blank', async () => { diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx index 3c2cf869..21c46967 100644 --- a/frontend/src/pages/Login.jsx +++ b/frontend/src/pages/Login.jsx @@ -3,15 +3,44 @@ import LoginForm from '../components/forms/LoginForm'; const SuperuserForm = lazy(() => import('../components/forms/SuperuserForm')); import useAuthStore from '../store/auth'; import ErrorBoundary from '../components/ErrorBoundary.jsx'; -import { Text } from '@mantine/core'; +import { Center, Image, Loader, Paper, Stack } from '@mantine/core'; +import logo from '../assets/logo.png'; -const Login = ({}) => { +const LoginLoadingCard = () => ( +
+ + + Dispatcharr Logo + + + +
+); + +const Login = () => { const superuserExists = useAuthStore((s) => s.superuserExists); + if (superuserExists === null) { + return ; + } + if (!superuserExists) { return ( - Loading...
}> + }> diff --git a/frontend/src/pages/__tests__/Login.test.jsx b/frontend/src/pages/__tests__/Login.test.jsx index e55db96f..e0049de0 100644 --- a/frontend/src/pages/__tests__/Login.test.jsx +++ b/frontend/src/pages/__tests__/Login.test.jsx @@ -10,11 +10,29 @@ vi.mock('../../components/forms/LoginForm', () => ({ vi.mock('../../components/forms/SuperuserForm', () => ({ default: () =>
SuperuserForm
, })); +vi.mock('../../assets/logo.png', () => ({ default: 'logo.png' })); vi.mock('@mantine/core', () => ({ - Text: ({ children }) =>
{children}
, + Center: ({ children }) =>
{children}
, + Image: ({ src, alt }) => {alt}, + Loader: (props) =>
, + Paper: ({ children }) =>
{children}
, + Stack: ({ children }) =>
{children}
, })); describe('Login', () => { + it('renders a loading state while setup status is unknown', () => { + useAuthStore.mockReturnValue(null); + + render(); + + expect( + screen.getByRole('progressbar', { name: 'Loading login' }) + ).toBeInTheDocument(); + expect(screen.getByAltText('Dispatcharr Logo')).toBeInTheDocument(); + expect(screen.queryByTestId('login-form')).not.toBeInTheDocument(); + expect(screen.queryByTestId('superuser-form')).not.toBeInTheDocument(); + }); + it('renders SuperuserForm when superuser does not exist', async () => { useAuthStore.mockReturnValue(false); diff --git a/frontend/src/store/__tests__/auth.test.jsx b/frontend/src/store/__tests__/auth.test.jsx index d07d4154..7fd87420 100644 --- a/frontend/src/store/__tests__/auth.test.jsx +++ b/frontend/src/store/__tests__/auth.test.jsx @@ -38,7 +38,7 @@ const localStorageMock = (() => { }; })(); -global.localStorage = localStorageMock; +globalThis.localStorage = localStorageMock; // Helper to create a mock JWT token const createMockToken = (expiresInSeconds = 3600) => { @@ -108,7 +108,6 @@ describe('useAuthStore', () => { isAuthenticated: false, isInitialized: false, isInitializing: false, - needsSuperuser: false, user: { username: '', email: '', @@ -120,7 +119,9 @@ describe('useAuthStore', () => { accessToken: null, refreshToken: null, tokenExpiration: null, - superuserExists: true, + superuserExists: null, + setupAllowed: null, + setupClientIp: '', }); } }); @@ -131,7 +132,6 @@ describe('useAuthStore', () => { expect(result.current.isAuthenticated).toBe(false); expect(result.current.isInitialized).toBe(false); - expect(result.current.needsSuperuser).toBe(false); expect(result.current.user).toEqual({ username: '', email: '', @@ -140,7 +140,9 @@ describe('useAuthStore', () => { }); expect(result.current.isLoading).toBe(false); expect(result.current.error).toBeNull(); - expect(result.current.superuserExists).toBe(true); + expect(result.current.superuserExists).toBeNull(); + expect(result.current.setupAllowed).toBeNull(); + expect(result.current.setupClientIp).toBe(''); }); }); @@ -503,15 +505,21 @@ describe('useAuthStore', () => { }); }); - describe('setSuperuserExists', () => { - it('should update superuser exists state', () => { + describe('setSuperuserStatus', () => { + it('stores the complete bootstrap status response', () => { const { result } = renderHook(() => useAuthStore()); act(() => { - result.current.setSuperuserExists(false); + result.current.setSuperuserStatus({ + superuser_exists: false, + setup_allowed: false, + client_ip: '203.0.113.50', + }); }); expect(result.current.superuserExists).toBe(false); + expect(result.current.setupAllowed).toBe(false); + expect(result.current.setupClientIp).toBe('203.0.113.50'); }); }); diff --git a/frontend/src/store/auth.jsx b/frontend/src/store/auth.jsx index 8f362357..5989b907 100644 --- a/frontend/src/store/auth.jsx +++ b/frontend/src/store/auth.jsx @@ -28,7 +28,6 @@ const useAuthStore = create((set, get) => ({ isAuthenticated: false, isInitialized: false, isInitializing: false, - needsSuperuser: false, user: { username: '', email: '', @@ -153,11 +152,18 @@ const useAuthStore = create((set, get) => ({ accessToken: localStorage.getItem('accessToken') || null, refreshToken: localStorage.getItem('refreshToken') || null, tokenExpiration: localStorage.getItem('tokenExpiration') || null, - superuserExists: true, + superuserExists: null, + setupAllowed: null, + setupClientIp: '', setIsAuthenticated: (isAuthenticated) => set({ isAuthenticated }), - setSuperuserExists: (superuserExists) => set({ superuserExists }), + setSuperuserStatus: (status) => + set({ + superuserExists: status?.superuser_exists ?? true, + setupAllowed: status?.setup_allowed ?? null, + setupClientIp: status?.client_ip ?? '', + }), getToken: async () => { const tokenExpiration = localStorage.getItem('tokenExpiration');