diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index c8d211922f..c023dc74c0 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,13 +1,17 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 324 +INVENTREE_API_VERSION = 325 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v324 - 2024-03-17 : https://github.com/inventree/InvenTree/pull/9244 + - Adds the option for superusers to list all user tokens + - Make list endpoints sortable, filterable and searchable + v324 - 2025-03-17 : https://github.com/inventree/InvenTree/pull/9320 - Adds BulkUpdate support for the SalesOrderAllocation model - Adds BulkUpdate support for the PartCategory model diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py index 931ee393a1..37e90be6ee 100644 --- a/src/backend/InvenTree/users/api.py +++ b/src/backend/InvenTree/users/api.py @@ -9,11 +9,11 @@ from django.views.decorators.csrf import ensure_csrf_cookie from django.views.generic.base import RedirectView import structlog +from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from rest_framework import exceptions, permissions -from rest_framework.generics import DestroyAPIView +from rest_framework.generics import DestroyAPIView, GenericAPIView from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from rest_framework.views import APIView import InvenTree.helpers import InvenTree.permissions @@ -30,6 +30,7 @@ from users.models import ApiToken, Owner, UserProfile from users.serializers import ( ApiTokenSerializer, ExtendedUserSerializer, + GetAuthTokenSerializer, GroupSerializer, MeUserSerializer, OwnerSerializer, @@ -214,12 +215,23 @@ class GroupList(GroupMixin, ListCreateAPI): ordering_fields = ['name'] -class GetAuthToken(APIView): +class GetAuthToken(GenericAPIView): """Return authentication token for an authenticated user.""" permission_classes = [permissions.IsAuthenticated] - serializer_class = None + serializer_class = GetAuthTokenSerializer + @extend_schema( + parameters=[ + OpenApiParameter( + name='name', + type=str, + location=OpenApiParameter.QUERY, + description='Name of the token', + ) + ], + responses={200: OpenApiResponse(response=GetAuthTokenSerializer())}, + ) def get(self, request, *args, **kwargs): """Return an API token if the user is authenticated. @@ -272,16 +284,68 @@ class GetAuthToken(APIView): raise exceptions.NotAuthenticated() # pragma: no cover -class TokenListView(DestroyAPIView, ListAPI): - """List of registered tokens for current users.""" +class TokenMixin: + """Mixin for API token endpoints.""" permission_classes = (IsAuthenticated,) serializer_class = ApiTokenSerializer def get_queryset(self): """Only return data for current user.""" + if self.request.user.is_superuser and self.request.query_params.get( + 'all_users', False + ): + return ApiToken.objects.all() return ApiToken.objects.filter(user=self.request.user) + @extend_schema( + parameters=[ + OpenApiParameter( + name='all_users', + type=bool, + location=OpenApiParameter.QUERY, + description='Display tokens for all users (superuser only)', + ) + ] + ) + def get(self, request, *args, **kwargs): + """Details for a user token.""" + return super().get(request, *args, **kwargs) + + +class TokenListView(TokenMixin, ListCreateAPI): + """List of user tokens for current user.""" + + filter_backends = SEARCH_ORDER_FILTER + search_fields = ['name', 'key'] + ordering_fields = [ + 'created', + 'expiry', + 'last_seen', + 'user', + 'name', + 'revoked', + 'revoked', + ] + + filterset_fields = ['revoked', 'user'] + + def create(self, request, *args, **kwargs): + """Create token and show key to user.""" + resp = super().create(request, *args, **kwargs) + resp.data['token'] = self.serializer_class.Meta.model.objects.get( + id=resp.data['id'] + ).key + return resp + + def get(self, request, *args, **kwargs): + """List of user tokens for current user.""" + return super().get(request, *args, **kwargs) + + +class TokenDetailView(TokenMixin, DestroyAPIView, RetrieveAPI): + """Details for a user token.""" + def perform_destroy(self, instance): """Revoke token.""" instance.revoked = True @@ -314,7 +378,7 @@ user_urls = [ path( 'tokens/', include([ - path('/', TokenListView.as_view(), name='api-token-detail'), + path('/', TokenDetailView.as_view(), name='api-token-detail'), path('', TokenListView.as_view(), name='api-token-list'), ]), ), diff --git a/src/backend/InvenTree/users/serializers.py b/src/backend/InvenTree/users/serializers.py index c31e24deeb..15cb5fc796 100644 --- a/src/backend/InvenTree/users/serializers.py +++ b/src/backend/InvenTree/users/serializers.py @@ -127,6 +127,9 @@ class ApiTokenSerializer(InvenTreeModelSerializer): """Serializer for the ApiToken model.""" in_use = serializers.SerializerMethodField(read_only=True) + user = serializers.PrimaryKeyRelatedField( + queryset=User.objects.all(), required=False + ) def get_in_use(self, token: ApiToken) -> bool: """Return True if the token is currently used to call the endpoint.""" @@ -153,6 +156,26 @@ class ApiTokenSerializer(InvenTreeModelSerializer): 'in_use', ] + def validate(self, data): + """Validate the data for the serializer.""" + if 'user' not in data: + data['user'] = self.context['request'].user + return super().validate(data) + + +class GetAuthTokenSerializer(serializers.Serializer): + """Serializer for the GetAuthToken API endpoint.""" + + class Meta: + """Meta options for GetAuthTokenSerializer.""" + + model = ApiToken + fields = ['token', 'name', 'expiry'] + + token = serializers.CharField(read_only=True) + name = serializers.CharField() + expiry = serializers.DateField(read_only=True) + class BriefUserProfileSerializer(InvenTreeModelSerializer): """Brief serializer for the UserProfile model.""" diff --git a/src/frontend/src/pages/Index/Settings/AccountSettings/SecurityContent.tsx b/src/frontend/src/pages/Index/Settings/AccountSettings/SecurityContent.tsx index da18c2bffc..368cfb49cc 100644 --- a/src/frontend/src/pages/Index/Settings/AccountSettings/SecurityContent.tsx +++ b/src/frontend/src/pages/Index/Settings/AccountSettings/SecurityContent.tsx @@ -23,23 +23,18 @@ import { hideNotification, showNotification } from '@mantine/notifications'; import { IconAlertCircle, IconAt, - IconCircleX, IconExclamationCircle, IconX } from '@tabler/icons-react'; import { useQuery } from '@tanstack/react-query'; -import { useCallback, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { api } from '../../../../App'; import { StylishText } from '../../../../components/items/StylishText'; import { ApiEndpoints } from '../../../../enums/ApiEndpoints'; import { ProviderLogin, authApi } from '../../../../functions/auth'; -import { showApiErrorMessage } from '../../../../functions/notifications'; -import { useTable } from '../../../../hooks/UseTable'; import { apiUrl, useServerApiState } from '../../../../states/ApiState'; import type { AuthConfig, Provider } from '../../../../states/states'; -import { BooleanColumn } from '../../../../tables/ColumnRenderers'; -import { InvenTreeTable } from '../../../../tables/InvenTreeTable'; -import type { RowAction } from '../../../../tables/RowActions'; +import { ApiTokenTable } from '../../../../tables/settings/ApiTokenTable'; import { QrRegistrationForm } from './QrRegistrationForm'; import { useReauth } from './useConfirm'; @@ -91,7 +86,7 @@ export function SecurityContent() { {t`Access Tokens`} - + @@ -715,68 +710,3 @@ async function runActionWithFallback( }); } } - -function TokenSection() { - const table = useTable('api-tokens', 'id'); - - const tableColumns = useMemo(() => { - return [ - { - accessor: 'name' - }, - BooleanColumn({ - accessor: 'active' - }), - { - accessor: 'token' - }, - { - accessor: 'last_seen' - }, - { - accessor: 'expiry' - } - ]; - }, []); - - const rowActions = useCallback((record: any): RowAction[] => { - return [ - { - title: t`Revoke`, - color: 'red', - hidden: !record.active || record.in_use, - icon: , - onClick: () => { - revokeToken(record.id); - } - } - ]; - }, []); - - const revokeToken = async (id: string) => { - api - .delete(apiUrl(ApiEndpoints.user_tokens, id)) - .then(() => { - table.refreshTable(); - }) - .catch((error) => { - showApiErrorMessage({ - error: error, - title: t`Error revoking token` - }); - }); - }; - - return ( - - ); -} diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/UserManagementPanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/UserManagementPanel.tsx index bb1bc8d59c..afc12a10a1 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/UserManagementPanel.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/UserManagementPanel.tsx @@ -3,44 +3,47 @@ import { Accordion } from '@mantine/core'; import { StylishText } from '../../../../components/items/StylishText'; import { GlobalSettingList } from '../../../../components/settings/SettingList'; +import { ApiTokenTable } from '../../../../tables/settings/ApiTokenTable'; import { GroupTable } from '../../../../tables/settings/GroupTable'; import { UserTable } from '../../../../tables/settings/UserTable'; export default function UserManagementPanel() { return ( - <> - - - - {t`Users`} - - - - - - - - {t`Groups`} - - - - - - - - {t`Settings`} - - - - - - - + + + + {t`Users`} + + + + + + + + {t`Groups`} + + + + + + + + {t`Settings`} + + + + + + + + {t`Tokens`} + + + + + + ); } diff --git a/src/frontend/src/tables/settings/ApiTokenTable.tsx b/src/frontend/src/tables/settings/ApiTokenTable.tsx new file mode 100644 index 0000000000..c072602bc5 --- /dev/null +++ b/src/frontend/src/tables/settings/ApiTokenTable.tsx @@ -0,0 +1,201 @@ +import { Trans, t } from '@lingui/macro'; +import { Badge, Code, Flex, Modal, Text } from '@mantine/core'; +import { useDisclosure } from '@mantine/hooks'; +import { IconCircleX } from '@tabler/icons-react'; +import { useCallback, useMemo, useState } from 'react'; +import { api } from '../../App'; +import { AddItemButton } from '../../components/buttons/AddItemButton'; +import { CopyButton } from '../../components/buttons/CopyButton'; +import { StylishText } from '../../components/items/StylishText'; +import { ApiEndpoints } from '../../enums/ApiEndpoints'; +import { showApiErrorMessage } from '../../functions/notifications'; +import { useCreateApiFormModal } from '../../hooks/UseForm'; +import { useTable } from '../../hooks/UseTable'; +import { apiUrl } from '../../states/ApiState'; +import type { RowAction } from '../../tables/RowActions'; +import { BooleanColumn } from '../ColumnRenderers'; +import { type TableFilter, UserFilter } from '../Filter'; +import { InvenTreeTable } from '../InvenTreeTable'; + +export function ApiTokenTable({ + only_myself = true +}: Readonly<{ only_myself: boolean }>) { + const [token, setToken] = useState(''); + const [opened, { open, close }] = useDisclosure(false); + + const generateToken = useCreateApiFormModal({ + url: ApiEndpoints.user_tokens, + title: t`Generate Token`, + fields: { name: {} }, + successMessage: t`Token generated`, + onFormSuccess: (data: any) => { + setToken(data.token); + open(); + table.refreshTable(); + } + }); + const tableActions = useMemo(() => { + if (only_myself) + return [ + generateToken.open()} + /> + ]; + return []; + }, [only_myself]); + + const table = useTable('api-tokens', 'id'); + + const tableColumns = useMemo(() => { + const cols = [ + { + accessor: 'name', + title: t`Name`, + sortable: true + }, + BooleanColumn({ + accessor: 'active', + title: t`Active`, + sortable: false + }), + BooleanColumn({ + accessor: 'revoked', + title: t`Revoked` + }), + { + accessor: 'token', + title: t`Token`, + render: (record: any) => { + return ( + <> + {record.token}{' '} + {record.in_use ? ( + + In Use + + ) : null} + + ); + } + }, + { + accessor: 'last_seen', + title: t`Last Seen`, + sortable: true + }, + { + accessor: 'expiry', + title: t`Expiry`, + sortable: true + }, + { + accessor: 'created', + title: t`Created`, + sortable: true + } + ]; + if (!only_myself) { + cols.push({ accessor: 'user', title: t`User`, sortable: true }); + } + return cols; + }, [only_myself]); + + const tableFilters: TableFilter[] = useMemo(() => { + const filters: TableFilter[] = [ + { + name: 'revoked', + label: t`Revoked`, + description: t`Show revoked tokens` + } + ]; + + if (!only_myself) { + filters.push( + UserFilter({ + name: 'user', + label: t`User`, + description: t`Filter by user` + }) + ); + } + return filters; + }, [only_myself]); + + const rowActions = useCallback((record: any): RowAction[] => { + return [ + { + title: t`Revoke`, + color: 'red', + hidden: !record.active || record.in_use, + icon: , + onClick: () => { + revokeToken(record.id); + } + } + ]; + }, []); + + const revokeToken = async (id: string) => { + let targetUrl = apiUrl(ApiEndpoints.user_tokens, id); + if (!only_myself) { + targetUrl += '?all_users=true'; + } + api + .delete(targetUrl) + .then(() => { + table.refreshTable(); + }) + .catch((error) => { + showApiErrorMessage({ + error: error, + title: t`Error revoking token` + }); + }); + }; + + const urlParams = useMemo(() => { + if (only_myself) return {}; + return { all_users: true }; + }, [only_myself]); + + return ( + <> + {only_myself && ( + <> + {generateToken.modal} + {t`Token`}} + centered + > + + + Tokens are only shown once - make sure to note it down. + + + + {token} + + + + + )} + + + ); +}