From c3d1600c074f6c6e14e3213ea0c6fa0852ef1965 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 27 Jun 2025 20:22:49 -0500 Subject: [PATCH 01/22] Additional logging. --- docker/entrypoint.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 2d9f7fdc..412cf808 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -98,9 +98,11 @@ chmod +x /etc/profile.d/dispatcharr.sh pip install django-filter # Run init scripts -echo "Starting init process..." +echo "Starting user setup..." . /app/docker/init/01-user-setup.sh +echo "Setting up PostgreSQL..." . /app/docker/init/02-postgres.sh +echo "Starting init process..." . /app/docker/init/03-init-dispatcharr.sh # Start PostgreSQL From d50a6ebce53855428c3b8ab7b5559eaf0f642b49 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 27 Jun 2025 21:03:04 -0500 Subject: [PATCH 02/22] Converted users to our custom table --- frontend/src/components/tables/UsersTable.jsx | 330 ++++++++++++++++++ frontend/src/pages/Users.jsx | 142 +------- 2 files changed, 339 insertions(+), 133 deletions(-) create mode 100644 frontend/src/components/tables/UsersTable.jsx diff --git a/frontend/src/components/tables/UsersTable.jsx b/frontend/src/components/tables/UsersTable.jsx new file mode 100644 index 00000000..1709d31d --- /dev/null +++ b/frontend/src/components/tables/UsersTable.jsx @@ -0,0 +1,330 @@ +import React, { useEffect, useMemo, useCallback, useState } from 'react'; +import API from '../../api'; +import UserForm from '../forms/User'; +import useUsersStore from '../../store/users'; +import useAuthStore from '../../store/auth'; +import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants'; +import useWarningsStore from '../../store/warnings'; +import { + SquarePlus, + SquareMinus, + SquarePen, + EllipsisVertical, +} from 'lucide-react'; +import { + ActionIcon, + Box, + Text, + Paper, + Button, + Flex, + Group, + useMantineTheme, + Menu, + UnstyledButton, + LoadingOverlay, + Stack, +} from '@mantine/core'; +import { CustomTable, useTable } from './CustomTable'; +import ConfirmationDialog from '../ConfirmationDialog'; +import useLocalStorage from '../../hooks/useLocalStorage'; + +const UserRowActions = ({ theme, row, editUser, deleteUser }) => { + const [tableSize, _] = useLocalStorage('table-size', 'default'); + const authUser = useAuthStore((s) => s.user); + + const onEdit = useCallback(() => { + editUser(row.original); + }, [row.original, editUser]); + + const onDelete = useCallback(() => { + deleteUser(row.original.id); + }, [row.original.id, deleteUser]); + + const iconSize = + tableSize == 'default' ? 'sm' : tableSize == 'compact' ? 'xs' : 'md'; + + return ( + + + + + + + + + + + + ); +}; + +const UsersTable = () => { + const theme = useMantineTheme(); + + /** + * STORES + */ + const users = useUsersStore((s) => s.users); + const authUser = useAuthStore((s) => s.user); + const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); + const suppressWarning = useWarningsStore((s) => s.suppressWarning); + + /** + * useState + */ + const [selectedUser, setSelectedUser] = useState(null); + const [userModalOpen, setUserModalOpen] = useState(false); + const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [userToDelete, setUserToDelete] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + /** + * Functions + */ + const executeDeleteUser = useCallback(async (id) => { + setIsLoading(true); + await API.deleteUser(id); + setIsLoading(false); + setConfirmDeleteOpen(false); + }, []); + + const editUser = useCallback(async (user = null) => { + setSelectedUser(user); + setUserModalOpen(true); + }, []); + + const deleteUser = useCallback(async (id) => { + const user = users.find((u) => u.id === id); + setUserToDelete(user); + setDeleteTarget(id); + + if (isWarningSuppressed('delete-user')) { + return executeDeleteUser(id); + } + + setConfirmDeleteOpen(true); + }, [users, isWarningSuppressed, executeDeleteUser]); + + /** + * useMemo + */ + const columns = useMemo( + () => [ + { + header: 'Username', + accessorKey: 'username', + cell: ({ getValue }) => ( + + {getValue()} + + ), + }, + { + header: 'Email', + accessorKey: 'email', + cell: ({ getValue }) => ( + + {getValue()} + + ), + }, + { + header: 'User Level', + accessorKey: 'user_level', + size: 120, + cell: ({ getValue }) => ( + + {USER_LEVEL_LABELS[getValue()]} + + ), + }, + { + id: 'actions', + size: 80, + header: 'Actions', + cell: ({ row }) => ( + + ), + }, + ], + [theme, editUser, deleteUser] + ); + + const closeUserForm = () => { + setSelectedUser(null); + setUserModalOpen(false); + }; + + const data = useMemo(() => { + return users.sort((a, b) => a.id - b.id); + }, [users]); + + const renderHeaderCell = (header) => { + return ( + + {header.column.columnDef.header} + + ); + }; + + const table = useTable({ + columns, + data, + allRowIds: data.map((user) => user.id), + enablePagination: false, + enableRowSelection: false, + enableRowVirtualization: false, + renderTopToolbar: false, + manualSorting: false, + manualFiltering: false, + manualPagination: false, + headerCellRenderFns: { + actions: renderHeaderCell, + username: renderHeaderCell, + email: renderHeaderCell, + user_level: renderHeaderCell, + }, + }); + + return ( + <> + + + + Users + + + + + + + + + + + + + +
+ + +
+
+
+
+ + + + setConfirmDeleteOpen(false)} + onConfirm={() => executeDeleteUser(deleteTarget)} + title="Confirm User Deletion" + message={ + userToDelete ? ( +
+ {`Are you sure you want to delete the following user? + +Username: ${userToDelete.username} +Email: ${userToDelete.email} +User Level: ${USER_LEVEL_LABELS[userToDelete.user_level]} + +This action cannot be undone.`} +
+ ) : ( + 'Are you sure you want to delete this user? This action cannot be undone.' + ) + } + confirmLabel="Delete" + cancelLabel="Cancel" + actionKey="delete-user" + onSuppressChange={suppressWarning} + size="md" + /> + + ); +}; + +export default UsersTable; \ No newline at end of file diff --git a/frontend/src/pages/Users.jsx b/frontend/src/pages/Users.jsx index 765eedc8..570e49c1 100644 --- a/frontend/src/pages/Users.jsx +++ b/frontend/src/pages/Users.jsx @@ -1,31 +1,11 @@ import React, { useState } from 'react'; -import useUsersStore from '../store/users'; -import { - ActionIcon, - Box, - Button, - Center, - Divider, - Group, - Paper, - Select, - Stack, - Text, - useMantineTheme, -} from '@mantine/core'; -import { SquareMinus, SquarePen, SquarePlus } from 'lucide-react'; -import UserForm from '../components/forms/User'; +import UsersTable from '../components/tables/UsersTable'; +import { Box } from '@mantine/core'; import useAuthStore from '../store/auth'; -import API from '../api'; -import { USER_LEVELS, USER_LEVEL_LABELS } from '../constants'; -import ConfirmationDialog from '../components/ConfirmationDialog'; -import useWarningsStore from '../store/warnings'; +import { USER_LEVELS } from '../constants'; const UsersPage = () => { - const theme = useMantineTheme(); - const authUser = useAuthStore((s) => s.user); - const users = useUsersStore((s) => s.users); const [selectedUser, setSelectedUser] = useState(null); const [userModalOpen, setUserModalOpen] = useState(false); @@ -33,10 +13,9 @@ const UsersPage = () => { const [deleteTarget, setDeleteTarget] = useState(null); const [userToDelete, setUserToDelete] = useState(null); - const isWarningSuppressed = useWarningsStore((s) => s.isWarningSuppressed); - const suppressWarning = useWarningsStore((s) => s.suppressWarning); - - console.log(authUser); + if (!authUser.id) { + return <>; + } const closeUserModal = () => { setSelectedUser(null); @@ -67,112 +46,9 @@ const UsersPage = () => { }; return ( - <> -
- - - - - - - {users - .sort((a, b) => a.id > b.id) - .map((user) => { - if (!user) { - return <>; - } - - return ( - - - {user.username} - - - - {user.email} - - - {authUser.user_level == USER_LEVELS.ADMIN && ( - - {USER_LEVEL_LABELS[user.user_level]} - editUser(user)} - > - - - - deleteUser(user.id)} - disabled={authUser.id === user.id} - > - - - - )} - - ); - })} - - -
- - setConfirmDeleteOpen(false)} - onConfirm={() => executeDeleteUser(deleteTarget)} - title="Confirm User Deletion" - message={ - userToDelete ? ( -
- {`Are you sure you want to delete the following user? - -Username: ${userToDelete.username} -Email: ${userToDelete.email} -User Level: ${USER_LEVEL_LABELS[userToDelete.user_level]} - -This action cannot be undone.`} -
- ) : ( - 'Are you sure you want to delete this user? This action cannot be undone.' - ) - } - confirmLabel="Delete" - cancelLabel="Cancel" - actionKey="delete-user" - onSuppressChange={suppressWarning} - size="md" - /> - + + + ); }; From 1e91dd759794ba2077a8fe3e6e3b46897f01b061 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 27 Jun 2025 21:43:50 -0500 Subject: [PATCH 03/22] Added all available fields. --- apps/accounts/serializers.py | 6 + frontend/src/components/tables/UsersTable.jsx | 202 +++++++++++++----- 2 files changed, 159 insertions(+), 49 deletions(-) diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index 5aa81f3e..81b5037f 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -39,6 +39,12 @@ class UserSerializer(serializers.ModelSerializer): "password", "channel_profiles", "custom_properties", + "avatar_config", + "is_active", + "is_staff", + "is_superuser", + "last_login", + "date_joined", ] def create(self, validated_data): diff --git a/frontend/src/components/tables/UsersTable.jsx b/frontend/src/components/tables/UsersTable.jsx index 1709d31d..53ef3c75 100644 --- a/frontend/src/components/tables/UsersTable.jsx +++ b/frontend/src/components/tables/UsersTable.jsx @@ -10,6 +10,8 @@ import { SquareMinus, SquarePen, EllipsisVertical, + Eye, + EyeOff, } from 'lucide-react'; import { ActionIcon, @@ -91,10 +93,18 @@ const UsersTable = () => { const [deleteTarget, setDeleteTarget] = useState(null); const [userToDelete, setUserToDelete] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [visiblePasswords, setVisiblePasswords] = useState({}); /** * Functions */ + const togglePasswordVisibility = useCallback((userId) => { + setVisiblePasswords(prev => ({ + ...prev, + [userId]: !prev[userId] + })); + }, []); + const executeDeleteUser = useCallback(async (id) => { setIsLoading(true); await API.deleteUser(id); @@ -139,6 +149,36 @@ const UsersTable = () => { ), }, + { + header: 'First Name', + accessorKey: 'first_name', + cell: ({ getValue }) => ( + + {getValue() || '-'} + + ), + }, + { + header: 'Last Name', + accessorKey: 'last_name', + cell: ({ getValue }) => ( + + {getValue() || '-'} + + ), + }, { header: 'Email', accessorKey: 'email', @@ -164,6 +204,68 @@ const UsersTable = () => { ), }, + { + header: 'Last Login', + accessorKey: 'last_login', + size: 140, + cell: ({ getValue }) => { + const date = getValue(); + return ( + + {date ? new Date(date).toLocaleDateString() : 'Never'} + + ); + }, + }, + { + header: 'Date Joined', + accessorKey: 'date_joined', + size: 140, + cell: ({ getValue }) => { + const date = getValue(); + return ( + + {date ? new Date(date).toLocaleDateString() : '-'} + + ); + }, + }, + { + header: 'XC Password', + accessorKey: 'custom_properties', + size: 120, + cell: ({ getValue, row }) => { + const userId = row.original.id; + const isVisible = visiblePasswords[userId]; + + // Parse custom_properties and extract xc_password + let password = 'N/A'; + try { + const customProps = JSON.parse(getValue() || '{}'); + password = customProps.xc_password || 'N/A'; + } catch { + password = 'N/A'; + } + + return ( + + + {password === 'N/A' ? 'N/A' : (isVisible ? password : '••••••••')} + + {password !== 'N/A' && ( + togglePasswordVisibility(userId)} + > + {isVisible ? : } + + )} + + ); + }, + }, { id: 'actions', size: 80, @@ -178,7 +280,7 @@ const UsersTable = () => { ), }, ], - [theme, editUser, deleteUser] + [theme, editUser, deleteUser, visiblePasswords, togglePasswordVisibility] ); const closeUserForm = () => { @@ -219,32 +321,47 @@ const UsersTable = () => { return ( <> - - - - Users - - + + + + + Users + + - - - + {/* Top toolbar */} + - - - + - - -
+ {/* Table container */} + -
-
-
-
+ + + + Date: Fri, 27 Jun 2025 21:45:27 -0500 Subject: [PATCH 04/22] Standardized headers. --- frontend/src/components/tables/UsersTable.jsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/components/tables/UsersTable.jsx b/frontend/src/components/tables/UsersTable.jsx index 53ef3c75..2ee40176 100644 --- a/frontend/src/components/tables/UsersTable.jsx +++ b/frontend/src/components/tables/UsersTable.jsx @@ -314,8 +314,13 @@ const UsersTable = () => { headerCellRenderFns: { actions: renderHeaderCell, username: renderHeaderCell, + first_name: renderHeaderCell, + last_name: renderHeaderCell, email: renderHeaderCell, user_level: renderHeaderCell, + last_login: renderHeaderCell, + date_joined: renderHeaderCell, + custom_properties: renderHeaderCell, }, }); From 1a8bbb6bb80708f8febfba7189198edff3da625c Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Fri, 27 Jun 2025 21:59:56 -0500 Subject: [PATCH 05/22] Reorder columns --- apps/accounts/serializers.py | 2 + frontend/src/components/tables/UsersTable.jsx | 44 ++++++++++--------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py index 81b5037f..865d29af 100644 --- a/apps/accounts/serializers.py +++ b/apps/accounts/serializers.py @@ -45,6 +45,8 @@ class UserSerializer(serializers.ModelSerializer): "is_superuser", "last_login", "date_joined", + "first_name", + "last_name", ] def create(self, validated_data): diff --git a/frontend/src/components/tables/UsersTable.jsx b/frontend/src/components/tables/UsersTable.jsx index 2ee40176..6572b418 100644 --- a/frontend/src/components/tables/UsersTable.jsx +++ b/frontend/src/components/tables/UsersTable.jsx @@ -134,6 +134,16 @@ const UsersTable = () => { */ const columns = useMemo( () => [ + { + header: 'User Level', + accessorKey: 'user_level', + size: 120, + cell: ({ getValue }) => ( + + {USER_LEVEL_LABELS[getValue()]} + + ), + }, { header: 'Username', accessorKey: 'username', @@ -195,14 +205,17 @@ const UsersTable = () => { ), }, { - header: 'User Level', - accessorKey: 'user_level', - size: 120, - cell: ({ getValue }) => ( - - {USER_LEVEL_LABELS[getValue()]} - - ), + header: 'Date Joined', + accessorKey: 'date_joined', + size: 140, + cell: ({ getValue }) => { + const date = getValue(); + return ( + + {date ? new Date(date).toLocaleDateString() : '-'} + + ); + }, }, { header: 'Last Login', @@ -217,23 +230,11 @@ const UsersTable = () => { ); }, }, - { - header: 'Date Joined', - accessorKey: 'date_joined', - size: 140, - cell: ({ getValue }) => { - const date = getValue(); - return ( - - {date ? new Date(date).toLocaleDateString() : '-'} - - ); - }, - }, { header: 'XC Password', accessorKey: 'custom_properties', size: 120, + enableSorting: false, cell: ({ getValue, row }) => { const userId = row.original.id; const isVisible = visiblePasswords[userId]; @@ -270,6 +271,7 @@ const UsersTable = () => { id: 'actions', size: 80, header: 'Actions', + enableSorting: false, cell: ({ row }) => ( Date: Sat, 28 Jun 2025 08:49:09 -0500 Subject: [PATCH 06/22] Add first and last name to user form. --- frontend/src/components/forms/User.jsx | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/forms/User.jsx b/frontend/src/components/forms/User.jsx index 00ea0537..d296fa63 100644 --- a/frontend/src/components/forms/User.jsx +++ b/frontend/src/components/forms/User.jsx @@ -41,6 +41,8 @@ const User = ({ user = null, isOpen, onClose }) => { mode: 'uncontrolled', initialValues: { username: '', + first_name: '', + last_name: '', email: '', user_level: '0', password: '', @@ -52,7 +54,7 @@ const User = ({ user = null, isOpen, onClose }) => { username: !values.username ? 'Username is required' : values.user_level == USER_LEVELS.STREAMER && - !values.username.match(/^[a-z0-9]+$/i) + !values.username.match(/^[a-z0-9]+$/i) ? 'Streamer username must be alphanumeric' : null, password: @@ -127,6 +129,8 @@ const User = ({ user = null, isOpen, onClose }) => { form.setValues({ username: user.username, + first_name: user.first_name || '', + last_name: user.last_name || '', email: user.email, user_level: `${user.user_level}`, channel_profiles: @@ -170,6 +174,14 @@ const User = ({ user = null, isOpen, onClose }) => { key={form.key('username')} /> + + { key={form.key('email')} /> + + Date: Sat, 28 Jun 2025 08:55:07 -0500 Subject: [PATCH 07/22] Set last_login when successful login occurs. --- apps/accounts/api_views.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/accounts/api_views.py b/apps/accounts/api_views.py index f6b48e55..c4b544b0 100644 --- a/apps/accounts/api_views.py +++ b/apps/accounts/api_views.py @@ -22,7 +22,22 @@ class TokenObtainPairView(TokenObtainPairView): if not network_access_allowed(request, "UI"): return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN) - return super().post(request, *args, **kwargs) + # Get the response from the parent class first + response = super().post(request, *args, **kwargs) + + # 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 + + return response class TokenRefreshView(TokenRefreshView): @@ -87,6 +102,11 @@ class AuthViewSet(viewsets.ViewSet): if user: login(request, user) + # Update last_login timestamp + from django.utils import timezone + user.last_login = timezone.now() + user.save(update_fields=['last_login']) + return Response( { "message": "Login successful", From 77d8ab8d55ca2c52c4518590e393047e63dff55b Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 28 Jun 2025 09:01:57 -0500 Subject: [PATCH 08/22] Table formatting --- frontend/src/components/tables/UsersTable.jsx | 29 +++++-------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/frontend/src/components/tables/UsersTable.jsx b/frontend/src/components/tables/UsersTable.jsx index 6572b418..ddb62f14 100644 --- a/frontend/src/components/tables/UsersTable.jsx +++ b/frontend/src/components/tables/UsersTable.jsx @@ -160,23 +160,9 @@ const UsersTable = () => { ), }, { - header: 'First Name', - accessorKey: 'first_name', - cell: ({ getValue }) => ( - - {getValue() || '-'} - - ), - }, - { - header: 'Last Name', - accessorKey: 'last_name', + id: 'name', + header: 'Name', + accessorFn: (row) => `${row.first_name || ''} ${row.last_name || ''}`.trim(), cell: ({ getValue }) => ( { { header: 'Date Joined', accessorKey: 'date_joined', - size: 140, + size: 120, cell: ({ getValue }) => { const date = getValue(); return ( @@ -220,12 +206,12 @@ const UsersTable = () => { { header: 'Last Login', accessorKey: 'last_login', - size: 140, + size: 175, cell: ({ getValue }) => { const date = getValue(); return ( - {date ? new Date(date).toLocaleDateString() : 'Never'} + {date ? new Date(date).toLocaleString() : 'Never'} ); }, @@ -316,8 +302,7 @@ const UsersTable = () => { headerCellRenderFns: { actions: renderHeaderCell, username: renderHeaderCell, - first_name: renderHeaderCell, - last_name: renderHeaderCell, + name: renderHeaderCell, email: renderHeaderCell, user_level: renderHeaderCell, last_login: renderHeaderCell, From d8ffec474c729673da3e7c358fd91c23b79b6f50 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 28 Jun 2025 09:04:28 -0500 Subject: [PATCH 09/22] Set minimum size --- frontend/src/components/tables/UsersTable.jsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/tables/UsersTable.jsx b/frontend/src/components/tables/UsersTable.jsx index ddb62f14..137bca89 100644 --- a/frontend/src/components/tables/UsersTable.jsx +++ b/frontend/src/components/tables/UsersTable.jsx @@ -376,12 +376,14 @@ const UsersTable = () => { - - +
+ + +
From f6825418daf9a3144f5f4c944e71327bd079d2b9 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sat, 28 Jun 2025 09:10:19 -0500 Subject: [PATCH 10/22] Show first name if available. Remove placeholder link that was dead. --- frontend/src/components/Sidebar.jsx | 80 ++++++++++++++--------------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index 83bc2fc3..6d69e9e7 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -90,45 +90,45 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { const navItems = authUser && authUser.user_level == USER_LEVELS.ADMIN ? [ - { - label: 'Channels', - icon: , - path: '/channels', - badge: `(${Object.keys(channels).length})`, - }, - { - label: 'M3U & EPG Manager', - icon: , - path: '/sources', - }, - { label: 'TV Guide', icon: , path: '/guide' }, - { label: 'DVR', icon: