mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-27 20:15:03 +00:00
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
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:
commit
120bc6b1be
17 changed files with 577 additions and 108 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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="*"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue