mirror of
https://github.com/inventree/InvenTree.git
synced 2025-07-01 11:10: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:
3
.github/workflows/qc_checks.yaml
vendored
3
.github/workflows/qc_checks.yaml
vendored
@ -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: |
|
||||
|
@ -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
|
||||
|
@ -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'),
|
||||
]
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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."""
|
||||
|
||||
|
@ -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/',
|
||||
|
@ -72,6 +72,7 @@ export type ApiFormFieldType = {
|
||||
| 'email'
|
||||
| 'url'
|
||||
| 'string'
|
||||
| 'password'
|
||||
| 'icon'
|
||||
| 'boolean'
|
||||
| 'date'
|
||||
|
@ -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",
|
||||
|
@ -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} />
|
||||
|
@ -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]
|
||||
|
@ -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`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -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();
|
||||
});
|
||||
|
||||
/**
|
||||
|
@ -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"
|
||||
|
||||
|
Reference in New Issue
Block a user