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');