mirror of
https://github.com/inventree/InvenTree.git
synced 2025-05-02 21:38:48 +00:00
Added first UI components for user managment (#5875)
* Added first UI components for user managment Ref #4962 * Add user roles to table and serializer * added key to AddItem actions * added ordering to group * style text * do not show unnecessary options * fix admi / superuser usage * switched to use BooleanColumn * Added active column * added user role change action * added user active change action * Added api change log * fixed logical error * added admin center to navigation * added groups to user serializer * added groups to the uI * Added user drawer * fixed active state * remove actions as they are not usable after refactor * move functions to drawer * added drawer lock state * added edit toggle * merge fix * renamed values * remove empty roles section * fix settings header * make title shorter to reducelayout shift when switching to server settings
This commit is contained in:
parent
41296e4574
commit
33c02fcd78
@ -2,10 +2,15 @@
|
|||||||
|
|
||||||
|
|
||||||
# InvenTree API version
|
# InvenTree API version
|
||||||
INVENTREE_API_VERSION = 149
|
INVENTREE_API_VERSION = 150
|
||||||
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
INVENTREE_API_TEXT = """
|
||||||
|
v150 -> 2023-11-07: https://github.com/inventree/InvenTree/pull/5875
|
||||||
|
- Extended user API endpoints to enable ordering
|
||||||
|
- Extended user API endpoints to enable user role changes
|
||||||
|
- Added endpoint to create a new user
|
||||||
|
|
||||||
v149 -> 2023-11-07 : https://github.com/inventree/InvenTree/pull/5876
|
v149 -> 2023-11-07 : https://github.com/inventree/InvenTree/pull/5876
|
||||||
- Add 'building' quantity to BomItem serializer
|
- Add 'building' quantity to BomItem serializer
|
||||||
- Add extra ordering options for the BomItem list API
|
- Add extra ordering options for the BomItem list API
|
||||||
|
@ -6,6 +6,7 @@ from decimal import Decimal
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.contrib.sites.models import Site
|
||||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@ -15,7 +16,7 @@ from djmoney.contrib.django_rest_framework.fields import MoneyField
|
|||||||
from djmoney.money import Money
|
from djmoney.money import Money
|
||||||
from djmoney.utils import MONEY_CLASSES, get_currency_field_name
|
from djmoney.utils import MONEY_CLASSES, get_currency_field_name
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.exceptions import ValidationError
|
from rest_framework.exceptions import PermissionDenied, ValidationError
|
||||||
from rest_framework.fields import empty
|
from rest_framework.fields import empty
|
||||||
from rest_framework.serializers import DecimalField
|
from rest_framework.serializers import DecimalField
|
||||||
from rest_framework.utils import model_meta
|
from rest_framework.utils import model_meta
|
||||||
@ -302,6 +303,72 @@ class UserSerializer(InvenTreeModelSerializer):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ExendedUserSerializer(UserSerializer):
|
||||||
|
"""Serializer for a User with a bit more info."""
|
||||||
|
from users.serializers import GroupSerializer
|
||||||
|
|
||||||
|
groups = GroupSerializer(read_only=True, many=True)
|
||||||
|
|
||||||
|
class Meta(UserSerializer.Meta):
|
||||||
|
"""Metaclass defines serializer fields."""
|
||||||
|
fields = UserSerializer.Meta.fields + [
|
||||||
|
'groups',
|
||||||
|
'is_staff',
|
||||||
|
'is_superuser',
|
||||||
|
'is_active'
|
||||||
|
]
|
||||||
|
|
||||||
|
read_only_fields = UserSerializer.Meta.read_only_fields + [
|
||||||
|
'groups',
|
||||||
|
]
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
"""Expanded validation for changing user role."""
|
||||||
|
# Check if is_staff or is_superuser is in attrs
|
||||||
|
role_change = 'is_staff' in attrs or 'is_superuser' in attrs
|
||||||
|
request_user = self.context['request'].user
|
||||||
|
|
||||||
|
if role_change:
|
||||||
|
if request_user.is_superuser:
|
||||||
|
# Superusers can change any role
|
||||||
|
pass
|
||||||
|
elif request_user.is_staff and 'is_superuser' not in attrs:
|
||||||
|
# Staff can change any role except is_superuser
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise PermissionDenied(_("You do not have permission to change this user role."))
|
||||||
|
return super().validate(attrs)
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreateSerializer(ExendedUserSerializer):
|
||||||
|
"""Serializer for creating a new User."""
|
||||||
|
def validate(self, attrs):
|
||||||
|
"""Expanded valiadation for auth."""
|
||||||
|
# Check that the user trying to create a new user is a superuser
|
||||||
|
if not self.context['request'].user.is_superuser:
|
||||||
|
raise serializers.ValidationError(_("Only superusers can create new users"))
|
||||||
|
|
||||||
|
# Generate a random password
|
||||||
|
password = User.objects.make_random_password(length=14)
|
||||||
|
attrs.update({'password': password})
|
||||||
|
return super().validate(attrs)
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
"""Send an e email to the user after creation."""
|
||||||
|
instance = super().create(validated_data)
|
||||||
|
|
||||||
|
# Make sure the user cannot login until they have set a password
|
||||||
|
instance.set_unusable_password()
|
||||||
|
# Send the user an onboarding email (from current site)
|
||||||
|
current_site = Site.objects.get_current()
|
||||||
|
domain = current_site.domain
|
||||||
|
instance.email_user(
|
||||||
|
subject=_(f"Welcome to {current_site.name}"),
|
||||||
|
message=_(f"Your account has been created.\n\nPlease use the password reset function to get access (at https://{domain})."),
|
||||||
|
)
|
||||||
|
return instance
|
||||||
|
|
||||||
|
|
||||||
class InvenTreeAttachmentSerializerField(serializers.FileField):
|
class InvenTreeAttachmentSerializerField(serializers.FileField):
|
||||||
"""Override the DRF native FileField serializer, to remove the leading server path.
|
"""Override the DRF native FileField serializer, to remove the leading server path.
|
||||||
|
|
||||||
|
@ -6,14 +6,14 @@ import logging
|
|||||||
from django.contrib.auth.models import Group, User
|
from django.contrib.auth.models import Group, User
|
||||||
from django.urls import include, path, re_path
|
from django.urls import include, path, re_path
|
||||||
|
|
||||||
from django_filters.rest_framework import DjangoFilterBackend
|
|
||||||
from rest_framework import exceptions, permissions
|
from rest_framework import exceptions, permissions
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
|
|
||||||
from InvenTree.filters import InvenTreeSearchFilter
|
from InvenTree.filters import SEARCH_ORDER_FILTER
|
||||||
from InvenTree.mixins import ListAPI, RetrieveAPI, RetrieveUpdateAPI
|
from InvenTree.mixins import (ListAPI, ListCreateAPI, RetrieveAPI,
|
||||||
from InvenTree.serializers import UserSerializer
|
RetrieveUpdateAPI, RetrieveUpdateDestroyAPI)
|
||||||
|
from InvenTree.serializers import ExendedUserSerializer, UserCreateSerializer
|
||||||
from users.models import ApiToken, Owner, RuleSet, check_user_role
|
from users.models import ApiToken, Owner, RuleSet, check_user_role
|
||||||
from users.serializers import GroupSerializer, OwnerSerializer
|
from users.serializers import GroupSerializer, OwnerSerializer
|
||||||
|
|
||||||
@ -112,11 +112,11 @@ class RoleDetails(APIView):
|
|||||||
return Response(data)
|
return Response(data)
|
||||||
|
|
||||||
|
|
||||||
class UserDetail(RetrieveAPI):
|
class UserDetail(RetrieveUpdateDestroyAPI):
|
||||||
"""Detail endpoint for a single user."""
|
"""Detail endpoint for a single user."""
|
||||||
|
|
||||||
queryset = User.objects.all()
|
queryset = User.objects.all()
|
||||||
serializer_class = UserSerializer
|
serializer_class = ExendedUserSerializer
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
permissions.IsAuthenticated
|
permissions.IsAuthenticated
|
||||||
]
|
]
|
||||||
@ -130,19 +130,15 @@ class MeUserDetail(RetrieveUpdateAPI, UserDetail):
|
|||||||
return self.request.user
|
return self.request.user
|
||||||
|
|
||||||
|
|
||||||
class UserList(ListAPI):
|
class UserList(ListCreateAPI):
|
||||||
"""List endpoint for detail on all users."""
|
"""List endpoint for detail on all users."""
|
||||||
|
|
||||||
queryset = User.objects.all()
|
queryset = User.objects.all()
|
||||||
serializer_class = UserSerializer
|
serializer_class = UserCreateSerializer
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
permissions.IsAuthenticated,
|
permissions.IsAuthenticated,
|
||||||
]
|
]
|
||||||
|
filter_backends = SEARCH_ORDER_FILTER
|
||||||
filter_backends = [
|
|
||||||
DjangoFilterBackend,
|
|
||||||
InvenTreeSearchFilter,
|
|
||||||
]
|
|
||||||
|
|
||||||
search_fields = [
|
search_fields = [
|
||||||
'first_name',
|
'first_name',
|
||||||
@ -150,8 +146,18 @@ class UserList(ListAPI):
|
|||||||
'username',
|
'username',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
ordering_fields = [
|
||||||
|
'email',
|
||||||
|
'username',
|
||||||
|
'first_name',
|
||||||
|
'last_name',
|
||||||
|
'is_staff',
|
||||||
|
'is_superuser',
|
||||||
|
'is_active',
|
||||||
|
]
|
||||||
|
|
||||||
class GroupDetail(RetrieveAPI):
|
|
||||||
|
class GroupDetail(RetrieveUpdateDestroyAPI):
|
||||||
"""Detail endpoint for a particular auth group"""
|
"""Detail endpoint for a particular auth group"""
|
||||||
|
|
||||||
queryset = Group.objects.all()
|
queryset = Group.objects.all()
|
||||||
@ -161,7 +167,7 @@ class GroupDetail(RetrieveAPI):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class GroupList(ListAPI):
|
class GroupList(ListCreateAPI):
|
||||||
"""List endpoint for all auth groups"""
|
"""List endpoint for all auth groups"""
|
||||||
|
|
||||||
queryset = Group.objects.all()
|
queryset = Group.objects.all()
|
||||||
@ -170,15 +176,16 @@ class GroupList(ListAPI):
|
|||||||
permissions.IsAuthenticated,
|
permissions.IsAuthenticated,
|
||||||
]
|
]
|
||||||
|
|
||||||
filter_backends = [
|
filter_backends = SEARCH_ORDER_FILTER
|
||||||
DjangoFilterBackend,
|
|
||||||
InvenTreeSearchFilter,
|
|
||||||
]
|
|
||||||
|
|
||||||
search_fields = [
|
search_fields = [
|
||||||
'name',
|
'name',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
ordering_fields = [
|
||||||
|
'name',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class GetAuthToken(APIView):
|
class GetAuthToken(APIView):
|
||||||
"""Return authentication token for an authenticated user."""
|
"""Return authentication token for an authenticated user."""
|
||||||
|
@ -1,18 +1,8 @@
|
|||||||
import {
|
import { Anchor, Group, Stack, Text, Title } from '@mantine/core';
|
||||||
Anchor,
|
|
||||||
Button,
|
|
||||||
Group,
|
|
||||||
Paper,
|
|
||||||
Space,
|
|
||||||
Stack,
|
|
||||||
Text
|
|
||||||
} from '@mantine/core';
|
|
||||||
import { IconSwitch } from '@tabler/icons-react';
|
import { IconSwitch } from '@tabler/icons-react';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import { StylishText } from '../items/StylishText';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construct a settings page header with interlinks to one other settings page
|
* Construct a settings page header with interlinks to one other settings page
|
||||||
*/
|
*/
|
||||||
@ -24,35 +14,28 @@ export function SettingsHeader({
|
|||||||
switch_text,
|
switch_text,
|
||||||
switch_link
|
switch_link
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string | ReactNode;
|
||||||
shorthand?: string;
|
shorthand?: string;
|
||||||
subtitle?: string;
|
subtitle?: string | ReactNode;
|
||||||
switch_condition?: boolean;
|
switch_condition?: boolean;
|
||||||
switch_text?: string | ReactNode;
|
switch_text?: string | ReactNode;
|
||||||
switch_link?: string;
|
switch_link?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Paper shadow="xs" radius="xs" p="xs">
|
<Stack spacing="0" ml={'sm'}>
|
||||||
<Group position="apart">
|
<Group>
|
||||||
<Stack spacing="xs">
|
<Title order={3}>{title}</Title>
|
||||||
<Group position="left" spacing="xs">
|
{shorthand && <Text c="dimmed">({shorthand})</Text>}
|
||||||
<StylishText size="xl">{title}</StylishText>
|
</Group>
|
||||||
<Text size="sm">{shorthand}</Text>
|
<Group>
|
||||||
</Group>
|
<Text c="dimmed">{subtitle}</Text>
|
||||||
<Text italic>{subtitle}</Text>
|
|
||||||
</Stack>
|
|
||||||
<Space />
|
|
||||||
{switch_text && switch_link && switch_condition && (
|
{switch_text && switch_link && switch_condition && (
|
||||||
<Anchor component={Link} to={switch_link}>
|
<Anchor component={Link} to={switch_link}>
|
||||||
<Button variant="outline">
|
<IconSwitch size={14} />
|
||||||
<Group spacing="sm">
|
{switch_text}
|
||||||
<IconSwitch size={18} />
|
|
||||||
<Text>{switch_text}</Text>
|
|
||||||
</Group>
|
|
||||||
</Button>
|
|
||||||
</Anchor>
|
</Anchor>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
102
src/frontend/src/components/tables/settings/GroupTable.tsx
Normal file
102
src/frontend/src/components/tables/settings/GroupTable.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { Text } from '@mantine/core';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { ApiPaths } from '../../../enums/ApiEndpoints';
|
||||||
|
import {
|
||||||
|
openCreateApiForm,
|
||||||
|
openDeleteApiForm,
|
||||||
|
openEditApiForm
|
||||||
|
} from '../../../functions/forms';
|
||||||
|
import { useTableRefresh } from '../../../hooks/TableRefresh';
|
||||||
|
import { apiUrl } from '../../../states/ApiState';
|
||||||
|
import { AddItemButton } from '../../buttons/AddItemButton';
|
||||||
|
import { TableColumn } from '../Column';
|
||||||
|
import { InvenTreeTable } from '../InvenTreeTable';
|
||||||
|
import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table for displaying list of groups
|
||||||
|
*/
|
||||||
|
export function GroupTable() {
|
||||||
|
const { tableKey, refreshTable } = useTableRefresh('groups');
|
||||||
|
|
||||||
|
const columns: TableColumn[] = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
accessor: 'name',
|
||||||
|
sortable: true,
|
||||||
|
title: t`Name`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const rowActions = useCallback((record: any): RowAction[] => {
|
||||||
|
return [
|
||||||
|
RowEditAction({
|
||||||
|
onClick: () => {
|
||||||
|
openEditApiForm({
|
||||||
|
url: ApiPaths.group_list,
|
||||||
|
pk: record.pk,
|
||||||
|
title: t`Edit group`,
|
||||||
|
fields: {
|
||||||
|
name: {}
|
||||||
|
},
|
||||||
|
onFormSuccess: refreshTable,
|
||||||
|
successMessage: t`Group updated`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
RowDeleteAction({
|
||||||
|
onClick: () => {
|
||||||
|
openDeleteApiForm({
|
||||||
|
url: ApiPaths.group_list,
|
||||||
|
pk: record.pk,
|
||||||
|
title: t`Delete group`,
|
||||||
|
successMessage: t`Group deleted`,
|
||||||
|
onFormSuccess: refreshTable,
|
||||||
|
preFormContent: (
|
||||||
|
<Text>{t`Are you sure you want to delete this group?`}</Text>
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addGroup = useCallback(() => {
|
||||||
|
openCreateApiForm({
|
||||||
|
url: ApiPaths.group_list,
|
||||||
|
title: t`Add group`,
|
||||||
|
fields: { name: {} },
|
||||||
|
onFormSuccess: refreshTable,
|
||||||
|
successMessage: t`Added group`
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const tableActions = useMemo(() => {
|
||||||
|
let actions = [];
|
||||||
|
|
||||||
|
actions.push(
|
||||||
|
<AddItemButton
|
||||||
|
key={'add-group'}
|
||||||
|
onClick={addGroup}
|
||||||
|
tooltip={t`Add group`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<InvenTreeTable
|
||||||
|
url={apiUrl(ApiPaths.group_list)}
|
||||||
|
tableKey={tableKey}
|
||||||
|
columns={columns}
|
||||||
|
props={{
|
||||||
|
rowActions: rowActions,
|
||||||
|
customActionGroups: tableActions
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
217
src/frontend/src/components/tables/settings/UserDrawer.tsx
Normal file
217
src/frontend/src/components/tables/settings/UserDrawer.tsx
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
import { Trans, t } from '@lingui/macro';
|
||||||
|
import {
|
||||||
|
Chip,
|
||||||
|
Drawer,
|
||||||
|
Group,
|
||||||
|
List,
|
||||||
|
Loader,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Title
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { useToggle } from '@mantine/hooks';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import { IconCheck } from '@tabler/icons-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { api } from '../../../App';
|
||||||
|
import { ApiPaths } from '../../../enums/ApiEndpoints';
|
||||||
|
import {
|
||||||
|
invalidResponse,
|
||||||
|
permissionDenied
|
||||||
|
} from '../../../functions/notifications';
|
||||||
|
import { apiUrl } from '../../../states/ApiState';
|
||||||
|
import { useUserState } from '../../../states/UserState';
|
||||||
|
import { EditButton } from '../../items/EditButton';
|
||||||
|
import { UserDetailI } from './UserTable';
|
||||||
|
|
||||||
|
export function UserDrawer({
|
||||||
|
opened,
|
||||||
|
close,
|
||||||
|
refreshTable,
|
||||||
|
userDetail
|
||||||
|
}: {
|
||||||
|
opened: boolean;
|
||||||
|
close: () => void;
|
||||||
|
refreshTable: () => void;
|
||||||
|
userDetail: UserDetailI | undefined;
|
||||||
|
}) {
|
||||||
|
const [user] = useUserState((state) => [state.user]);
|
||||||
|
const [rightsValue, setRightsValue] = useState(['']);
|
||||||
|
const [locked, setLocked] = useState<boolean>(false);
|
||||||
|
const [userEditing, setUserEditing] = useToggle([false, true] as const);
|
||||||
|
|
||||||
|
// Set initial values
|
||||||
|
useEffect(() => {
|
||||||
|
if (!userDetail) return;
|
||||||
|
|
||||||
|
setLocked(true);
|
||||||
|
// rights
|
||||||
|
let new_rights = [];
|
||||||
|
if (userDetail.is_staff) {
|
||||||
|
new_rights.push('is_staff');
|
||||||
|
}
|
||||||
|
if (userDetail.is_active) {
|
||||||
|
new_rights.push('is_active');
|
||||||
|
}
|
||||||
|
if (userDetail.is_superuser) {
|
||||||
|
new_rights.push('is_superuser');
|
||||||
|
}
|
||||||
|
setRightsValue(new_rights);
|
||||||
|
|
||||||
|
setLocked(false);
|
||||||
|
}, [userDetail]);
|
||||||
|
|
||||||
|
// actions on role change
|
||||||
|
function changeRights(roles: [string]) {
|
||||||
|
if (!userDetail) return;
|
||||||
|
|
||||||
|
let data = {
|
||||||
|
is_staff: roles.includes('is_staff'),
|
||||||
|
is_superuser: roles.includes('is_superuser')
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
data.is_staff != userDetail.is_staff ||
|
||||||
|
data.is_superuser != userDetail.is_superuser
|
||||||
|
) {
|
||||||
|
setPermission(userDetail.pk, data);
|
||||||
|
}
|
||||||
|
if (userDetail.is_active != roles.includes('is_active')) {
|
||||||
|
setActive(userDetail.pk, roles.includes('is_active'));
|
||||||
|
}
|
||||||
|
setRightsValue(roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPermission(pk: number, data: any) {
|
||||||
|
setLocked(true);
|
||||||
|
api
|
||||||
|
.patch(`${apiUrl(ApiPaths.user_list)}${pk}/`, data)
|
||||||
|
.then(() => {
|
||||||
|
notifications.show({
|
||||||
|
title: t`User permission changed successfully`,
|
||||||
|
message: t`Some changes might only take effect after the user refreshes their login.`,
|
||||||
|
color: 'green',
|
||||||
|
icon: <IconCheck size="1rem" />
|
||||||
|
});
|
||||||
|
refreshTable();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (error.response.status === 403) {
|
||||||
|
permissionDenied();
|
||||||
|
} else {
|
||||||
|
console.log(error);
|
||||||
|
invalidResponse(error.response.status);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setLocked(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActive(pk: number, active: boolean) {
|
||||||
|
setLocked(true);
|
||||||
|
api
|
||||||
|
.patch(`${apiUrl(ApiPaths.user_list)}${pk}/`, {
|
||||||
|
is_active: active
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
notifications.show({
|
||||||
|
title: t`Changed user active status successfully`,
|
||||||
|
message: t`Set to ${active}`,
|
||||||
|
color: 'green',
|
||||||
|
icon: <IconCheck size="1rem" />
|
||||||
|
});
|
||||||
|
refreshTable();
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (error.response.status === 403) {
|
||||||
|
permissionDenied();
|
||||||
|
} else {
|
||||||
|
console.log(error);
|
||||||
|
invalidResponse(error.response.status);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setLocked(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
const userEditable = locked || !userEditing;
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
opened={opened}
|
||||||
|
onClose={close}
|
||||||
|
position="right"
|
||||||
|
title={userDetail ? t`User details for ${userDetail.username}` : ''}
|
||||||
|
overlayProps={{ opacity: 0.5, blur: 4 }}
|
||||||
|
>
|
||||||
|
<Stack spacing={'xs'}>
|
||||||
|
<Group>
|
||||||
|
<Title order={5}>
|
||||||
|
<Trans>Details</Trans>
|
||||||
|
</Title>
|
||||||
|
<EditButton
|
||||||
|
editing={userEditing}
|
||||||
|
setEditing={setUserEditing}
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
{userDetail ? (
|
||||||
|
<Stack spacing={0} ml={'md'}>
|
||||||
|
<TextInput
|
||||||
|
label={t`Username`}
|
||||||
|
value={userDetail.username}
|
||||||
|
disabled={userEditable}
|
||||||
|
/>
|
||||||
|
<TextInput label={t`Email`} value={userDetail.email} disabled />
|
||||||
|
<TextInput
|
||||||
|
label={t`First Name`}
|
||||||
|
value={userDetail.first_name}
|
||||||
|
disabled={userEditable}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label={t`Last Name`}
|
||||||
|
value={userDetail.last_name}
|
||||||
|
disabled={userEditable}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text>
|
||||||
|
<Trans>Rights</Trans>
|
||||||
|
</Text>
|
||||||
|
<Chip.Group multiple value={rightsValue} onChange={changeRights}>
|
||||||
|
<Group spacing={0}>
|
||||||
|
<Chip value="is_active" disabled={locked || !user?.is_staff}>
|
||||||
|
<Trans>Active</Trans>
|
||||||
|
</Chip>
|
||||||
|
<Chip value="is_staff" disabled={locked || !user?.is_staff}>
|
||||||
|
<Trans>Staff</Trans>
|
||||||
|
</Chip>
|
||||||
|
<Chip
|
||||||
|
value="is_superuser"
|
||||||
|
disabled={locked || !(user?.is_staff && user?.is_superuser)}
|
||||||
|
>
|
||||||
|
<Trans>Superuser</Trans>
|
||||||
|
</Chip>
|
||||||
|
</Group>
|
||||||
|
</Chip.Group>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Loader />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Title order={5}>
|
||||||
|
<Trans>Groups</Trans>
|
||||||
|
</Title>
|
||||||
|
<Text ml={'md'}>
|
||||||
|
{userDetail && userDetail.groups.length == 0 ? (
|
||||||
|
<Trans>No groups</Trans>
|
||||||
|
) : (
|
||||||
|
<List>
|
||||||
|
{userDetail &&
|
||||||
|
userDetail.groups.map((message) => (
|
||||||
|
<List.Item key={message.name}>{message.name}</List.Item>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Drawer>
|
||||||
|
);
|
||||||
|
}
|
176
src/frontend/src/components/tables/settings/UserTable.tsx
Normal file
176
src/frontend/src/components/tables/settings/UserTable.tsx
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { Text } from '@mantine/core';
|
||||||
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { ApiPaths } from '../../../enums/ApiEndpoints';
|
||||||
|
import {
|
||||||
|
openCreateApiForm,
|
||||||
|
openDeleteApiForm,
|
||||||
|
openEditApiForm
|
||||||
|
} from '../../../functions/forms';
|
||||||
|
import { useTableRefresh } from '../../../hooks/TableRefresh';
|
||||||
|
import { apiUrl } from '../../../states/ApiState';
|
||||||
|
import { AddItemButton } from '../../buttons/AddItemButton';
|
||||||
|
import { TableColumn } from '../Column';
|
||||||
|
import { BooleanColumn } from '../ColumnRenderers';
|
||||||
|
import { InvenTreeTable } from '../InvenTreeTable';
|
||||||
|
import { RowAction, RowDeleteAction, RowEditAction } from '../RowActions';
|
||||||
|
import { UserDrawer } from './UserDrawer';
|
||||||
|
|
||||||
|
interface GroupDetailI {
|
||||||
|
pk: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserDetailI {
|
||||||
|
pk: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
groups: GroupDetailI[];
|
||||||
|
is_active: boolean;
|
||||||
|
is_staff: boolean;
|
||||||
|
is_superuser: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Table for displaying list of users
|
||||||
|
*/
|
||||||
|
export function UserTable() {
|
||||||
|
const { tableKey, refreshTable } = useTableRefresh('users');
|
||||||
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
const [userDetail, setUserDetail] = useState<UserDetailI>();
|
||||||
|
|
||||||
|
const columns: TableColumn[] = useMemo(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
accessor: 'email',
|
||||||
|
sortable: true,
|
||||||
|
title: t`Email`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: 'username',
|
||||||
|
sortable: true,
|
||||||
|
switchable: false,
|
||||||
|
title: t`Username`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: 'first_name',
|
||||||
|
sortable: true,
|
||||||
|
title: t`First Name`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: 'last_name',
|
||||||
|
sortable: true,
|
||||||
|
title: t`Last Name`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessor: 'groups',
|
||||||
|
sortable: true,
|
||||||
|
switchable: true,
|
||||||
|
title: t`Groups`,
|
||||||
|
render: (record: any) => {
|
||||||
|
return record.groups.length;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
BooleanColumn({
|
||||||
|
accessor: 'is_staff',
|
||||||
|
title: t`Staff`
|
||||||
|
}),
|
||||||
|
BooleanColumn({
|
||||||
|
accessor: 'is_superuser',
|
||||||
|
title: t`Superuser`
|
||||||
|
}),
|
||||||
|
BooleanColumn({
|
||||||
|
accessor: 'is_active',
|
||||||
|
title: t`Active`
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const rowActions = useCallback((record: UserDetailI): RowAction[] => {
|
||||||
|
return [
|
||||||
|
RowEditAction({
|
||||||
|
onClick: () => {
|
||||||
|
openEditApiForm({
|
||||||
|
url: ApiPaths.user_list,
|
||||||
|
pk: record.pk,
|
||||||
|
title: t`Edit user`,
|
||||||
|
fields: {
|
||||||
|
email: {},
|
||||||
|
first_name: {},
|
||||||
|
last_name: {}
|
||||||
|
},
|
||||||
|
onFormSuccess: refreshTable,
|
||||||
|
successMessage: t`User updated`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
RowDeleteAction({
|
||||||
|
onClick: () => {
|
||||||
|
openDeleteApiForm({
|
||||||
|
url: ApiPaths.user_list,
|
||||||
|
pk: record.pk,
|
||||||
|
title: t`Delete user`,
|
||||||
|
successMessage: t`user deleted`,
|
||||||
|
onFormSuccess: refreshTable,
|
||||||
|
preFormContent: (
|
||||||
|
<Text>{t`Are you sure you want to delete this user?`}</Text>
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const addUser = useCallback(() => {
|
||||||
|
openCreateApiForm({
|
||||||
|
url: ApiPaths.user_list,
|
||||||
|
title: t`Add user`,
|
||||||
|
fields: {
|
||||||
|
username: {},
|
||||||
|
email: {},
|
||||||
|
first_name: {},
|
||||||
|
last_name: {}
|
||||||
|
},
|
||||||
|
onFormSuccess: refreshTable,
|
||||||
|
successMessage: t`Added user`
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const tableActions = useMemo(() => {
|
||||||
|
let actions = [];
|
||||||
|
|
||||||
|
actions.push(
|
||||||
|
<AddItemButton key="add-user" onClick={addUser} tooltip={t`Add user`} />
|
||||||
|
);
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<UserDrawer
|
||||||
|
opened={opened}
|
||||||
|
close={close}
|
||||||
|
refreshTable={refreshTable}
|
||||||
|
userDetail={userDetail}
|
||||||
|
/>
|
||||||
|
<InvenTreeTable
|
||||||
|
url={apiUrl(ApiPaths.user_list)}
|
||||||
|
tableKey={tableKey}
|
||||||
|
columns={columns}
|
||||||
|
props={{
|
||||||
|
rowActions: rowActions,
|
||||||
|
customActionGroups: tableActions,
|
||||||
|
onRowClick: (record: any) => {
|
||||||
|
setUserDetail(record);
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -57,6 +57,11 @@ export const menuItems: MenuLinkItem[] = [
|
|||||||
id: 'settings-system',
|
id: 'settings-system',
|
||||||
text: <Trans>System Settings</Trans>,
|
text: <Trans>System Settings</Trans>,
|
||||||
link: '/settings/system'
|
link: '/settings/system'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'settings-admin',
|
||||||
|
text: <Trans>Admin Center</Trans>,
|
||||||
|
link: '/settings/admin'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -21,6 +21,7 @@ export enum ApiPaths {
|
|||||||
user_email_remove = 'api-user-email-remove',
|
user_email_remove = 'api-user-email-remove',
|
||||||
|
|
||||||
user_list = 'api-user-list',
|
user_list = 'api-user-list',
|
||||||
|
group_list = 'api-group-list',
|
||||||
owner_list = 'api-owner-list',
|
owner_list = 'api-owner-list',
|
||||||
|
|
||||||
settings_global_list = 'api-settings-global-list',
|
settings_global_list = 'api-settings-global-list',
|
||||||
|
@ -16,6 +16,8 @@ import { PlaceholderPill } from '../../../components/items/Placeholder';
|
|||||||
import { PanelGroup, PanelType } from '../../../components/nav/PanelGroup';
|
import { PanelGroup, PanelType } from '../../../components/nav/PanelGroup';
|
||||||
import { SettingsHeader } from '../../../components/nav/SettingsHeader';
|
import { SettingsHeader } from '../../../components/nav/SettingsHeader';
|
||||||
import { GlobalSettingList } from '../../../components/settings/SettingList';
|
import { GlobalSettingList } from '../../../components/settings/SettingList';
|
||||||
|
import { GroupTable } from '../../../components/tables/settings/GroupTable';
|
||||||
|
import { UserTable } from '../../../components/tables/settings/UserTable';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* System settings page
|
* System settings page
|
||||||
@ -28,7 +30,14 @@ export default function AdminCenter() {
|
|||||||
label: t`User Management`,
|
label: t`User Management`,
|
||||||
content: (
|
content: (
|
||||||
<Stack spacing="xs">
|
<Stack spacing="xs">
|
||||||
<PlaceholderPill />
|
<Title order={5}>
|
||||||
|
<Trans>Users</Trans>
|
||||||
|
</Title>
|
||||||
|
<UserTable />
|
||||||
|
<Title order={5}>
|
||||||
|
<Trans>Groups</Trans>
|
||||||
|
</Title>
|
||||||
|
<GroupTable />
|
||||||
<Divider />
|
<Divider />
|
||||||
<Stack spacing={0}>
|
<Stack spacing={0}>
|
||||||
<Text>
|
<Text>
|
||||||
@ -87,7 +96,7 @@ export default function AdminCenter() {
|
|||||||
<Stack spacing="xs">
|
<Stack spacing="xs">
|
||||||
<SettingsHeader
|
<SettingsHeader
|
||||||
title={t`Admin Center`}
|
title={t`Admin Center`}
|
||||||
subtitle={t`Advanced Amininistrative Options for InvenTree`}
|
subtitle={t`Advanced Options`}
|
||||||
switch_link="/settings/system"
|
switch_link="/settings/system"
|
||||||
switch_text="System Settings"
|
switch_text="System Settings"
|
||||||
/>
|
/>
|
||||||
|
@ -116,6 +116,12 @@ export function apiEndpoint(path: ApiPaths): string {
|
|||||||
return 'version/';
|
return 'version/';
|
||||||
case ApiPaths.sso_providers:
|
case ApiPaths.sso_providers:
|
||||||
return 'auth/providers/';
|
return 'auth/providers/';
|
||||||
|
case ApiPaths.user_list:
|
||||||
|
return 'user/';
|
||||||
|
case ApiPaths.group_list:
|
||||||
|
return 'user/group/';
|
||||||
|
case ApiPaths.owner_list:
|
||||||
|
return 'user/owner/';
|
||||||
case ApiPaths.build_order_list:
|
case ApiPaths.build_order_list:
|
||||||
return 'build/';
|
return 'build/';
|
||||||
case ApiPaths.build_order_attachment_list:
|
case ApiPaths.build_order_attachment_list:
|
||||||
|
Loading…
x
Reference in New Issue
Block a user