mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
Remove user account disable/enable UI (keep backend guards)
Removed the frontend UI exposure while keeping backend guards as defensive measures. Removed: - frontend/src/components/forms/User.jsx — Account Enabled switch - frontend/src/components/tables/UsersTable.jsx — Status column - apps/accounts/api_views.py — is_active check in TokenRefreshView - apps/accounts/tests.py — related tests
This commit is contained in:
parent
db318e4981
commit
f453380f5c
4 changed files with 2 additions and 273 deletions
|
|
@ -113,24 +113,6 @@ class TokenRefreshView(TokenRefreshView):
|
||||||
)
|
)
|
||||||
return Response({"error": "Unauthorized"}, status=status.HTTP_403_FORBIDDEN)
|
return Response({"error": "Unauthorized"}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
||||||
# Check if user account is still active before issuing new access token
|
|
||||||
raw_token = request.data.get("refresh")
|
|
||||||
if raw_token:
|
|
||||||
try:
|
|
||||||
from rest_framework_simplejwt.tokens import RefreshToken as RefreshTokenClass
|
|
||||||
token = RefreshTokenClass(raw_token)
|
|
||||||
user_id = token.payload.get("user_id")
|
|
||||||
if user_id:
|
|
||||||
user = User.objects.filter(id=user_id).first()
|
|
||||||
if user and not user.is_active:
|
|
||||||
logger.info(f"Token refresh blocked for disabled user: user_id={user_id}")
|
|
||||||
return Response(
|
|
||||||
{"error": "Account is disabled"},
|
|
||||||
status=status.HTTP_403_FORBIDDEN
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass # Let parent handle invalid tokens
|
|
||||||
|
|
||||||
return super().post(request, *args, **kwargs)
|
return super().post(request, *args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
from django.test import TestCase, RequestFactory
|
from django.test import TestCase
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
from rest_framework import status
|
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
@ -70,214 +69,4 @@ class InitializeSuperuserTests(TestCase):
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertTrue(response.json()["superuser_exists"])
|
self.assertTrue(response.json()["superuser_exists"])
|
||||||
# Should NOT have created a new user
|
# Should NOT have created a new user
|
||||||
self.assertFalse(User.objects.filter(username="newadmin").exists())
|
self.assertFalse(User.objects.filter(username="newadmin").exists())
|
||||||
|
|
||||||
|
|
||||||
class TokenRefreshDisabledUserTests(TestCase):
|
|
||||||
"""Tests for blocking token refresh on disabled users"""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.client = APIClient()
|
|
||||||
self.token_url = "/api/accounts/token/"
|
|
||||||
self.refresh_url = "/api/accounts/token/refresh/"
|
|
||||||
|
|
||||||
def test_refresh_works_for_active_user(self):
|
|
||||||
"""Active user should be able to refresh their token"""
|
|
||||||
user = User.objects.create_user(username="active", password="testpass123")
|
|
||||||
user.user_level = 1
|
|
||||||
user.is_active = True
|
|
||||||
user.save()
|
|
||||||
|
|
||||||
# Get tokens
|
|
||||||
login_response = self.client.post(
|
|
||||||
self.token_url,
|
|
||||||
{"username": "active", "password": "testpass123"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(login_response.status_code, 200)
|
|
||||||
refresh_token = login_response.data["refresh"]
|
|
||||||
|
|
||||||
# Refresh should succeed
|
|
||||||
refresh_response = self.client.post(
|
|
||||||
self.refresh_url,
|
|
||||||
{"refresh": refresh_token},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(refresh_response.status_code, 200)
|
|
||||||
self.assertIn("access", refresh_response.data)
|
|
||||||
|
|
||||||
def test_refresh_blocked_for_disabled_user(self):
|
|
||||||
"""Disabled user should not be able to refresh their token"""
|
|
||||||
user = User.objects.create_user(username="disabled", password="testpass123")
|
|
||||||
user.user_level = 1
|
|
||||||
user.is_active = True
|
|
||||||
user.save()
|
|
||||||
|
|
||||||
# Get tokens while user is active
|
|
||||||
login_response = self.client.post(
|
|
||||||
self.token_url,
|
|
||||||
{"username": "disabled", "password": "testpass123"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(login_response.status_code, 200)
|
|
||||||
refresh_token = login_response.data["refresh"]
|
|
||||||
|
|
||||||
# Disable the user
|
|
||||||
user.is_active = False
|
|
||||||
user.save()
|
|
||||||
|
|
||||||
# Refresh should be blocked
|
|
||||||
refresh_response = self.client.post(
|
|
||||||
self.refresh_url,
|
|
||||||
{"refresh": refresh_token},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(refresh_response.status_code, 403)
|
|
||||||
|
|
||||||
|
|
||||||
class LastAdminProtectionTests(TestCase):
|
|
||||||
"""Tests for preventing disabling the last active admin"""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.admin = User.objects.create_user(username="admin1", password="testpass123")
|
|
||||||
self.admin.user_level = 10
|
|
||||||
self.admin.save()
|
|
||||||
|
|
||||||
self.client = APIClient()
|
|
||||||
self.client.force_authenticate(user=self.admin)
|
|
||||||
|
|
||||||
def test_cannot_disable_last_admin(self):
|
|
||||||
"""Should reject disabling the only active admin"""
|
|
||||||
response = self.client.patch(
|
|
||||||
f"/api/accounts/users/{self.admin.id}/",
|
|
||||||
{"is_active": False},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 400)
|
|
||||||
self.assertIn("is_active", response.data)
|
|
||||||
|
|
||||||
# Verify admin is still active
|
|
||||||
self.admin.refresh_from_db()
|
|
||||||
self.assertTrue(self.admin.is_active)
|
|
||||||
|
|
||||||
def test_can_disable_admin_when_another_exists(self):
|
|
||||||
"""Should allow disabling an admin when another active admin exists"""
|
|
||||||
admin2 = User.objects.create_user(username="admin2", password="testpass123")
|
|
||||||
admin2.user_level = 10
|
|
||||||
admin2.save()
|
|
||||||
|
|
||||||
response = self.client.patch(
|
|
||||||
f"/api/accounts/users/{admin2.id}/",
|
|
||||||
{"is_active": False},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
admin2.refresh_from_db()
|
|
||||||
self.assertFalse(admin2.is_active)
|
|
||||||
|
|
||||||
def test_can_disable_non_admin_user(self):
|
|
||||||
"""Should always allow disabling non-admin users"""
|
|
||||||
regular = User.objects.create_user(username="regular", password="testpass123")
|
|
||||||
regular.user_level = 1
|
|
||||||
regular.save()
|
|
||||||
|
|
||||||
response = self.client.patch(
|
|
||||||
f"/api/accounts/users/{regular.id}/",
|
|
||||||
{"is_active": False},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
regular.refresh_from_db()
|
|
||||||
self.assertFalse(regular.is_active)
|
|
||||||
|
|
||||||
def test_can_reenable_disabled_user(self):
|
|
||||||
"""Should allow re-enabling a disabled user"""
|
|
||||||
regular = User.objects.create_user(username="disabled", password="testpass123")
|
|
||||||
regular.user_level = 1
|
|
||||||
regular.is_active = False
|
|
||||||
regular.save()
|
|
||||||
|
|
||||||
response = self.client.patch(
|
|
||||||
f"/api/accounts/users/{regular.id}/",
|
|
||||||
{"is_active": True},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
regular.refresh_from_db()
|
|
||||||
self.assertTrue(regular.is_active)
|
|
||||||
|
|
||||||
|
|
||||||
class DisabledUserLoginTests(TestCase):
|
|
||||||
"""Tests that disabled users cannot log in"""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.client = APIClient()
|
|
||||||
self.token_url = "/api/accounts/token/"
|
|
||||||
|
|
||||||
def test_disabled_user_cannot_login(self):
|
|
||||||
"""Disabled user should get rejected at login"""
|
|
||||||
user = User.objects.create_user(username="disabled", password="testpass123")
|
|
||||||
user.is_active = False
|
|
||||||
user.save()
|
|
||||||
|
|
||||||
response = self.client.post(
|
|
||||||
self.token_url,
|
|
||||||
{"username": "disabled", "password": "testpass123"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 401)
|
|
||||||
|
|
||||||
def test_active_user_can_login(self):
|
|
||||||
"""Active user should be able to log in"""
|
|
||||||
user = User.objects.create_user(username="active", password="testpass123")
|
|
||||||
user.is_active = True
|
|
||||||
user.save()
|
|
||||||
|
|
||||||
response = self.client.post(
|
|
||||||
self.token_url,
|
|
||||||
{"username": "active", "password": "testpass123"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertIn("access", response.data)
|
|
||||||
|
|
||||||
|
|
||||||
class DisabledUserAccessTokenTests(TestCase):
|
|
||||||
"""Tests that a disabled user's existing access token is rejected on authenticated endpoints"""
|
|
||||||
|
|
||||||
def setUp(self):
|
|
||||||
self.client = APIClient()
|
|
||||||
self.token_url = "/api/accounts/token/"
|
|
||||||
self.users_url = "/api/accounts/users/"
|
|
||||||
|
|
||||||
def test_existing_token_rejected_after_disable(self):
|
|
||||||
"""Access token obtained while active should be rejected after user is disabled"""
|
|
||||||
user = User.objects.create_user(username="willdisable", password="testpass123")
|
|
||||||
user.user_level = 10 # Admin so they can access the users endpoint
|
|
||||||
user.is_active = True
|
|
||||||
user.save()
|
|
||||||
|
|
||||||
# Get tokens while user is active
|
|
||||||
login_response = self.client.post(
|
|
||||||
self.token_url,
|
|
||||||
{"username": "willdisable", "password": "testpass123"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
self.assertEqual(login_response.status_code, 200)
|
|
||||||
access_token = login_response.data["access"]
|
|
||||||
|
|
||||||
# Verify token works while active
|
|
||||||
self.client.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}")
|
|
||||||
response = self.client.get(self.users_url)
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
# Disable the user
|
|
||||||
user.is_active = False
|
|
||||||
user.save()
|
|
||||||
|
|
||||||
# Same token should now be rejected
|
|
||||||
response = self.client.get(self.users_url)
|
|
||||||
self.assertIn(response.status_code, [401, 403])
|
|
||||||
|
|
@ -42,7 +42,6 @@ const User = ({ user = null, isOpen, onClose }) => {
|
||||||
xc_password: '',
|
xc_password: '',
|
||||||
channel_profiles: [],
|
channel_profiles: [],
|
||||||
hide_adult_content: false,
|
hide_adult_content: false,
|
||||||
is_active: true,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
validate: (values) => ({
|
validate: (values) => ({
|
||||||
|
|
@ -135,7 +134,6 @@ const User = ({ user = null, isOpen, onClose }) => {
|
||||||
: ['0'],
|
: ['0'],
|
||||||
xc_password: customProps.xc_password || '',
|
xc_password: customProps.xc_password || '',
|
||||||
hide_adult_content: customProps.hide_adult_content || false,
|
hide_adult_content: customProps.hide_adult_content || false,
|
||||||
is_active: user.is_active !== false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (customProps.xc_password) {
|
if (customProps.xc_password) {
|
||||||
|
|
@ -203,30 +201,6 @@ const User = ({ user = null, isOpen, onClose }) => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isAdmin && (
|
|
||||||
<Box>
|
|
||||||
<Tooltip
|
|
||||||
label={isEditingSelf
|
|
||||||
? "You cannot disable your own account"
|
|
||||||
: "Disabled accounts cannot log in or access any resources"
|
|
||||||
}
|
|
||||||
position="top"
|
|
||||||
withArrow
|
|
||||||
>
|
|
||||||
<div style={isEditingSelf ? { cursor: 'not-allowed' } : undefined}>
|
|
||||||
<Switch
|
|
||||||
label="Account Enabled"
|
|
||||||
{...form.getInputProps('is_active', {
|
|
||||||
type: 'checkbox',
|
|
||||||
})}
|
|
||||||
key={form.key('is_active')}
|
|
||||||
disabled={isEditingSelf}
|
|
||||||
styles={isEditingSelf ? { track: { pointerEvents: 'none' } } : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack gap="xs" style={{ flex: 1 }}>
|
<Stack gap="xs" style={{ flex: 1 }}>
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import useWarningsStore from '../../store/warnings';
|
||||||
import { SquarePlus, SquareMinus, SquarePen, Eye, EyeOff } from 'lucide-react';
|
import { SquarePlus, SquareMinus, SquarePen, Eye, EyeOff } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Badge,
|
|
||||||
Box,
|
Box,
|
||||||
Text,
|
Text,
|
||||||
Paper,
|
Paper,
|
||||||
|
|
@ -148,20 +147,6 @@ const UsersTable = () => {
|
||||||
<Text size="sm">{USER_LEVEL_LABELS[getValue()]}</Text>
|
<Text size="sm">{USER_LEVEL_LABELS[getValue()]}</Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
header: 'Status',
|
|
||||||
accessorKey: 'is_active',
|
|
||||||
size: 90,
|
|
||||||
cell: ({ getValue }) => (
|
|
||||||
<Badge
|
|
||||||
size="sm"
|
|
||||||
color={getValue() !== false ? 'green' : 'red'}
|
|
||||||
variant="light"
|
|
||||||
>
|
|
||||||
{getValue() !== false ? 'Active' : 'Disabled'}
|
|
||||||
</Badge>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
header: 'Username',
|
header: 'Username',
|
||||||
accessorKey: 'username',
|
accessorKey: 'username',
|
||||||
|
|
@ -331,7 +316,6 @@ const UsersTable = () => {
|
||||||
name: renderHeaderCell,
|
name: renderHeaderCell,
|
||||||
email: renderHeaderCell,
|
email: renderHeaderCell,
|
||||||
user_level: renderHeaderCell,
|
user_level: renderHeaderCell,
|
||||||
is_active: renderHeaderCell,
|
|
||||||
last_login: renderHeaderCell,
|
last_login: renderHeaderCell,
|
||||||
date_joined: renderHeaderCell,
|
date_joined: renderHeaderCell,
|
||||||
custom_properties: renderHeaderCell,
|
custom_properties: renderHeaderCell,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue