Skip to content

feat: improve user/group management actions #9602

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 23 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
fe3e669
feat: improve user management actions
matmair Apr 28, 2025
e5e6759
add lock / unlock action
matmair Apr 28, 2025
74783d2
add actions for password reset
matmair Apr 29, 2025
8fee5cf
submit coverage info to codecov
matmair Apr 29, 2025
96b599b
bump api version
matmair Apr 29, 2025
f9e8b66
Merge branch 'master' into feat--improve-user-management-actions
matmair Apr 29, 2025
0cae937
add frontend test
matmair Apr 29, 2025
200fa58
add backend test
matmair Apr 29, 2025
0111007
fix test state
matmair Apr 29, 2025
fc0f375
Merge branch 'master' of https://github.com/inventree/InvenTree into …
matmair May 1, 2025
c509ad4
move test
matmair May 1, 2025
7a5dc6c
Merge branch 'master' of https://github.com/inventree/InvenTree into …
matmair Jun 2, 2025
4e30d60
fix style
matmair Jun 2, 2025
fba3c18
Merge branch 'master' into feat--improve-user-management-actions
SchrodingersGat Jun 3, 2025
2ffed9e
Merge branch 'master' into feat--improve-user-management-actions
matmair Jun 4, 2025
9479cfd
Merge branch 'master' into feat--improve-user-management-actions
matmair Jun 5, 2025
8282721
fix name
matmair Jun 5, 2025
a988b7e
hide password change if not superuser
matmair Jun 5, 2025
31d38f1
bump playwright
matmair Jun 6, 2025
d28e85d
Merge branch 'master' into feat--improve-user-management-actions
matmair Jun 7, 2025
e207187
Merge branch 'master' into feat--improve-user-management-actions
matmair Jun 9, 2025
29ff26e
Merge branch 'master' into feat--improve-user-management-actions
matmair Jun 10, 2025
cf862e3
Merge branch 'master' into feat--improve-user-management-actions
matmair Jun 11, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions .github/workflows/qc_checks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
6 changes: 4 additions & 2 deletions src/backend/InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
"""InvenTree API version information."""

# InvenTree API version
INVENTREE_API_VERSION = 346

INVENTREE_API_VERSION = 347
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""


INVENTREE_API_TEXT = """

v347 -> 2025-06-07 : https://github.com/inventree/InvenTree/pull/9602
- Adds passwort reset API endpoint for admin users

v346 -> 2025-06-07 : https://github.com/inventree/InvenTree/pull/9718
- Adds "read_only" field to the GlobalSettings API endpoint(s)

Expand Down
46 changes: 45 additions & 1 deletion src/backend/InvenTree/users/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

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
Expand All @@ -23,6 +25,7 @@
RetrieveAPI,
RetrieveUpdateAPI,
RetrieveUpdateDestroyAPI,
UpdateAPI,
)
from InvenTree.settings import FRONTEND_URL_BASE
from users.models import ApiToken, Owner, RuleSet, UserProfile
Expand All @@ -37,6 +40,7 @@
RuleSetSerializer,
UserCreateSerializer,
UserProfileSerializer,
UserSetPasswordSerializer,
)

logger = structlog.get_logger('inventree')
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -467,6 +501,16 @@ def get_object(self):
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'),
]
24 changes: 24 additions & 0 deletions src/backend/InvenTree/users/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,30 @@ def update(self, instance, validated_data):
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."""

Expand Down
26 changes: 26 additions & 0 deletions src/backend/InvenTree/users/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,32 @@ def test_user_roles(self):
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."""

Expand Down
1 change: 1 addition & 0 deletions src/frontend/lib/enums/ApiEndpoints.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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/',
Expand Down
1 change: 1 addition & 0 deletions src/frontend/lib/types/Forms.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export type ApiFormFieldType = {
| 'email'
| 'url'
| 'string'
| 'password'
| 'icon'
| 'boolean'
| 'date'
Expand Down
2 changes: 1 addition & 1 deletion src/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 12 additions & 0 deletions src/frontend/src/components/forms/fields/ApiFormField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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} />
Expand Down
11 changes: 10 additions & 1 deletion src/frontend/src/tables/settings/GroupTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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]
Expand Down
84 changes: 80 additions & 4 deletions src/frontend/src/tables/settings/UserTable.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -23,6 +30,7 @@ import {
import { DetailDrawer } from '../../components/nav/DetailDrawer';
import { showApiErrorMessage } from '../../functions/notifications';
import {
useApiFormModal,
useCreateApiFormModal,
useDeleteApiFormModal
} from '../../hooks/UseForm';
Expand Down Expand Up @@ -225,9 +233,9 @@ export function UserDrawer({
*/
export function UserTable({
directLink
}: {
}: Readonly<{
directLink?: boolean;
}) {
}>) {
const table = useTable('users');
const navigate = useNavigate();
const user = useUserState();
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -371,6 +428,7 @@ export function UserTable({

return (
<>
{editable && setPassword.modal}
{editable && newUser.modal}
{editable && deleteUser.modal}
{editable && (
Expand Down Expand Up @@ -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`
});
}
}
Loading
Loading