more user management features and bug fixes

This commit is contained in:
dekzter 2025-05-21 15:33:54 -04:00
parent e979113935
commit e3553b04ad
9 changed files with 624 additions and 160 deletions

View file

@ -14,20 +14,34 @@ import json
from urllib.parse import urlparse
def generate_m3u(request, user):
if user.user_level == 0:
channel_profiles = user.channel_profiles.all()
filters = {
"channelprofilemembership__channel_profile__in": channel_profiles,
"channelprofilemembership__enabled": True,
"user_level__lte": user.user_level,
}
def generate_m3u(request, profile_name=None, user=None):
"""
Dynamically generate an M3U file from channels.
The stream URL now points to the new stream_view that uses StreamProfile.
"""
if user is not None:
if user.user_level == 0:
channel_profiles = user.channel_profiles.all()
filters = {
"channelprofilemembership__channel_profile__in": channel_profiles,
"channelprofilemembership__enabled": True,
"user_level__lte": user.user_level,
}
channels = Channel.objects.filter(**filters).order_by("channel_number")
channels = Channel.objects.filter(**filters).order_by("channel_number")
else:
channels = Channel.objects.filter(user_level__lte=user.user_level).order_by(
"channel_number"
)
else:
channels = Channel.objects.filter(user_level__lte=user.user_level).order_by(
"channel_number"
)
if profile_name is not None:
channel_profile = ChannelProfile.objects.get(name=profile_name)
channels = Channel.objects.filter(
channelprofilemembership__channel_profile=channel_profile,
channelprofilemembership__enabled=True,
).order_by("channel_number")
else:
channels = Channel.objects.order_by("channel_number")
m3u_content = "#EXTM3U\n"
for channel in channels:
@ -177,7 +191,7 @@ def generate_dummy_epg(
return xml_lines
def generate_epg(request, user):
def generate_epg(request, profile_name=None, user=None):
"""
Dynamically generate an XMLTV (EPG) file using the new EPGData/ProgramData models.
Since the EPG data is stored independently of Channels, we group programmes
@ -190,19 +204,29 @@ def generate_epg(request, user):
'<tv generator-info-name="Dispatcharr" generator-info-url="https://github.com/Dispatcharr/Dispatcharr">'
)
if user.user_level == 0:
channel_profiles = user.channel_profiles.all()
filters = {
"channelprofilemembership__channel_profile__in": channel_profiles,
"channelprofilemembership__enabled": True,
"user_level__lte": user.user_level,
}
if user is not None:
if user.user_level == 0:
channel_profiles = user.channel_profiles.all()
filters = {
"channelprofilemembership__channel_profile__in": channel_profiles,
"channelprofilemembership__enabled": True,
"user_level__lte": user.user_level,
}
channels = Channel.objects.filter(**filters).order_by("channel_number")
channels = Channel.objects.filter(**filters).order_by("channel_number")
else:
channels = Channel.objects.filter(user_level__lte=user.user_level).order_by(
"channel_number"
)
else:
channels = Channel.objects.filter(user_level__lte=user.user_level).order_by(
"channel_number"
)
if profile_name is not None:
channel_profile = ChannelProfile.objects.get(name=profile_name)
channels = Channel.objects.filter(
channelprofilemembership__channel_profile=channel_profile,
channelprofilemembership__enabled=True,
)
else:
channels = Channel.objects.all()
# Retrieve all active channels
for channel in channels:

View file

@ -33,6 +33,7 @@ import useSettingsStore from '../store/settings';
import useAuthStore from '../store/auth'; // Add this import
import API from '../api';
import { USER_LEVELS } from '../constants';
import UserForm from './forms/User';
const NavLink = ({ item, isActive, collapsed }) => {
return (
@ -81,6 +82,9 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
version: '',
timestamp: null,
});
const [userFormOpen, setUserFormOpen] = useState(false);
const closeUserForm = () => setUserFormOpen(false);
// Navigation Items
const navItems =
@ -295,9 +299,9 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
whiteSpace: 'nowrap',
}}
>
<Text size="sm" color="white">
<UnstyledButton onClick={() => setUserFormOpen(true)}>
{authUser.username}
</Text>
</UnstyledButton>
<ActionIcon variant="transparent" color="white" size="sm">
<LogOut onClick={logout} />
@ -315,6 +319,8 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
{appVersion?.timestamp ? `-${appVersion.timestamp}` : ''}
</Text>
)}
<UserForm user={authUser} isOpen={userFormOpen} onClose={closeUserForm} />
</AppShell.Navbar>
);
};

View file

@ -0,0 +1,88 @@
import React, { useState, useEffect, useRef } from 'react';
import API from '../../api';
import {
Button,
Modal,
Text,
Group,
Flex,
useMantineTheme,
NumberInput,
} from '@mantine/core';
import { ListOrdered } from 'lucide-react';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
const AssignChannelNumbers = ({ channelIds, isOpen, onClose }) => {
const theme = useMantineTheme();
const form = useForm({
mode: 'uncontrolled',
initialValues: {
starting_number: 1,
},
});
const onSubmit = async () => {
const { starting_number } = form.getValues();
try {
const result = await API.assignChannelNumbers(
channelIds,
starting_number
);
notifications.show({
title: result.message || 'Channels assigned',
color: 'green.5',
});
API.requeryChannels();
onClose();
} catch (err) {
console.error(err);
notifications.show({
title: 'Failed to assign channels',
color: 'red.5',
});
}
};
if (!isOpen) {
return <></>;
}
return (
<Modal
opened={isOpen}
onClose={onClose}
size="xs"
title={
<Group gap="5">
<ListOrdered size="20" />
<Text>Assign Channel #s</Text>
</Group>
}
styles={{ hannontent: { '--mantine-color-body': '#27272A' } }}
>
<form onSubmit={form.onSubmit(onSubmit)}>
<NumberInput
placeholder="Starting #"
mb="xs"
size="xs"
{...form.getInputProps('starting_number')}
key={form.key('starting_number')}
/>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button type="submit" variant="default" disabled={form.submitting}>
Submit
</Button>
</Flex>
</form>
</Modal>
);
};
export default AssignChannelNumbers;

View file

@ -0,0 +1,294 @@
import React, { useState, useEffect, useRef } from 'react';
import useChannelsStore from '../../store/channels';
import API from '../../api';
import useStreamProfilesStore from '../../store/streamProfiles';
import ChannelGroupForm from './ChannelGroup';
import {
Box,
Button,
Modal,
TextInput,
Text,
Group,
ActionIcon,
Flex,
Select,
Stack,
useMantineTheme,
Popover,
ScrollArea,
Tooltip,
UnstyledButton,
Center,
} from '@mantine/core';
import { ListOrdered, SquarePlus, SquareX, X } from 'lucide-react';
import { FixedSizeList as List } from 'react-window';
import { useForm } from '@mantine/form';
import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants';
const ChannelBatchForm = ({ channelIds, isOpen, onClose }) => {
const theme = useMantineTheme();
const groupListRef = useRef(null);
const channelGroups = useChannelsStore((s) => s.channelGroups);
const streamProfiles = useStreamProfilesStore((s) => s.profiles);
const [channelGroupModelOpen, setChannelGroupModalOpen] = useState(false);
const [selectedChannelGroup, setSelectedChannelGroup] = useState('');
const [groupPopoverOpened, setGroupPopoverOpened] = useState(false);
const [groupFilter, setGroupFilter] = useState('');
const groupOptions = Object.values(channelGroups);
const form = useForm({
mode: 'uncontrolled',
initialValues: {
channel_group: '',
stream_profile_id: '0',
user_level: '-1',
},
});
const onSubmit = async () => {
const values = {
...form.getValues(),
channel_group_id: selectedChannelGroup,
};
if (!values.stream_profile_id || values.stream_profile_id === '0') {
values.stream_profile_id = null;
}
if (!values.channel_group_id) {
delete values.channel_group_id;
}
if (values.user_level == '-1') {
delete values.user_level;
}
await API.batchUpdateChannels({
ids: channelIds,
values,
});
};
// useEffect(() => {
// // const sameStreamProfile = channels.every(
// // (channel) => channel.stream_profile_id == channels[0].stream_profile_id
// // );
// // const sameChannelGroup = channels.every(
// // (channel) => channel.channel_group_id == channels[0].channel_group_id
// // );
// // const sameUserLevel = channels.every(
// // (channel) => channel.user_level == channels[0].user_level
// // );
// // form.setValues({
// // ...(sameStreamProfile && {
// // stream_profile_id: `${channels[0].stream_profile_id}`,
// // }),
// // ...(sameChannelGroup && {
// // channel_group_id: `${channels[0].channel_group_id}`,
// // }),
// // ...(sameUserLevel && {
// // user_level: `${channels[0].user_level}`,
// // }),
// // });
// }, [channelIds, streamProfiles, channelGroups]);
const handleChannelGroupModalClose = (newGroup) => {
setChannelGroupModalOpen(false);
if (newGroup && newGroup.id) {
setSelectedChannelGroup(newGroup.id);
form.setValues({
channel_group: `${newGroup.name}`,
});
}
};
const filteredGroups = groupOptions.filter((group) =>
group.name.toLowerCase().includes(groupFilter.toLowerCase())
);
if (!isOpen) {
return <></>;
}
return (
<>
<Modal
opened={isOpen}
onClose={onClose}
size="xs"
title={
<Group gap="5">
<ListOrdered size="20" />
<Text>Channels</Text>
</Group>
}
styles={{ hannontent: { '--mantine-color-body': '#27272A' } }}
>
<form onSubmit={form.onSubmit(onSubmit)}>
<Group justify="space-between" align="top">
<Stack gap="5" style={{ flex: 1 }}>
<Popover
opened={groupPopoverOpened}
onChange={setGroupPopoverOpened}
// position="bottom-start"
withArrow
>
<Popover.Target>
<Group style={{ width: '100%' }} align="flex-end">
<TextInput
id="channel_group"
name="channel_group"
label="Channel Group"
readOnly
{...form.getInputProps('channel_group')}
key={form.key('channel_group')}
onClick={() => setGroupPopoverOpened(true)}
size="xs"
style={{ flex: 1 }}
/>
<ActionIcon
color={theme.tailwind.green[5]}
onClick={() => setChannelGroupModalOpen(true)}
title="Create new group"
size="small"
variant="transparent"
style={{ marginBottom: 5 }}
>
<SquarePlus size="20" />
</ActionIcon>
</Group>
</Popover.Target>
<Popover.Dropdown onMouseDown={(e) => e.stopPropagation()}>
<Group style={{ width: '100%' }} spacing="xs">
<TextInput
placeholder="Filter"
value={groupFilter}
onChange={(event) =>
setGroupFilter(event.currentTarget.value)
}
mb="xs"
size="xs"
style={{ flex: 1 }}
/>
<ActionIcon
color={theme.tailwind.green[5]}
onClick={() => setChannelGroupModalOpen(true)}
title="Create new group"
size="small"
variant="transparent"
style={{ marginBottom: 5 }}
>
<SquarePlus size="20" />
</ActionIcon>
</Group>
<ScrollArea style={{ height: 200 }}>
<List
height={200} // Set max height for visible items
itemCount={filteredGroups.length}
itemSize={20} // Adjust row height for each item
width={200}
ref={groupListRef}
>
{({ index, style }) => (
<Box
style={{ ...style, height: 20, overflow: 'hidden' }}
>
<Tooltip
openDelay={500}
label={filteredGroups[index].name}
size="xs"
>
<UnstyledButton
onClick={() => {
setSelectedChannelGroup(
filteredGroups[index].id
);
form.setValues({
channel_group: filteredGroups[index].name,
});
setGroupPopoverOpened(false);
}}
>
<Text
size="xs"
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{filteredGroups[index].name}
</Text>
</UnstyledButton>
</Tooltip>
</Box>
)}
</List>
</ScrollArea>
</Popover.Dropdown>
</Popover>
<Select
id="stream_profile_id"
label="Stream Profile"
name="stream_profile_id"
{...form.getInputProps('stream_profile_id')}
key={form.key('stream_profile_id')}
data={[{ value: '0', label: '(use default)' }].concat(
streamProfiles.map((option) => ({
value: `${option.id}`,
label: option.name,
}))
)}
size="xs"
/>
<Select
size="xs"
label="User Level Access"
{...form.getInputProps('user_level')}
key={form.key('user_level')}
data={[
{
value: '-1',
label: '(no change)',
},
].concat(
Object.entries(USER_LEVELS).map(([label, value]) => {
return {
label: USER_LEVEL_LABELS[value],
value: `${value}`,
};
})
)}
/>
</Stack>
</Group>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">
<Button type="submit" variant="default" disabled={form.submitting}>
Submit
</Button>
</Flex>
</form>
</Modal>
<ChannelGroupForm
isOpen={channelGroupModelOpen}
onClose={handleChannelGroupModalClose}
/>
</>
);
};
export default ChannelBatchForm;

View file

@ -20,12 +20,13 @@ import {
MultiSelect,
} from '@mantine/core';
import { isNotEmpty, useForm } from '@mantine/form';
import useUsersStore from '../../store/users';
import useChannelsStore from '../../store/channels';
import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants';
import useAuthStore from '../../store/auth';
const User = ({ user = null, isOpen, onClose }) => {
const profiles = useChannelsStore((s) => s.profiles);
const authUser = useAuthStore((s) => s.user);
console.log(user);
@ -77,7 +78,7 @@ const User = ({ user = null, isOpen, onClose }) => {
};
useEffect(() => {
if (user) {
if (user?.id) {
form.setValues({
username: user.username,
email: user.email,
@ -93,10 +94,16 @@ const User = ({ user = null, isOpen, onClose }) => {
return <></>;
}
console.log(user);
const showPermissions =
authUser.user_level == USER_LEVELS.ADMIN && authUser.id !== user?.id;
return (
<Modal opened={isOpen} onClose={onClose} title="User" size="xl">
<Modal
opened={isOpen}
onClose={onClose}
title="User"
size={showPermissions ? 'xl' : 'md'}
>
<form onSubmit={form.onSubmit(onSubmit)}>
<Group justify="space-between" align="top">
<Stack gap="xs" style={{ flex: 1 }}>
@ -123,31 +130,33 @@ const User = ({ user = null, isOpen, onClose }) => {
/>
</Stack>
<Stack gap="xs" style={{ flex: 1 }}>
<Select
label="User Level"
data={Object.entries(USER_LEVELS).map(([label, value]) => {
return {
label: USER_LEVEL_LABELS[value],
value: `${value}`,
};
})}
{...form.getInputProps('user_level')}
key={form.key('user_level')}
/>
{showPermissions && (
<Stack gap="xs" style={{ flex: 1 }}>
<Select
label="User Level"
data={Object.entries(USER_LEVELS).map(([label, value]) => {
return {
label: USER_LEVEL_LABELS[value],
value: `${value}`,
};
})}
{...form.getInputProps('user_level')}
key={form.key('user_level')}
/>
<MultiSelect
label="Channel Profiles"
{...form.getInputProps('channel_profiles')}
key={form.key('channel_profiles')}
data={Object.values(profiles)
.filter((profile) => profile.id != 0)
.map((profile) => ({
label: profile.name,
value: `${profile.id}`,
}))}
/>
</Stack>
<MultiSelect
label="Channel Profiles"
{...form.getInputProps('channel_profiles')}
key={form.key('channel_profiles')}
data={Object.values(profiles)
.filter((profile) => profile.id != 0)
.map((profile) => ({
label: profile.name,
value: `${profile.id}`,
}))}
/>
</Stack>
)}
</Group>
<Flex mih={50} gap="xs" justify="flex-end" align="flex-end">

View file

@ -3,6 +3,7 @@ import useChannelsStore from '../../store/channels';
import { notifications } from '@mantine/notifications';
import API from '../../api';
import ChannelForm from '../forms/Channel';
import ChannelBatchForm from '../forms/ChannelBatch';
import RecordingForm from '../forms/Recording';
import { useDebounce, copyToClipboard } from '../../utils';
import logo from '../../images/logo.png';
@ -268,6 +269,7 @@ const ChannelsTable = ({}) => {
*/
const [channel, setChannel] = useState(null);
const [channelModalOpen, setChannelModalOpen] = useState(false);
const [channelBatchModalOpen, setChannelBatchModalOpen] = useState(false);
const [recordingModalOpen, setRecordingModalOpen] = useState(false);
const [selectedProfile, setSelectedProfile] = useState(
profiles[selectedProfileId]
@ -365,8 +367,12 @@ const ChannelsTable = ({}) => {
};
const editChannel = async (ch = null) => {
setChannel(ch);
setChannelModalOpen(true);
if (selectedChannelIds.length > 0) {
setChannelBatchModalOpen(true);
} else {
setChannel(ch);
setChannelModalOpen(true);
}
};
const deleteChannel = async (id) => {
@ -480,6 +486,10 @@ const ChannelsTable = ({}) => {
});
};
const closeChannelBatchForm = () => {
setChannelBatchModalOpen(false);
};
const closeChannelForm = () => {
setChannel(null);
setChannelModalOpen(false);
@ -1041,6 +1051,12 @@ const ChannelsTable = ({}) => {
onClose={closeChannelForm}
/>
<ChannelBatchForm
channelIds={selectedChannelIds}
isOpen={channelBatchModalOpen}
onClose={closeChannelBatchForm}
/>
<RecordingForm
channel={channel}
isOpen={recordingModalOpen}

View file

@ -5,12 +5,14 @@ import {
Button,
Flex,
Group,
Menu,
NumberInput,
Popover,
Select,
Text,
TextInput,
Tooltip,
UnstyledButton,
useMantineTheme,
} from '@mantine/core';
import {
@ -18,7 +20,10 @@ import {
Binary,
Check,
CircleCheck,
Ellipsis,
EllipsisVertical,
SquareMinus,
SquarePen,
SquarePlus,
} from 'lucide-react';
import API from '../../../api';
@ -26,6 +31,7 @@ import { notifications } from '@mantine/notifications';
import useChannelsStore from '../../../store/channels';
import useAuthStore from '../../../store/auth';
import { USER_LEVELS } from '../../../constants';
import AssignChannelNumbersForm from '../../forms/AssignChannelNumbers';
const CreateProfilePopover = React.memo(() => {
const [opened, setOpened] = useState(false);
@ -96,12 +102,17 @@ const ChannelTableHeader = ({
const theme = useMantineTheme();
const [channelNumAssignmentStart, setChannelNumAssignmentStart] = useState(1);
const [assignNumbersModalOpen, setAssignNumbersModalOpen] = useState(false);
const profiles = useChannelsStore((s) => s.profiles);
const selectedProfileId = useChannelsStore((s) => s.selectedProfileId);
const setSelectedProfileId = useChannelsStore((s) => s.setSelectedProfileId);
const authUser = useAuthStore((s) => s.user);
const closeAssignChannelNumbersModal = () => {
setAssignNumbersModalOpen(false);
};
const deleteProfile = async (id) => {
await API.deleteChannelProfile(id);
};
@ -195,6 +206,19 @@ const ChannelTableHeader = ({
}}
>
<Flex gap={6}>
<Button
leftSection={<SquarePen size={18} />}
variant="default"
size="xs"
onClick={editChannel}
disabled={
selectedTableIds.length == 0 ||
authUser.user_level != USER_LEVELS.ADMIN
}
>
Edit
</Button>
<Button
leftSection={<SquareMinus size={18} />}
variant="default"
@ -205,60 +229,9 @@ const ChannelTableHeader = ({
authUser.user_level != USER_LEVELS.ADMIN
}
>
Remove
Delete
</Button>
<Tooltip label="Assign Channel #s">
<Popover withArrow shadow="md">
<Popover.Target>
<Button
leftSection={<ArrowDown01 size={18} />}
variant="default"
size="xs"
p={5}
disabled={
selectedTableIds.length == 0 ||
authUser.user_level != USER_LEVELS.ADMIN
}
>
Assign
</Button>
</Popover.Target>
<Popover.Dropdown>
<Group>
<Text>Start #</Text>
<NumberInput
value={channelNumAssignmentStart}
onChange={setChannelNumAssignmentStart}
size="small"
style={{ width: 50 }}
/>
<ActionIcon
size="xs"
color={theme.tailwind.green[5]}
variant="transparent"
onClick={assignChannels}
>
<Check />
</ActionIcon>
</Group>
</Popover.Dropdown>
</Popover>
</Tooltip>
<Tooltip label="Auto-Match EPG">
<Button
leftSection={<Binary size={18} />}
variant="default"
size="xs"
onClick={matchEpg}
p={5}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
>
Auto-Match
</Button>
</Tooltip>
<Button
leftSection={<SquarePlus size={18} />}
variant="light"
@ -277,8 +250,48 @@ const ChannelTableHeader = ({
>
Add
</Button>
<Menu>
<Menu.Target>
<ActionIcon variant="default" size={30}>
<EllipsisVertical size={18} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<ArrowDown01 size={18} />}
disabled={
selectedTableIds.length == 0 ||
authUser.user_level != USER_LEVELS.ADMIN
}
>
<UnstyledButton
size="xs"
onClick={() => setAssignNumbersModalOpen(true)}
>
<Text size="xs">Assign #s</Text>
</UnstyledButton>
</Menu.Item>
<Menu.Item
leftSection={<Binary size={18} />}
disabled={authUser.user_level != USER_LEVELS.ADMIN}
>
<UnstyledButton size="xs" onClick={matchEpg}>
<Text size="xs">Auto-Match</Text>
</UnstyledButton>
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Flex>
</Box>
<AssignChannelNumbersForm
channelIds={selectedTableIds}
isOpen={assignNumbersModalOpen}
onClose={closeAssignChannelNumbersModal}
/>
</Group>
);
};

View file

@ -5,9 +5,12 @@ import {
Box,
Button,
Center,
Divider,
Group,
Paper,
Select,
Stack,
Text,
useMantineTheme,
} from '@mantine/core';
import { SquareMinus, SquarePen, SquarePlus } from 'lucide-react';
@ -46,63 +49,74 @@ const UsersPage = () => {
<Center>
<Paper
style={{
minWidth: 400,
minWidth: 600,
padding: 10,
margin: 20,
}}
>
<Button
leftSection={<SquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editUser()}
p={5}
color="green"
style={{
borderWidth: '1px',
borderColor: 'green',
color: 'white',
}}
>
Add User
</Button>
{Object.values(users)
.sort((a, b) => a.id > b.id)
.map((user) => {
return (
<Group justify="space-between">
<Box flex={1} style={{ alignContent: 'flex-start' }}>
{user.username}
</Box>
<Stack>
<Box>
<Button
leftSection={<SquarePlus size={18} />}
variant="light"
size="xs"
onClick={() => editUser()}
p={5}
color="green"
style={{
borderWidth: '1px',
borderColor: 'green',
color: 'white',
}}
>
Add User
</Button>
</Box>
<Box flex={1} style={{ alignContent: 'flex-start' }}>
{user.email}
</Box>
{users
.sort((a, b) => a.id > b.id)
.map((user) => {
if (!user) {
return <></>;
}
{authUser.user_level == USER_LEVELS.ADMIN && (
<Group>
<ActionIcon
size={18}
variant="transparent"
color={theme.tailwind.yellow[3]}
onClick={() => editUser(user)}
>
<SquarePen size="18" />
</ActionIcon>
return (
<Group justify="space-between">
<Box flex={1} style={{ alignContent: 'flex-start' }}>
{user.username}
</Box>
<ActionIcon
size={18}
variant="transparent"
color={theme.tailwind.red[6]}
onClick={() => deleteUser(user.id)}
disabled={authUser.id === user.id}
>
<SquareMinus size="18" />
</ActionIcon>
</Group>
)}
</Group>
);
})}
<Box flex={1} style={{ alignContent: 'flex-start' }}>
{user.email}
</Box>
{authUser.user_level == USER_LEVELS.ADMIN && (
<Group>
<Text>{USER_LEVEL_LABELS[user.user_level]}</Text>
<ActionIcon
size={18}
variant="transparent"
color={theme.tailwind.yellow[3]}
onClick={() => editUser(user)}
>
<SquarePen size="18" />
</ActionIcon>
<ActionIcon
size={18}
variant="transparent"
color={theme.tailwind.red[6]}
onClick={() => deleteUser(user.id)}
disabled={authUser.id === user.id}
>
<SquareMinus size="18" />
</ActionIcon>
</Group>
)}
</Group>
);
})}
</Stack>
</Paper>
</Center>

View file

@ -2,7 +2,7 @@ import { create } from 'zustand';
import api from '../api';
const useUsersStore = create((set) => ({
users: {},
users: [],
isLoading: false,
error: null,