Merge pull request #1481 from Dispatcharr/initialize-superuser-refactor
Some checks are pending
Backend Tests / Plan test groups (push) Waiting to run
Backend Tests / (push) Blocked by required conditions
CI Pipeline / prepare (push) Waiting to run
CI Pipeline / docker (amd64, ubuntu-24.04) (push) Blocked by required conditions
CI Pipeline / docker (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
CI Pipeline / create-manifest (push) Blocked by required conditions
Build and Push Multi-Arch Docker Image / build-and-push (push) Waiting to run
Frontend Tests / test (push) Waiting to run

Implement IP-based restrictions for superuser initialization
This commit is contained in:
SergeantPanda 2026-07-26 21:45:21 -05:00 committed by GitHub
commit 120bc6b1be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 577 additions and 108 deletions

View file

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

View file

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

View file

@ -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())
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())

View file

@ -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"]

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 = () => {
<Route path="/vods" element={<VODsPage />} />
</>
) : (
<Route path="/login" element={<Login needsSuperuser />} />
<Route path="/login" element={<Login />} />
)}
<Route
path="*"

View file

@ -157,6 +157,10 @@ export default class API {
return response;
} catch (e) {
// Setup IP policy blocks are explained inline on SuperuserForm.
if (e?.status === 403 && e?.body?.setup_allowed === false) {
throw e;
}
errorNotification('Failed to create superuser', e);
}
}

View file

@ -10,6 +10,10 @@ import {
Text,
Image,
Divider,
Code,
Anchor,
Modal,
Group,
} from '@mantine/core';
import API from '../../api';
import useAuthStore from '../../store/auth';
@ -24,14 +28,70 @@ const createSuperUser = (formData) => {
});
};
function SetupHelpModal({ opened, onClose, clientIp }) {
const ip = clientIp || '<your-ip>';
return (
<Modal
opened={opened}
onClose={onClose}
title="Finish setup from this network"
centered
data-testid="setup-help"
>
<Stack spacing="md">
<Text size="sm">
Web setup is limited to local networks by default. Dispatcharr
currently sees your connection as <Code>{ip}</Code>.
</Text>
<Text size="sm">
To allow web setup from this IP, set the environment variable and
restart, then reload this page:
</Text>
<Code block>DISPATCHARR_SETUP_ALLOWED_IP={ip}</Code>
<Text size="sm">
Or create the admin account from the host with a management command:
</Text>
<div>
<Text weight={500} size="sm" mb={8}>
If running with Docker:
</Text>
<Code block>
{`docker exec <container_name> python manage.py createsuperuser`}
</Code>
</div>
<div>
<Text weight={500} size="sm" mb={8}>
If running locally:
</Text>
<Code block>python manage.py createsuperuser</Code>
</div>
<Text size="xs" color="dimmed">
Replace <code>{'<container_name>'}</code> with your Docker container
name. After the account exists, this setup page will no longer be
available.
</Text>
</Stack>
</Modal>
);
}
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
</Title>
<Text size="sm" color="dimmed" align="center">
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.'}
</Text>
<Divider style={{ width: '100%' }} />
</Stack>
<form onSubmit={handleSubmit}>
{setupAllowed ? (
<form onSubmit={handleSubmit}>
<Stack>
<TextInput
label="Username"
name="username"
value={formData.username}
onChange={handleChange}
required
/>
<TextInput
label="Password"
type="password"
name="password"
value={formData.password}
onChange={handleChange}
required
/>
<TextInput
label="Email (optional)"
type="email"
name="email"
value={formData.email}
onChange={handleChange}
/>
<Group justify="flex-end">
<Anchor
size="sm"
component="button"
type="button"
onClick={() => setSetupHelpOpened(true)}
>
Remote setup help
</Anchor>
</Group>
<Button type="submit" fullWidth>
Create Account
</Button>
</Stack>
</form>
) : (
<Stack>
<TextInput
label="Username"
name="username"
value={formData.username}
onChange={handleChange}
required
/>
<TextInput
label="Password"
type="password"
name="password"
value={formData.password}
onChange={handleChange}
required
/>
<TextInput
label="Email (optional)"
type="email"
name="email"
value={formData.email}
onChange={handleChange}
/>
<Button type="submit" fullWidth>
Create Account
<Text size="sm" align="center">
Your connection appears as <Code>{clientIp || 'unknown'}</Code>.
Open the instructions to allow this IP for web setup, or create
the admin account from the host.
</Text>
<Button fullWidth onClick={() => setSetupHelpOpened(true)}>
View setup instructions
</Button>
</Stack>
</form>
)}
{storedVersion.version && (
<Text
@ -138,6 +230,12 @@ function SuperuserForm() {
</Text>
)}
</Paper>
<SetupHelpModal
opened={setupHelpOpened}
onClose={() => setSetupHelpOpened(false)}
clientIp={clientIp}
/>
</Center>
);
}

View file

@ -17,22 +17,51 @@ vi.mock('../../../store/settings', () => ({ default: vi.fn() }));
// @mantine/core
vi.mock('@mantine/core', () => ({
Button: ({ children, type, fullWidth }) => (
<button type={type} data-fullwidth={fullWidth}>
Anchor: ({ children, onClick, component = 'a', type, ...rest }) => {
const Component = component;
return (
<Component type={type} onClick={onClick} {...rest}>
{children}
</Component>
);
},
Button: ({ children, type, fullWidth, disabled, onClick }) => (
<button
type={type}
data-fullwidth={fullWidth}
disabled={disabled}
onClick={onClick}
>
{children}
</button>
),
Center: ({ children, style }) => <div style={style}>{children}</div>,
Code: ({ children, block }) =>
block ? <pre>{children}</pre> : <code>{children}</code>,
Divider: ({ style }) => <hr style={style} />,
Group: ({ children }) => <div>{children}</div>,
Image: ({ src, alt }) => <img src={src} alt={alt} />,
Modal: ({ children, opened, title, ...rest }) =>
opened ? (
<div role="dialog" data-testid={rest['data-testid']} data-title={title}>
<strong>{title}</strong>
{children}
</div>
) : null,
Paper: ({ children, style }) => <div style={style}>{children}</div>,
Stack: ({ children }) => <div>{children}</div>,
Text: ({ children, size, color, align }) => (
<span data-size={size} data-color={color} data-align={align}>
Text: ({ children, size, color, align, weight, mb }) => (
<span
data-size={size}
data-color={color}
data-align={align}
data-weight={weight}
data-mb={mb}
>
{children}
</span>
),
TextInput: ({ label, name, value, onChange, required, type }) => (
TextInput: ({ label, name, value, onChange, required, type, disabled }) => (
<div>
<label htmlFor={name}>{label}</label>
<input
@ -43,6 +72,7 @@ vi.mock('@mantine/core', () => ({
value={value}
onChange={onChange}
required={required}
disabled={disabled}
/>
</div>
),
@ -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(<SuperuserForm />);
expect(screen.getByText('Dispatcharr')).toBeInTheDocument();
});
it('renders the welcome message', () => {
it('renders the welcome message', async () => {
setupMocks();
render(<SuperuserForm />);
expect(
@ -97,19 +136,19 @@ describe('SuperuserForm', () => {
).toBeInTheDocument();
});
it('renders the logo image', () => {
it('renders the logo image', async () => {
setupMocks();
render(<SuperuserForm />);
expect(screen.getByAltText('Dispatcharr Logo')).toBeInTheDocument();
});
it('renders Username input', () => {
it('renders Username input', async () => {
setupMocks();
render(<SuperuserForm />);
expect(screen.getByTestId('input-username')).toBeInTheDocument();
});
it('renders Password input with type="password"', () => {
it('renders Password input with type="password"', async () => {
setupMocks();
render(<SuperuserForm />);
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(<SuperuserForm />);
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(<SuperuserForm />);
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(<SuperuserForm />);
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(<SuperuserForm />);
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(<SuperuserForm />);
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(<SuperuserForm />);
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(<SuperuserForm />);
rerender(<SuperuserForm />);
@ -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(<SuperuserForm />);
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(<SuperuserForm />);
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(<SuperuserForm />);
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(<SuperuserForm />);
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(<SuperuserForm />);
@ -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(<SuperuserForm />);
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 () => {

View file

@ -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 = () => (
<Center style={{ height: '100vh' }}>
<Paper
elevation={3}
style={{
padding: 30,
width: '100%',
maxWidth: 500,
}}
>
<Stack align="center" justify="center" spacing="lg" mih={280}>
<Image
src={logo}
alt="Dispatcharr Logo"
width={120}
height={120}
fit="contain"
/>
<Loader aria-label="Loading login" />
</Stack>
</Paper>
</Center>
);
const Login = () => {
const superuserExists = useAuthStore((s) => s.superuserExists);
if (superuserExists === null) {
return <LoginLoadingCard />;
}
if (!superuserExists) {
return (
<ErrorBoundary>
<Suspense fallback={<Text>Loading...</Text>}>
<Suspense fallback={<LoginLoadingCard />}>
<SuperuserForm />
</Suspense>
</ErrorBoundary>

View file

@ -10,11 +10,29 @@ vi.mock('../../components/forms/LoginForm', () => ({
vi.mock('../../components/forms/SuperuserForm', () => ({
default: () => <div data-testid="superuser-form">SuperuserForm</div>,
}));
vi.mock('../../assets/logo.png', () => ({ default: 'logo.png' }));
vi.mock('@mantine/core', () => ({
Text: ({ children }) => <div>{children}</div>,
Center: ({ children }) => <div>{children}</div>,
Image: ({ src, alt }) => <img src={src} alt={alt} />,
Loader: (props) => <div role="progressbar" {...props} />,
Paper: ({ children }) => <div>{children}</div>,
Stack: ({ children }) => <div>{children}</div>,
}));
describe('Login', () => {
it('renders a loading state while setup status is unknown', () => {
useAuthStore.mockReturnValue(null);
render(<Login />);
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);

View file

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

View file

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