2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +00:00

[PUI] User settings panel updates (#7944)

* Simplify user theme settings

* Cleanup

* Fix permission on user list endpoint

* Update AccountDetailPanel to use modal form

* Update components

* UI updates

* Implement default colors

* Display more user details (read only)

* Add specific "MeUserSerializer"

- Prevent certain attributes from being adjusted

* Add <YesNoUndefinedButton>

* Allow role checks to be bypassed for a given view

- Override the 'get_permission_model' attribute with None

* Enable 'GET' metadata

- Required for extracting field information even if we only have 'read' permissions
- e.g. getting table columns for users without write perms
- use 'GET' action when reading table cols

* Add info on new user account

* Fix boolean expression wrapper

* Ruff fixes

* Adjust icon

* Update unit test

* Bummp API version

* Table layout fix
This commit is contained in:
Oliver 2024-09-14 12:30:46 +10:00 committed by GitHub
parent a5ab4a30ea
commit 7fbc1fba72
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 311 additions and 155 deletions

View File

@ -16,6 +16,7 @@ The demo instance has a number of user accounts which you can use to explore the
| Username | Password | Staff Access | Enabled | Description |
| -------- | -------- | ------------ | ------- | ----------- |
| noaccess | youshallnotpass | No | Yes | Can login, but has no permissions |
| allaccess | nolimits | No | Yes | View / create / edit all pages and items |
| reader | readonly | No | Yes | Can view all pages but cannot create, edit or delete database records |
| engineer | partsonly | No | Yes | Can manage parts, view stock, but no access to purchase orders or sales orders |

View File

@ -1,14 +1,17 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 252
INVENTREE_API_VERSION = 253
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v252 - 2024-09-30 : https://github.com/inventree/InvenTree/pull/8035
v253 - 2024-09-14 : https://github.com/inventree/InvenTree/pull/7944
- Adjustments for user API endpoints
v252 - 2024-09-13 : https://github.com/inventree/InvenTree/pull/8040
- Add endpoint for listing all known units
v251 - 2024-09-06 : https://github.com/inventree/InvenTree/pull/8018

View File

@ -2,9 +2,13 @@
import logging
from rest_framework import serializers
from django.core.exceptions import PermissionDenied
from django.http import Http404
from rest_framework import exceptions, serializers
from rest_framework.fields import empty
from rest_framework.metadata import SimpleMetadata
from rest_framework.request import clone_request
from rest_framework.utils import model_meta
import common.models
@ -29,6 +33,40 @@ class InvenTreeMetadata(SimpleMetadata):
so we can perform lookup for ForeignKey related fields.
"""
def determine_actions(self, request, view):
"""Determine the 'actions' available to the user for the given view.
Note that this differs from the standard DRF implementation,
in that we also allow annotation for the 'GET' method.
This allows the client to determine what fields are available,
even if they are only for a read (GET) operation.
See SimpleMetadata.determine_actions for more information.
"""
actions = {}
for method in {'PUT', 'POST', 'GET'} & set(view.allowed_methods):
view.request = clone_request(request, method)
try:
# Test global permissions
if hasattr(view, 'check_permissions'):
view.check_permissions(view.request)
# Test object permissions
if method == 'PUT' and hasattr(view, 'get_object'):
view.get_object()
except (exceptions.APIException, PermissionDenied, Http404):
pass
else:
# If user has appropriate permissions for the view, include
# appropriate metadata about the fields that should be supplied.
serializer = view.get_serializer()
actions[method] = self.get_serializer_info(serializer)
finally:
view.request = request
return actions
def determine_metadata(self, request, view):
"""Overwrite the metadata to adapt to the request user."""
self.request = request
@ -81,6 +119,7 @@ class InvenTreeMetadata(SimpleMetadata):
# Map the request method to a permission type
rolemap = {
'GET': 'view',
'POST': 'add',
'PUT': 'change',
'PATCH': 'change',
@ -102,10 +141,6 @@ class InvenTreeMetadata(SimpleMetadata):
if 'DELETE' in view.allowed_methods and check(user, table, 'delete'):
actions['DELETE'] = {}
# Add a 'VIEW' action if we are allowed to view
if 'GET' in view.allowed_methods and check(user, table, 'view'):
actions['GET'] = {}
metadata['actions'] = actions
except AttributeError:

View File

@ -79,6 +79,9 @@ class RolePermission(permissions.BasePermission):
# Extract the model name associated with this request
model = get_model_for_view(view)
if model is None:
return True
app_label = model._meta.app_label
model_name = model._meta.model_name
@ -99,6 +102,17 @@ class IsSuperuser(permissions.IsAdminUser):
return bool(request.user and request.user.is_superuser)
class IsSuperuserOrReadOnly(permissions.IsAdminUser):
"""Allow read-only access to any user, but write access is restricted to superuser users."""
def has_permission(self, request, view):
"""Check if the user is a superuser."""
return bool(
(request.user and request.user.is_superuser)
or request.method in permissions.SAFE_METHODS
)
class IsStaffOrReadOnly(permissions.IsAdminUser):
"""Allows read-only access to any user, but write access is restricted to staff users."""

View File

@ -403,18 +403,21 @@ class UserSerializer(InvenTreeModelSerializer):
read_only_fields = ['username', 'email']
username = serializers.CharField(label=_('Username'), help_text=_('Username'))
first_name = serializers.CharField(
label=_('First Name'), help_text=_('First name of the user'), allow_blank=True
)
last_name = serializers.CharField(
label=_('Last Name'), help_text=_('Last name of the user'), allow_blank=True
)
email = serializers.EmailField(
label=_('Email'), help_text=_('Email address of the user'), allow_blank=True
)
class ExendedUserSerializer(UserSerializer):
class ExtendedUserSerializer(UserSerializer):
"""Serializer for a User with a bit more info."""
from users.serializers import GroupSerializer
@ -437,9 +440,11 @@ class ExendedUserSerializer(UserSerializer):
is_staff = serializers.BooleanField(
label=_('Staff'), help_text=_('Does this user have staff permissions')
)
is_superuser = serializers.BooleanField(
label=_('Superuser'), help_text=_('Is this user a superuser')
)
is_active = serializers.BooleanField(
label=_('Active'), help_text=_('Is this user account active')
)
@ -464,9 +469,33 @@ class ExendedUserSerializer(UserSerializer):
return super().validate(attrs)
class UserCreateSerializer(ExendedUserSerializer):
class MeUserSerializer(ExtendedUserSerializer):
"""API serializer specifically for the 'me' endpoint."""
class Meta(ExtendedUserSerializer.Meta):
"""Metaclass options.
Extends the ExtendedUserSerializer.Meta options,
but ensures that certain fields are read-only.
"""
read_only_fields = [
*ExtendedUserSerializer.Meta.read_only_fields,
'is_active',
'is_staff',
'is_superuser',
]
class UserCreateSerializer(ExtendedUserSerializer):
"""Serializer for creating a new User."""
class Meta(ExtendedUserSerializer.Meta):
"""Metaclass options for the UserCreateSerializer."""
# Prevent creation of users with superuser or staff permissions
read_only_fields = ['groups', 'is_staff', 'is_superuser']
def validate(self, attrs):
"""Expanded valiadation for auth."""
# Check that the user trying to create a new user is a superuser

View File

@ -24,6 +24,7 @@ from rest_framework.response import Response
from rest_framework.views import APIView
import InvenTree.helpers
import InvenTree.permissions
from common.models import InvenTreeSetting
from InvenTree.filters import SEARCH_ORDER_FILTER
from InvenTree.mixins import (
@ -33,7 +34,11 @@ from InvenTree.mixins import (
RetrieveUpdateAPI,
RetrieveUpdateDestroyAPI,
)
from InvenTree.serializers import ExendedUserSerializer, UserCreateSerializer
from InvenTree.serializers import (
ExtendedUserSerializer,
MeUserSerializer,
UserCreateSerializer,
)
from InvenTree.settings import FRONTEND_URL_BASE
from users.models import ApiToken, Owner
from users.serializers import (
@ -134,24 +139,38 @@ class UserDetail(RetrieveUpdateDestroyAPI):
"""Detail endpoint for a single user."""
queryset = User.objects.all()
serializer_class = ExendedUserSerializer
serializer_class = ExtendedUserSerializer
permission_classes = [permissions.IsAuthenticated]
class MeUserDetail(RetrieveUpdateAPI, UserDetail):
"""Detail endpoint for current user."""
serializer_class = MeUserSerializer
rolemap = {'POST': 'view', 'PUT': 'view', 'PATCH': 'view'}
def get_object(self):
"""Always return the current user object."""
return self.request.user
def get_permission_model(self):
"""Return the model for the permission check.
Note that for this endpoint, the current user can *always* edit their own details.
"""
return None
class UserList(ListCreateAPI):
"""List endpoint for detail on all users."""
queryset = User.objects.all()
serializer_class = UserCreateSerializer
permission_classes = [permissions.IsAuthenticated]
permission_classes = [
permissions.IsAuthenticated,
InvenTree.permissions.IsSuperuserOrReadOnly,
]
filter_backends = SEARCH_ORDER_FILTER
search_fields = ['first_name', 'last_name', 'username']

View File

@ -17,7 +17,10 @@ class UserAPITests(InvenTreeAPITestCase):
self.assignRole('admin.add')
response = self.options(reverse('api-user-list'), expected_code=200)
fields = response.data['actions']['POST']
# User is *not* a superuser, so user account API is read-only
self.assertNotIn('POST', response.data['actions'])
fields = response.data['actions']['GET']
# Check some of the field values
self.assertEqual(fields['username']['label'], 'Username')

View File

@ -1,5 +1,5 @@
import { t } from '@lingui/macro';
import { Badge } from '@mantine/core';
import { Badge, Skeleton } from '@mantine/core';
import { isTrue } from '../../functions/conversion';
@ -32,3 +32,11 @@ export function PassFailButton({
export function YesNoButton({ value }: { value: any }) {
return <PassFailButton value={value} passText={t`Yes`} failText={t`No`} />;
}
export function YesNoUndefinedButton({ value }: { value?: boolean }) {
if (value === undefined) {
return <Skeleton height={15} width={32} />;
} else {
return <YesNoButton value={value} />;
}
}

View File

@ -1,12 +1,14 @@
import { Trans, t } from '@lingui/macro';
import { Button, Group, Stack, Text, TextInput, Title } from '@mantine/core';
import { useForm } from '@mantine/form';
import { useToggle } from '@mantine/hooks';
import { Group, Stack, Table, Title } from '@mantine/core';
import { IconKey, IconUser } from '@tabler/icons-react';
import { useMemo } from 'react';
import { api } from '../../../../App';
import { EditButton } from '../../../../components/buttons/EditButton';
import { YesNoUndefinedButton } from '../../../../components/buttons/YesNoButton';
import { ApiFormFieldSet } from '../../../../components/forms/fields/ApiFormField';
import { ActionDropdown } from '../../../../components/items/ActionDropdown';
import { ApiEndpoints } from '../../../../enums/ApiEndpoints';
import { apiUrl } from '../../../../states/ApiState';
import { notYetImplemented } from '../../../../functions/notifications';
import { useEditApiFormModal } from '../../../../hooks/UseForm';
import { useUserState } from '../../../../states/UserState';
export function AccountDetailPanel() {
@ -14,66 +16,89 @@ export function AccountDetailPanel() {
state.user,
state.fetchUserState
]);
const form = useForm({ initialValues: user });
const [editing, setEditing] = useToggle([false, true] as const);
function SaveData(values: any) {
// copy values over to break form rendering link
const urlVals = { ...values };
urlVals.is_active = true;
// send
api
.put(apiUrl(ApiEndpoints.user_me), urlVals)
.then((res) => {
if (res.status === 200) {
setEditing();
fetchUserState();
}
})
.catch(() => {
console.error('ERR: Error saving user data');
});
}
const userFields: ApiFormFieldSet = useMemo(() => {
return {
first_name: {},
last_name: {}
};
}, []);
const editUser = useEditApiFormModal({
title: t`Edit User Information`,
url: ApiEndpoints.user_me,
onFormSuccess: fetchUserState,
fields: userFields,
successMessage: t`User details updated`
});
return (
<form onSubmit={form.onSubmit((values) => SaveData(values))}>
<Group>
<Title order={3}>
<Trans>Account Details</Trans>
</Title>
<EditButton setEditing={setEditing} editing={editing} />
</Group>
<Group>
{editing ? (
<Stack gap="xs">
<TextInput
label="first name"
placeholder={t`First name`}
{...form.getInputProps('first_name')}
/>
<TextInput
label="Last name"
placeholder={t`Last name`}
{...form.getInputProps('last_name')}
/>
<Group justify="right" mt="md">
<Button type="submit">
<Trans>Submit</Trans>
</Button>
</Group>
</Stack>
) : (
<Stack gap="0">
<Text>
<Trans>First name: </Trans>
{form.values.first_name}
</Text>
<Text>
<Trans>Last name: </Trans>
{form.values.last_name}
</Text>
</Stack>
)}
</Group>
</form>
<>
{editUser.modal}
<Stack gap="xs">
<Group justify="space-between">
<Title order={3}>
<Trans>User Details</Trans>
</Title>
<ActionDropdown
tooltip={t`User Actions`}
icon={<IconUser />}
actions={[
{
name: t`Edit User`,
icon: <IconUser />,
tooltip: t`Edit User Information`,
onClick: editUser.open
},
{
name: t`Set Password`,
icon: <IconKey />,
tooltip: t`Set User Password`,
onClick: notYetImplemented
}
]}
/>
</Group>
<Table>
<Table.Tbody>
<Table.Tr>
<Table.Td>
<Trans>Username</Trans>
</Table.Td>
<Table.Td>{user?.username}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
<Trans>First Name</Trans>
</Table.Td>
<Table.Td>{user?.first_name}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
<Trans>Last Name</Trans>
</Table.Td>
<Table.Td>{user?.last_name}</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
<Trans>Staff Access</Trans>
</Table.Td>
<Table.Td>
<YesNoUndefinedButton value={user?.is_staff} />
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
<Trans>Superuser</Trans>
</Table.Td>
<Table.Td>
<YesNoUndefinedButton value={user?.is_superuser} />
</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
</Stack>
</>
);
}

View File

@ -1,51 +0,0 @@
import { Trans } from '@lingui/macro';
import { Button, Container, Group, Table, Title } from '@mantine/core';
import { ColorToggle } from '../../../../components/items/ColorToggle';
import { LanguageSelect } from '../../../../components/items/LanguageSelect';
import { IS_DEV } from '../../../../main';
import { useLocalState } from '../../../../states/LocalState';
export function DisplaySettingsPanel({ height }: { height: number }) {
function enablePseudoLang(): void {
useLocalState.setState({ language: 'pseudo-LOCALE' });
}
return (
<Container w="100%" mih={height} p={0}>
<Title order={3}>
<Trans>Display Settings</Trans>
</Title>
<Table>
<tbody>
<tr>
<td>
<Trans>Color Mode</Trans>
</td>
<td>
<Group>
<ColorToggle />
</Group>
</td>
</tr>
<tr>
<td>
<Trans>Language</Trans>
</td>
<td>
{' '}
<Group>
<LanguageSelect width={200} />
{IS_DEV && (
<Button onClick={enablePseudoLang} variant="light">
<Trans>Use pseudo language</Trans>
</Button>
)}
</Group>
</td>
</tr>
</tbody>
</Table>
</Container>
);
}

View File

@ -1,7 +1,6 @@
import { Container, Grid, SimpleGrid } from '@mantine/core';
import { AccountDetailPanel } from './AccountDetailPanel';
import { DisplaySettingsPanel } from './DisplaySettingsPanel';
import { UserTheme } from './UserThemePanel';
export function AccountContent() {
@ -18,9 +17,6 @@ export function AccountContent() {
<Grid.Col>
<UserTheme height={SECONDARY_COL_HEIGHT} />
</Grid.Col>
<Grid.Col>
<DisplaySettingsPanel height={SECONDARY_COL_HEIGHT} />
</Grid.Col>
</Grid>
</SimpleGrid>
</div>

View File

@ -1,5 +1,7 @@
import { Trans, t } from '@lingui/macro';
import {
ActionIcon,
Button,
ColorInput,
ColorPicker,
Container,
@ -9,13 +11,18 @@ import {
Select,
Slider,
Table,
Title
Title,
useMantineTheme
} from '@mantine/core';
import { IconReload, IconRestore } from '@tabler/icons-react';
import { useState } from 'react';
import { ColorToggle } from '../../../../components/items/ColorToggle';
import { LanguageSelect } from '../../../../components/items/LanguageSelect';
import { SizeMarks } from '../../../../defaults/defaults';
import { notYetImplemented } from '../../../../functions/notifications';
import { IS_DEV } from '../../../../main';
import { useLocalState } from '../../../../states/LocalState';
import { theme } from '../../../../theme';
function getLkp(color: string) {
return { [DEFAULT_THEME.colors[color][6]]: color };
@ -26,18 +33,24 @@ const LOOKUP = Object.assign(
);
export function UserTheme({ height }: { height: number }) {
// primary color
function changePrimary(color: string) {
useLocalState.setState({ primaryColor: LOOKUP[color] });
}
const theme = useMantineTheme();
const [themeLoader, setThemeLoader] = useLocalState((state) => [
state.loader,
state.setLoader
]);
// white color
const [whiteColor, setWhiteColor] = useState(theme.white);
function changeWhite(color: string) {
useLocalState.setState({ whiteColor: color });
setWhiteColor(color);
}
// black color
const [blackColor, setBlackColor] = useState(theme.black);
function changeBlack(color: string) {
useLocalState.setState({ blackColor: color });
setBlackColor(color);
@ -48,6 +61,7 @@ export function UserTheme({ height }: { height: number }) {
if (obj) return obj;
return SizeMarks[0];
}
function getDefaultRadius() {
const obj = SizeMarks.find(
(mark) => mark.label === useLocalState.getState().radius
@ -60,16 +74,23 @@ export function UserTheme({ height }: { height: number }) {
setRadius(value);
useLocalState.setState({ radius: getMark(value).label });
}
// loader
// Set theme primary color
function changePrimary(color: string) {
useLocalState.setState({ primaryColor: LOOKUP[color] });
}
function enablePseudoLang(): void {
useLocalState.setState({ language: 'pseudo-LOCALE' });
}
// Custom loading indicator
const loaderDate = [
{ value: 'bars', label: t`bars` },
{ value: 'oval', label: t`oval` },
{ value: 'dots', label: t`dots` }
{ value: 'bars', label: t`Bars` },
{ value: 'oval', label: t`Oval` },
{ value: 'dots', label: t`Dots` }
];
const [themeLoader, setThemeLoader] = useLocalState((state) => [
state.loader,
state.setLoader
]);
function changeLoader(value: string | null) {
if (value === null) return;
setThemeLoader(value);
@ -78,13 +99,39 @@ export function UserTheme({ height }: { height: number }) {
return (
<Container w="100%" mih={height} p={0}>
<Title order={3}>
<Trans>Theme</Trans>
<Trans>Display Settings</Trans>
</Title>
<Table>
<Table.Tbody>
<Table.Tr>
<Table.Td>
<Trans>Primary color</Trans>
<Trans>Language</Trans>
</Table.Td>
<Table.Td>
<LanguageSelect width={200} />
</Table.Td>
<Table.Td>
{IS_DEV && (
<Button onClick={enablePseudoLang} variant="light">
<Trans>Use pseudo language</Trans>
</Button>
)}
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
<Trans>Color Mode</Trans>
</Table.Td>
<Table.Td>
<Group justify="left">
<ColorToggle />
</Group>
</Table.Td>
<Table.Td></Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
<Trans>Highlight color</Trans>
</Table.Td>
<Table.Td>
<ColorPicker
@ -94,6 +141,11 @@ export function UserTheme({ height }: { height: number }) {
swatches={Object.keys(LOOKUP)}
/>
</Table.Td>
<Table.Td>
<Button color={theme.primaryColor} variant="light">
<Trans>Example</Trans>
</Button>
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
@ -102,6 +154,14 @@ export function UserTheme({ height }: { height: number }) {
<Table.Td>
<ColorInput value={whiteColor} onChange={changeWhite} />
</Table.Td>
<Table.Td>
<ActionIcon
variant="default"
onClick={() => changeWhite('#FFFFFF')}
>
<IconRestore />
</ActionIcon>
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
@ -110,6 +170,14 @@ export function UserTheme({ height }: { height: number }) {
<Table.Td>
<ColorInput value={blackColor} onChange={changeBlack} />
</Table.Td>
<Table.Td>
<ActionIcon
variant="default"
onClick={() => changeBlack('#000000')}
>
<IconRestore />
</ActionIcon>
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
@ -132,13 +200,17 @@ export function UserTheme({ height }: { height: number }) {
<Trans>Loader</Trans>
</Table.Td>
<Table.Td>
<Group align="center">
<Group justify="left">
<Select
data={loaderDate}
value={themeLoader}
onChange={changeLoader}
/>
<Loader type={themeLoader} mah={18} />
</Group>
</Table.Td>
<Table.Td>
<Group justify="left">
<Loader type={themeLoader} mah={16} size="sm" />
</Group>
</Table.Td>
</Table.Tr>

View File

@ -153,7 +153,8 @@ export function InvenTreeTable<T = any>({
getTableColumnNames,
setTableColumnNames,
getTableSorting,
setTableSorting
setTableSorting,
loader
} = useLocalState();
const [fieldNames, setFieldNames] = useState<Record<string, string>>({});
@ -192,8 +193,9 @@ export function InvenTreeTable<T = any>({
// Extract field information from the API
let names: Record<string, string> = {};
let fields: ApiFormFieldSet =
extractAvailableFields(response, 'POST', true) || {};
extractAvailableFields(response, 'GET', true) || {};
// Extract flattened map of fields
mapFields(fields, (path, field) => {
@ -720,7 +722,7 @@ export function InvenTreeTable<T = any>({
withColumnBorders
striped
highlightOnHover
loaderType="dots"
loaderType={loader}
pinLastColumn={tableProps.rowActions != undefined}
idAccessor={tableProps.idAccessor}
minHeight={300}