diff --git a/apps/accounts/api_views.py b/apps/accounts/api_views.py
index bf87c2ab..41e2f077 100644
--- a/apps/accounts/api_views.py
+++ b/apps/accounts/api_views.py
@@ -20,30 +20,88 @@ class TokenObtainPairView(TokenObtainPairView):
def post(self, request, *args, **kwargs):
# Custom logic here
if not network_access_allowed(request, "UI"):
+ # Log blocked login attempt due to network restrictions
+ from core.utils import log_system_event
+ username = request.data.get("username", 'unknown')
+ client_ip = request.META.get('REMOTE_ADDR', 'unknown')
+ user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
+ log_system_event(
+ event_type='login_failed',
+ user=username,
+ client_ip=client_ip,
+ user_agent=user_agent,
+ reason='Network access denied',
+ )
return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN)
# Get the response from the parent class first
- response = super().post(request, *args, **kwargs)
+ username = request.data.get("username")
- # If login was successful, update last_login
- if response.status_code == 200:
- username = request.data.get("username")
- if username:
- from django.utils import timezone
- try:
- user = User.objects.get(username=username)
- user.last_login = timezone.now()
- user.save(update_fields=['last_login'])
- except User.DoesNotExist:
- pass # User doesn't exist, but login somehow succeeded
+ # Log login attempt
+ from core.utils import log_system_event
+ client_ip = request.META.get('REMOTE_ADDR', 'unknown')
+ user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
- return response
+ try:
+ response = super().post(request, *args, **kwargs)
+
+ # If login was successful, update last_login and log success
+ if response.status_code == 200:
+ if username:
+ from django.utils import timezone
+ try:
+ user = User.objects.get(username=username)
+ user.last_login = timezone.now()
+ user.save(update_fields=['last_login'])
+
+ # Log successful login
+ log_system_event(
+ event_type='login_success',
+ user=username,
+ client_ip=client_ip,
+ user_agent=user_agent,
+ )
+ except User.DoesNotExist:
+ pass # User doesn't exist, but login somehow succeeded
+ else:
+ # Log failed login attempt
+ log_system_event(
+ event_type='login_failed',
+ user=username or 'unknown',
+ client_ip=client_ip,
+ user_agent=user_agent,
+ reason='Invalid credentials',
+ )
+
+ return response
+
+ except Exception as e:
+ # If parent class raises an exception (e.g., validation error), log failed attempt
+ log_system_event(
+ event_type='login_failed',
+ user=username or 'unknown',
+ client_ip=client_ip,
+ user_agent=user_agent,
+ reason=f'Authentication error: {str(e)[:100]}',
+ )
+ raise # Re-raise the exception to maintain normal error flow
class TokenRefreshView(TokenRefreshView):
def post(self, request, *args, **kwargs):
# Custom logic here
if not network_access_allowed(request, "UI"):
+ # Log blocked token refresh attempt due to network restrictions
+ from core.utils import log_system_event
+ client_ip = request.META.get('REMOTE_ADDR', 'unknown')
+ user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
+ log_system_event(
+ event_type='login_failed',
+ user='token_refresh',
+ client_ip=client_ip,
+ user_agent=user_agent,
+ reason='Network access denied (token refresh)',
+ )
return Response({"error": "Unauthorized"}, status=status.HTTP_403_FORBIDDEN)
return super().post(request, *args, **kwargs)
@@ -80,6 +138,15 @@ def initialize_superuser(request):
class AuthViewSet(viewsets.ViewSet):
"""Handles user login and logout"""
+ def get_permissions(self):
+ """
+ Login doesn't require auth, but logout does
+ """
+ if self.action == 'logout':
+ from rest_framework.permissions import IsAuthenticated
+ return [IsAuthenticated()]
+ return []
+
@swagger_auto_schema(
operation_description="Authenticate and log in a user",
request_body=openapi.Schema(
@@ -100,6 +167,11 @@ class AuthViewSet(viewsets.ViewSet):
password = request.data.get("password")
user = authenticate(request, username=username, password=password)
+ # Get client info for logging
+ from core.utils import log_system_event
+ client_ip = request.META.get('REMOTE_ADDR', 'unknown')
+ user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
+
if user:
login(request, user)
# Update last_login timestamp
@@ -107,6 +179,14 @@ class AuthViewSet(viewsets.ViewSet):
user.last_login = timezone.now()
user.save(update_fields=['last_login'])
+ # Log successful login
+ log_system_event(
+ event_type='login_success',
+ user=username,
+ client_ip=client_ip,
+ user_agent=user_agent,
+ )
+
return Response(
{
"message": "Login successful",
@@ -118,6 +198,15 @@ class AuthViewSet(viewsets.ViewSet):
},
}
)
+
+ # Log failed login attempt
+ log_system_event(
+ event_type='login_failed',
+ user=username or 'unknown',
+ client_ip=client_ip,
+ user_agent=user_agent,
+ reason='Invalid credentials',
+ )
return Response({"error": "Invalid credentials"}, status=400)
@swagger_auto_schema(
@@ -126,6 +215,19 @@ class AuthViewSet(viewsets.ViewSet):
)
def logout(self, request):
"""Logs out the authenticated user"""
+ # Log logout event before actually logging out
+ from core.utils import log_system_event
+ username = request.user.username if request.user and request.user.is_authenticated else 'unknown'
+ client_ip = request.META.get('REMOTE_ADDR', 'unknown')
+ user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
+
+ log_system_event(
+ event_type='logout',
+ user=username,
+ client_ip=client_ip,
+ user_agent=user_agent,
+ )
+
logout(request)
return Response({"message": "Logout successful"})
diff --git a/apps/output/views.py b/apps/output/views.py
index faab1a24..edf603d3 100644
--- a/apps/output/views.py
+++ b/apps/output/views.py
@@ -53,6 +53,17 @@ def get_client_identifier(request):
def m3u_endpoint(request, profile_name=None, user=None):
logger.debug("m3u_endpoint called: method=%s, profile=%s", request.method, profile_name)
if not network_access_allowed(request, "M3U_EPG"):
+ # Log blocked M3U download
+ from core.utils import log_system_event
+ client_ip = request.META.get('REMOTE_ADDR', 'unknown')
+ user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
+ log_system_event(
+ event_type='m3u_blocked',
+ profile=profile_name or 'all',
+ reason='Network access denied',
+ client_ip=client_ip,
+ user_agent=user_agent,
+ )
return JsonResponse({"error": "Forbidden"}, status=403)
# Handle HEAD requests efficiently without generating content
@@ -67,6 +78,17 @@ def m3u_endpoint(request, profile_name=None, user=None):
def epg_endpoint(request, profile_name=None, user=None):
logger.debug("epg_endpoint called: method=%s, profile=%s", request.method, profile_name)
if not network_access_allowed(request, "M3U_EPG"):
+ # Log blocked EPG download
+ from core.utils import log_system_event
+ client_ip = request.META.get('REMOTE_ADDR', 'unknown')
+ user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
+ log_system_event(
+ event_type='epg_blocked',
+ profile=profile_name or 'all',
+ reason='Network access denied',
+ client_ip=client_ip,
+ user_agent=user_agent,
+ )
return JsonResponse({"error": "Forbidden"}, status=403)
# Handle HEAD requests efficiently without generating content
@@ -1958,12 +1980,34 @@ def xc_panel_api(request):
def xc_get(request):
if not network_access_allowed(request, 'XC_API'):
+ # Log blocked M3U download
+ from core.utils import log_system_event
+ client_ip = request.META.get('REMOTE_ADDR', 'unknown')
+ user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
+ log_system_event(
+ event_type='m3u_blocked',
+ user=request.GET.get('username', 'unknown'),
+ reason='Network access denied (XC API)',
+ client_ip=client_ip,
+ user_agent=user_agent,
+ )
return JsonResponse({'error': 'Forbidden'}, status=403)
action = request.GET.get("action")
user = xc_get_user(request)
if user is None:
+ # Log blocked M3U download due to invalid credentials
+ from core.utils import log_system_event
+ client_ip = request.META.get('REMOTE_ADDR', 'unknown')
+ user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
+ log_system_event(
+ event_type='m3u_blocked',
+ user=request.GET.get('username', 'unknown'),
+ reason='Invalid XC credentials',
+ client_ip=client_ip,
+ user_agent=user_agent,
+ )
return JsonResponse({'error': 'Unauthorized'}, status=401)
return generate_m3u(request, None, user)
@@ -1971,11 +2015,33 @@ def xc_get(request):
def xc_xmltv(request):
if not network_access_allowed(request, 'XC_API'):
+ # Log blocked EPG download
+ from core.utils import log_system_event
+ client_ip = request.META.get('REMOTE_ADDR', 'unknown')
+ user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
+ log_system_event(
+ event_type='epg_blocked',
+ user=request.GET.get('username', 'unknown'),
+ reason='Network access denied (XC API)',
+ client_ip=client_ip,
+ user_agent=user_agent,
+ )
return JsonResponse({'error': 'Forbidden'}, status=403)
user = xc_get_user(request)
if user is None:
+ # Log blocked EPG download due to invalid credentials
+ from core.utils import log_system_event
+ client_ip = request.META.get('REMOTE_ADDR', 'unknown')
+ user_agent = request.META.get('HTTP_USER_AGENT', 'unknown')
+ log_system_event(
+ event_type='epg_blocked',
+ user=request.GET.get('username', 'unknown'),
+ reason='Invalid XC credentials',
+ client_ip=client_ip,
+ user_agent=user_agent,
+ )
return JsonResponse({'error': 'Unauthorized'}, status=401)
return generate_epg(request, None, user)
diff --git a/core/migrations/0018_alter_systemevent_event_type.py b/core/migrations/0018_alter_systemevent_event_type.py
new file mode 100644
index 00000000..3fe4eecd
--- /dev/null
+++ b/core/migrations/0018_alter_systemevent_event_type.py
@@ -0,0 +1,18 @@
+# Generated by Django 5.2.4 on 2025-11-21 15:59
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0017_systemevent'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='systemevent',
+ name='event_type',
+ field=models.CharField(choices=[('channel_start', 'Channel Started'), ('channel_stop', 'Channel Stopped'), ('channel_buffering', 'Channel Buffering'), ('channel_failover', 'Channel Failover'), ('channel_reconnect', 'Channel Reconnected'), ('channel_error', 'Channel Error'), ('client_connect', 'Client Connected'), ('client_disconnect', 'Client Disconnected'), ('recording_start', 'Recording Started'), ('recording_end', 'Recording Ended'), ('stream_switch', 'Stream Switched'), ('m3u_refresh', 'M3U Refreshed'), ('m3u_download', 'M3U Downloaded'), ('epg_refresh', 'EPG Refreshed'), ('epg_download', 'EPG Downloaded'), ('login_success', 'Login Successful'), ('login_failed', 'Login Failed'), ('logout', 'User Logged Out'), ('m3u_blocked', 'M3U Download Blocked'), ('epg_blocked', 'EPG Download Blocked')], db_index=True, max_length=50),
+ ),
+ ]
diff --git a/core/models.py b/core/models.py
index 2a5eb1f3..b9166f66 100644
--- a/core/models.py
+++ b/core/models.py
@@ -398,6 +398,11 @@ class SystemEvent(models.Model):
('m3u_download', 'M3U Downloaded'),
('epg_refresh', 'EPG Refreshed'),
('epg_download', 'EPG Downloaded'),
+ ('login_success', 'Login Successful'),
+ ('login_failed', 'Login Failed'),
+ ('logout', 'User Logged Out'),
+ ('m3u_blocked', 'M3U Download Blocked'),
+ ('epg_blocked', 'EPG Download Blocked'),
]
event_type = models.CharField(max_length=50, choices=EVENT_TYPES, db_index=True)
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 470373f1..3304e8ad 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -170,7 +170,7 @@ export default class API {
static async logout() {
return await request(`${host}/api/accounts/auth/logout/`, {
- auth: false,
+ auth: true, // Send JWT token so backend can identify the user
method: 'POST',
});
}
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
index 143d01ab..d8c3fae8 100644
--- a/frontend/src/components/Sidebar.jsx
+++ b/frontend/src/components/Sidebar.jsx
@@ -188,8 +188,8 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
}
};
- const onLogout = () => {
- logout();
+ const onLogout = async () => {
+ await logout();
window.location.reload();
};
diff --git a/frontend/src/components/SystemEvents.jsx b/frontend/src/components/SystemEvents.jsx
index 4047801c..c4945fa2 100644
--- a/frontend/src/components/SystemEvents.jsx
+++ b/frontend/src/components/SystemEvents.jsx
@@ -19,11 +19,16 @@ import {
Gauge,
HardDriveDownload,
List,
+ LogIn,
+ LogOut,
RefreshCw,
+ Shield,
+ ShieldAlert,
SquareX,
Timer,
Users,
Video,
+ XCircle,
} from 'lucide-react';
import dayjs from 'dayjs';
import API from '../api';
@@ -108,6 +113,16 @@ const SystemEvents = () => {
return ;
case 'epg_download':
return ;
+ case 'login_success':
+ return ;
+ case 'login_failed':
+ return ;
+ case 'logout':
+ return ;
+ case 'm3u_blocked':
+ return ;
+ case 'epg_blocked':
+ return ;
default:
return ;
}
@@ -118,12 +133,14 @@ const SystemEvents = () => {
case 'channel_start':
case 'client_connect':
case 'recording_start':
+ case 'login_success':
return 'green';
case 'channel_reconnect':
return 'yellow';
case 'channel_stop':
case 'client_disconnect':
case 'recording_end':
+ case 'logout':
return 'gray';
case 'channel_buffering':
return 'yellow';
@@ -138,6 +155,10 @@ const SystemEvents = () => {
case 'm3u_download':
case 'epg_download':
return 'teal';
+ case 'login_failed':
+ case 'm3u_blocked':
+ case 'epg_blocked':
+ return 'red';
default:
return 'gray';
}
diff --git a/frontend/src/store/auth.jsx b/frontend/src/store/auth.jsx
index fd2c52b8..b1d60a1a 100644
--- a/frontend/src/store/auth.jsx
+++ b/frontend/src/store/auth.jsx
@@ -134,13 +134,21 @@ const useAuthStore = create((set, get) => ({
return false; // Add explicit return for when data.access is not available
} catch (error) {
console.error('Token refresh failed:', error);
- get().logout();
+ await get().logout();
return false; // Add explicit return after error
}
},
// Action to logout
- logout: () => {
+ logout: async () => {
+ // Call backend logout endpoint to log the event
+ try {
+ await API.logout();
+ } catch (error) {
+ // Continue with logout even if API call fails
+ console.error('Logout API call failed:', error);
+ }
+
set({
accessToken: null,
refreshToken: null,