From d5fa609275f35fc03c1d1d7f77fbbcc889b0bfa2 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Thu, 19 Jun 2025 01:14:59 +0200 Subject: [PATCH] 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 --- .github/workflows/qc_checks.yaml | 3 - .../InvenTree/InvenTree/api_version.py | 6 +- src/backend/InvenTree/users/api.py | 46 ++++++++++- src/backend/InvenTree/users/serializers.py | 24 ++++++ src/backend/InvenTree/users/test_api.py | 26 ++++++ src/frontend/lib/enums/ApiEndpoints.tsx | 1 + src/frontend/lib/types/Forms.tsx | 1 + src/frontend/package.json | 2 +- .../components/forms/fields/ApiFormField.tsx | 12 +++ .../src/tables/settings/GroupTable.tsx | 11 ++- .../src/tables/settings/UserTable.tsx | 80 ++++++++++++++++++- src/frontend/tests/pui_permissions.spec.ts | 26 +++++- src/frontend/yarn.lock | 28 +++---- 13 files changed, 242 insertions(+), 24 deletions(-) diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index 2839cfda89..121c6cfea4 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -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: | diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index d5d08f23e1..200c2be075 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py index ee3d4d3a7d..f8f2e8e067 100644 --- a/src/backend/InvenTree/users/api.py +++ b/src/backend/InvenTree/users/api.py @@ -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('/', UserDetail.as_view(), name='api-user-detail'), + path( + '/', + 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'), ] diff --git a/src/backend/InvenTree/users/serializers.py b/src/backend/InvenTree/users/serializers.py index 5950fb286d..21df9bcb83 100644 --- a/src/backend/InvenTree/users/serializers.py +++ b/src/backend/InvenTree/users/serializers.py @@ -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.""" diff --git a/src/backend/InvenTree/users/test_api.py b/src/backend/InvenTree/users/test_api.py index b577943976..f2f3772438 100644 --- a/src/backend/InvenTree/users/test_api.py +++ b/src/backend/InvenTree/users/test_api.py @@ -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.""" diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx index 3dad40b5f0..d0a3462ac4 100644 --- a/src/frontend/lib/enums/ApiEndpoints.tsx +++ b/src/frontend/lib/enums/ApiEndpoints.tsx @@ -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/', diff --git a/src/frontend/lib/types/Forms.tsx b/src/frontend/lib/types/Forms.tsx index 47c6dc70cc..79f55e52ad 100644 --- a/src/frontend/lib/types/Forms.tsx +++ b/src/frontend/lib/types/Forms.tsx @@ -72,6 +72,7 @@ export type ApiFormFieldType = { | 'email' | 'url' | 'string' + | 'password' | 'icon' | 'boolean' | 'date' diff --git a/src/frontend/package.json b/src/frontend/package.json index 704880d364..361d5bd58c 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -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", diff --git a/src/frontend/src/components/forms/fields/ApiFormField.tsx b/src/frontend/src/components/forms/fields/ApiFormField.tsx index a0c3c0f36b..98735d1fd8 100644 --- a/src/frontend/src/components/forms/fields/ApiFormField.tsx +++ b/src/frontend/src/components/forms/fields/ApiFormField.tsx @@ -152,6 +152,18 @@ export function ApiFormField({ }} /> ); + case 'password': + return ( + { + onKeyDown?.(value); + }} + /> + ); case 'icon': return ( diff --git a/src/frontend/src/tables/settings/GroupTable.tsx b/src/frontend/src/tables/settings/GroupTable.tsx index f91ac1662a..a1be754ccf 100644 --- a/src/frontend/src/tables/settings/GroupTable.tsx +++ b/src/frontend/src/tables/settings/GroupTable.tsx @@ -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: , + title: t`Open Profile`, + onClick: () => { + navigate(getDetailUrl(ModelType.group, record.pk)); + } + } ]; }, [user] diff --git a/src/frontend/src/tables/settings/UserTable.tsx b/src/frontend/src/tables/settings/UserTable.tsx index 60cfdf0f2b..ac4469e51d 100644 --- a/src/frontend/src/tables/settings/UserTable.tsx +++ b/src/frontend/src/tables/settings/UserTable.tsx @@ -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: , + title: t`Open Profile`, + onClick: () => { + navigate(getDetailUrl(ModelType.user, record.pk)); + } + }, + { + icon: , + title: t`Change Password`, + color: 'blue', + onClick: () => { + setSelectedUser(record.pk); + setPassword.open(); + }, + hidden: !user.isSuperuser() + }, + { + icon: , + title: t`Lock user`, + color: 'blue', + onClick: () => { + setUserActiveState(record.pk, false); + table.refreshTable(); + }, + hidden: !record.is_active + }, + { + icon: , + 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` + }); + } +} diff --git a/src/frontend/tests/pui_permissions.spec.ts b/src/frontend/tests/pui_permissions.spec.ts index 6ca4aad69a..4ab994377e 100644 --- a/src/frontend/tests/pui_permissions.spec.ts +++ b/src/frontend/tests/pui_permissions.spec.ts @@ -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(); }); /** diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index db9a5db32c..497e679bea 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -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"