2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-07-01 03:00:54 +00:00

feat: improve user/group management actions (#9602)

* feat: improve user management actions
add "open profile" actions

* add lock / unlock action

* add actions for password reset

* submit coverage info to codecov
no idea why this was turned off

* bump api version

* add frontend test

* add backend test

* fix test state

* move test

* fix style

* fix name

* hide password change if not superuser

* bump playwright
see https://github.com/microsoft/playwright/issues/35183

* fix test

* fix test order

* simplify test

---------

Co-authored-by: Oliver <oliver.henry.walters@gmail.com>
This commit is contained in:
Matthias Mair
2025-06-19 01:14:59 +02:00
committed by GitHub
parent 8346318f7d
commit d5fa609275
13 changed files with 242 additions and 24 deletions

View File

@ -621,17 +621,14 @@ jobs:
path: src/frontend/playwright-report/
retention-days: 14
- name: Report coverage
if: github.event_name != 'pull_request'
run: cd src/frontend && npx nyc report --report-dir ./coverage --temp-dir .nyc_output --reporter=lcov --exclude-after-remap false
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # pin@v5.4.3
if: github.event_name != 'pull_request'
with:
token: ${{ secrets.CODECOV_TOKEN }}
slug: inventree/InvenTree
flags: web
- name: Upload bundler info
if: github.event_name != 'pull_request'
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
run: |

View File

@ -1,11 +1,15 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 350
INVENTREE_API_VERSION = 351
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v351 -> 2025-06-18 : https://github.com/inventree/InvenTree/pull/9602
- Adds passwort reset API endpoint for admin users
v350 -> 2025-06-17 : https://github.com/inventree/InvenTree/pull/9798
- Adds "can_build" field to the part requirements API endpoint
- Remove "allocated" and "required" fields from the part requirements API endpoint

View File

@ -4,6 +4,8 @@ import datetime
from django.contrib.auth import get_user, login
from django.contrib.auth.models import Group, User
from django.contrib.auth.password_validation import password_changed, validate_password
from django.core.exceptions import ValidationError
from django.urls import include, path
from django.views.decorators.csrf import ensure_csrf_cookie
from django.views.generic.base import RedirectView
@ -23,6 +25,7 @@ from InvenTree.mixins import (
RetrieveAPI,
RetrieveUpdateAPI,
RetrieveUpdateDestroyAPI,
UpdateAPI,
)
from InvenTree.settings import FRONTEND_URL_BASE
from users.models import ApiToken, Owner, RuleSet, UserProfile
@ -37,6 +40,7 @@ from users.serializers import (
RuleSetSerializer,
UserCreateSerializer,
UserProfileSerializer,
UserSetPasswordSerializer,
)
logger = structlog.get_logger('inventree')
@ -141,6 +145,36 @@ class UserDetail(RetrieveUpdateDestroyAPI):
permission_classes = [InvenTree.permissions.StaffRolePermissionOrReadOnly]
class UserDetailSetPassword(UpdateAPI):
"""Allows superusers to set the password for a user."""
queryset = User.objects.all()
serializer_class = UserSetPasswordSerializer
permission_classes = [InvenTree.permissions.IsSuperuserOrSuperScope]
def get_object(self):
"""Return the user object for this endpoint."""
return self.get_queryset().get(pk=self.kwargs['pk'])
def perform_update(self, serializer):
"""Set the password for the user."""
user: User = serializer.instance
password: str = serializer.validated_data.get('password', None)
overwrite: bool = serializer.validated_data.get('override_warning', False)
if password:
if not overwrite:
try:
validate_password(password=password, user=user)
except ValidationError as e:
raise exceptions.ValidationError({'password': str(e)})
user.set_password(password)
password_changed(password=password, user=user)
user.save()
class MeUserDetail(RetrieveUpdateAPI, UserDetail):
"""Detail endpoint for current user.
@ -467,6 +501,16 @@ user_urls = [
path('', RuleSetList.as_view(), name='api-ruleset-list'),
]),
),
path('<int:pk>/', UserDetail.as_view(), name='api-user-detail'),
path(
'<int:pk>/',
include([
path(
'set-password/',
UserDetailSetPassword.as_view(),
name='api-user-set-password',
),
path('', UserDetail.as_view(), name='api-user-detail'),
]),
),
path('', UserList.as_view(), name='api-user-list'),
]

View File

@ -360,6 +360,30 @@ class ExtendedUserSerializer(UserSerializer):
return instance
class UserSetPasswordSerializer(serializers.Serializer):
"""Serializer for setting a password for a user."""
class Meta:
"""Meta options for UserSetPasswordSerializer."""
model = User
fields = ['password', 'override_warning']
password = serializers.CharField(
label=_('Password'),
help_text=_('Password for the user'),
write_only=True,
required=True,
style={'input_type': 'password'},
)
override_warning = serializers.BooleanField(
label=_('Override warning'),
help_text=_('Override the warning about password rules'),
write_only=True,
required=False,
)
class MeUserSerializer(ExtendedUserSerializer):
"""API serializer specifically for the 'me' endpoint."""

View File

@ -209,6 +209,32 @@ class UserAPITests(InvenTreeAPITestCase):
self.assertEqual(len(data['permissions']), len(perms) + len(build_perms))
class SuperuserAPITests(InvenTreeAPITestCase):
"""Tests for user API endpoints that require superuser rights."""
fixtures = ['users']
superuser = True
def test_user_password_set(self):
"""Test the set-password/ endpoint."""
user = User.objects.get(pk=2)
url = reverse('api-user-set-password', kwargs={'pk': user.pk})
# to simple password
resp = self.put(url, {'password': 1}, expected_code=400)
self.assertContains(resp, 'This password is too short', status_code=400)
# now with overwerite
resp = self.put(
url, {'password': 1, 'override_warning': True}, expected_code=200
)
self.assertEqual(resp.data, {})
# complex enough pwd
resp = self.put(url, {'password': 'inventree'}, expected_code=200)
self.assertEqual(resp.data, {})
class UserTokenTests(InvenTreeAPITestCase):
"""Tests for user token functionality."""

View File

@ -11,6 +11,7 @@ export enum ApiEndpoints {
// User API endpoints
user_list = 'user/',
user_set_password = 'user/:id/set-password/',
user_me = 'user/me/',
user_profile = 'user/profile/',
user_roles = 'user/roles/',

View File

@ -72,6 +72,7 @@ export type ApiFormFieldType = {
| 'email'
| 'url'
| 'string'
| 'password'
| 'icon'
| 'boolean'
| 'date'

View File

@ -112,7 +112,7 @@
"@lingui/babel-plugin-lingui-macro": "^5.3.0",
"@lingui/cli": "^5.3.1",
"@lingui/macro": "^5.3.0",
"@playwright/test": "^1.49.1",
"@playwright/test": "^1.52.0",
"@types/node": "^22.13.14",
"@types/qrcode": "^1.5.5",
"@types/react": "^18.3.8",

View File

@ -152,6 +152,18 @@ export function ApiFormField({
}}
/>
);
case 'password':
return (
<TextField
definition={{ ...reducedDefinition, type: 'password' }}
controller={controller}
fieldName={fieldName}
onChange={onChange}
onKeyDown={(value) => {
onKeyDown?.(value);
}}
/>
);
case 'icon':
return (
<IconField definition={fieldDefinition} controller={controller} />

View File

@ -7,6 +7,8 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/index';
import { IconUsersGroup } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { EditApiForm } from '../../components/forms/ApiForm';
@ -159,7 +161,14 @@ export function GroupTable({
setSelectedGroup(record.pk);
deleteGroup.open();
}
})
}),
{
icon: <IconUsersGroup />,
title: t`Open Profile`,
onClick: () => {
navigate(getDetailUrl(ModelType.group, record.pk));
}
}
];
},
[user]

View File

@ -1,13 +1,20 @@
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { Accordion, Alert, LoadingOverlay, Stack, Text } from '@mantine/core';
import { IconInfoCircle } from '@tabler/icons-react';
import {
IconInfoCircle,
IconKey,
IconLock,
IconLockOpen,
IconUserCircle
} from '@tabler/icons-react';
import { useCallback, useMemo, useState } from 'react';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/index';
import type { TableFilter } from '@lib/types/Filters';
import { showNotification } from '@mantine/notifications';
import { useNavigate } from 'react-router-dom';
@ -23,6 +30,7 @@ import {
import { DetailDrawer } from '../../components/nav/DetailDrawer';
import { showApiErrorMessage } from '../../functions/notifications';
import {
useApiFormModal,
useCreateApiFormModal,
useDeleteApiFormModal
} from '../../hooks/UseForm';
@ -298,7 +306,44 @@ export function UserTable({
setSelectedUser(record.pk);
deleteUser.open();
}
})
}),
{
icon: <IconUserCircle />,
title: t`Open Profile`,
onClick: () => {
navigate(getDetailUrl(ModelType.user, record.pk));
}
},
{
icon: <IconKey />,
title: t`Change Password`,
color: 'blue',
onClick: () => {
setSelectedUser(record.pk);
setPassword.open();
},
hidden: !user.isSuperuser()
},
{
icon: <IconLock />,
title: t`Lock user`,
color: 'blue',
onClick: () => {
setUserActiveState(record.pk, false);
table.refreshTable();
},
hidden: !record.is_active
},
{
icon: <IconLockOpen />,
title: t`Unlock user`,
color: 'blue',
onClick: () => {
setUserActiveState(record.pk, true);
table.refreshTable();
},
hidden: record.is_active
}
];
},
[user]
@ -327,6 +372,18 @@ export function UserTable({
successMessage: t`Added user`
});
const setPassword = useApiFormModal({
url: ApiEndpoints.user_set_password,
method: 'PUT',
pk: selectedUser,
title: t`Set Password`,
fields: {
password: { field_type: 'password' },
override_warning: {}
},
successMessage: t`Password updated`
});
const tableActions = useMemo(() => {
const actions = [];
const staff: boolean = user.isStaff() || user.isSuperuser();
@ -371,6 +428,7 @@ export function UserTable({
return (
<>
{editable && setPassword.modal}
{editable && newUser.modal}
{editable && deleteUser.modal}
{editable && (
@ -405,3 +463,21 @@ export function UserTable({
</>
);
}
async function setUserActiveState(userId: number, active: boolean) {
try {
await api.patch(apiUrl(ApiEndpoints.user_list, userId), {
is_active: active
});
showNotification({
title: t`User updated`,
message: t`User updated successfully`,
color: 'green'
});
} catch (error) {
showApiErrorMessage({
error: error,
title: t`Error updating user`
});
}
}

View File

@ -23,10 +23,34 @@ test('Permissions - Admin', async ({ browser, request }) => {
await loadTab(page, 'Plugins');
await loadTab(page, 'Users / Access');
// Let's create a new user
// Let's check creating a new user
await page.getByLabel('action-button-add-user').click();
await page.getByRole('button', { name: 'Submit' }).waitFor();
await page.getByRole('button', { name: 'Cancel' }).click();
// Change password
await page.getByRole('cell', { name: 'Ian', exact: true }).click({
button: 'right'
});
await page.getByRole('button', { name: 'Change Password' }).click();
await page.getByLabel('text-field-password').fill('123');
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText("['This password is too short").waitFor();
await page
.locator('label')
.filter({ hasText: 'Override warning' })
.locator('div')
.first()
.click();
await page.getByRole('button', { name: 'Submit' }).click();
await page.getByText('Password updated').click();
// Open profile
await page.getByRole('cell', { name: 'Ian', exact: true }).click({
button: 'right'
});
await page.getByRole('button', { name: 'Open Profile' }).click();
await page.getByText('User: ian', { exact: true }).click();
});
/**

View File

@ -1311,12 +1311,12 @@
dependencies:
"@octokit/openapi-types" "^22.2.0"
"@playwright/test@^1.49.1":
version "1.49.1"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.49.1.tgz#55fa360658b3187bfb6371e2f8a64f50ef80c827"
integrity sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==
"@playwright/test@^1.52.0":
version "1.52.0"
resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.52.0.tgz#267ec595b43a8f4fa5e444ea503689629e91a5b8"
integrity sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==
dependencies:
playwright "1.49.1"
playwright "1.52.0"
"@remix-run/router@1.19.2":
version "1.19.2"
@ -3955,17 +3955,17 @@ pkg-up@^3.1.0:
dependencies:
find-up "^3.0.0"
playwright-core@1.49.1:
version "1.49.1"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.49.1.tgz#32c62f046e950f586ff9e35ed490a424f2248015"
integrity sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==
playwright-core@1.52.0:
version "1.52.0"
resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.52.0.tgz#238f1f0c3edd4ebba0434ce3f4401900319a3dca"
integrity sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==
playwright@1.49.1:
version "1.49.1"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.49.1.tgz#830266dbca3008022afa7b4783565db9944ded7c"
integrity sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==
playwright@1.52.0:
version "1.52.0"
resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.52.0.tgz#26cb9a63346651e1c54c8805acfd85683173d4bd"
integrity sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==
dependencies:
playwright-core "1.49.1"
playwright-core "1.52.0"
optionalDependencies:
fsevents "2.3.2"