diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 95b0caa66a..0cbdbf287b 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,12 +1,15 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 226 +INVENTREE_API_VERSION = 227 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v227 - 2024-07-19 : https://github.com/inventree/InvenTree/pull/7693/ + - Adds endpoints to list and revoke the tokens issued to the current user + v226 - 2024-07-15 : https://github.com/inventree/InvenTree/pull/7648 - Adds barcode generation API endpoint diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py index fd6400b6bc..e784f81393 100644 --- a/src/backend/InvenTree/users/api.py +++ b/src/backend/InvenTree/users/api.py @@ -18,6 +18,8 @@ from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_ from rest_framework import exceptions, permissions from rest_framework.authentication import BasicAuthentication from rest_framework.decorators import authentication_classes +from rest_framework.generics import DestroyAPIView +from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView @@ -34,7 +36,12 @@ from InvenTree.mixins import ( from InvenTree.serializers import ExendedUserSerializer, UserCreateSerializer from InvenTree.settings import FRONTEND_URL_BASE from users.models import ApiToken, Owner -from users.serializers import GroupSerializer, OwnerSerializer, RoleSerializer +from users.serializers import ( + ApiTokenSerializer, + GroupSerializer, + OwnerSerializer, + RoleSerializer, +) logger = logging.getLogger('inventree') @@ -342,6 +349,22 @@ class GetAuthToken(APIView): raise exceptions.NotAuthenticated() +class TokenListView(DestroyAPIView, ListAPI): + """List of registered tokens for current users.""" + + permission_classes = (IsAuthenticated,) + serializer_class = ApiTokenSerializer + + def get_queryset(self): + """Only return data for current user.""" + return ApiToken.objects.filter(user=self.request.user) + + def perform_destroy(self, instance): + """Revoke token.""" + instance.revoked = True + instance.save() + + class LoginRedirect(RedirectView): """Redirect to the correct starting page after backend login.""" @@ -356,6 +379,13 @@ class LoginRedirect(RedirectView): user_urls = [ path('roles/', RoleDetails.as_view(), name='api-user-roles'), path('token/', GetAuthToken.as_view(), name='api-token'), + path( + 'tokens/', + include([ + path('/', TokenListView.as_view(), name='api-token-detail'), + path('', TokenListView.as_view(), name='api-token-list'), + ]), + ), path('me/', MeUserDetail.as_view(), name='api-user-me'), path( 'owner/', diff --git a/src/backend/InvenTree/users/serializers.py b/src/backend/InvenTree/users/serializers.py index c6c34fde3b..8455dfd2b6 100644 --- a/src/backend/InvenTree/users/serializers.py +++ b/src/backend/InvenTree/users/serializers.py @@ -8,7 +8,7 @@ from rest_framework import serializers from InvenTree.serializers import InvenTreeModelSerializer -from .models import Owner, RuleSet, check_user_role +from .models import ApiToken, Owner, RuleSet, check_user_role class OwnerSerializer(InvenTreeModelSerializer): @@ -116,5 +116,35 @@ def generate_permission_dict(permissions): perms[model] = [] perms[model].append(perm) - return perms + + +class ApiTokenSerializer(InvenTreeModelSerializer): + """Serializer for the ApiToken model.""" + + in_use = serializers.SerializerMethodField(read_only=True) + + def get_in_use(self, token: ApiToken) -> bool: + """Return True if the token is currently used to call the endpoint.""" + from InvenTree.middleware import get_token_from_request + + request = self.context.get('request') + rq_token = get_token_from_request(request) + return token.key == rq_token + + class Meta: + """Meta options for ApiTokenSerializer.""" + + model = ApiToken + fields = [ + 'created', + 'expiry', + 'id', + 'last_seen', + 'name', + 'token', + 'active', + 'revoked', + 'user', + 'in_use', + ] diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index 850c4506da..7a238d476a 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -14,6 +14,7 @@ export enum ApiEndpoints { user_me = 'user/me/', user_roles = 'user/roles/', user_token = 'user/token/', + user_tokens = 'user/tokens/', user_simple_login = 'email/generate/', user_reset = 'auth/password/reset/', user_reset_set = 'auth/password/reset/confirm/', diff --git a/src/frontend/src/pages/Index/Settings/AccountSettings/SecurityContent.tsx b/src/frontend/src/pages/Index/Settings/AccountSettings/SecurityContent.tsx index 1af5aadc17..b36f45ea91 100644 --- a/src/frontend/src/pages/Index/Settings/AccountSettings/SecurityContent.tsx +++ b/src/frontend/src/pages/Index/Settings/AccountSettings/SecurityContent.tsx @@ -8,6 +8,7 @@ import { Loader, Radio, Stack, + Table, Text, TextInput, Title, @@ -15,9 +16,10 @@ import { } from '@mantine/core'; import { IconAlertCircle, IconAt } from '@tabler/icons-react'; import { useQuery } from '@tanstack/react-query'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { api, queryClient } from '../../../../App'; +import { YesNoButton } from '../../../../components/buttons/YesNoButton'; import { PlaceholderPill } from '../../../../components/items/Placeholder'; import { ApiEndpoints } from '../../../../enums/ApiEndpoints'; import { apiUrl } from '../../../../states/ApiState'; @@ -85,6 +87,11 @@ export function SecurityContent() { )} )} + + + <Trans>Token</Trans> + + ); } @@ -329,3 +336,85 @@ function MfaContent() { ); } + +function TokenContent() { + const { isLoading, data, refetch } = useQuery({ + queryKey: ['token-list'], + queryFn: () => + api.get(apiUrl(ApiEndpoints.user_tokens)).then((res) => res.data) + }); + + function revokeToken(id: string) { + api + .delete(apiUrl(ApiEndpoints.user_tokens, id)) + .then(() => { + refetch(); + }) + .catch((res) => console.log(res.data)); + } + const rows = useMemo(() => { + if (isLoading || data === undefined) return null; + return data.map((token: any) => ( + + + + + {token.expiry} + {token.last_seen} + {token.token} + {token.name} + + {token.in_use ? ( + Token is used - no actions + ) : ( + + )} + + + )); + }, [data, isLoading]); + + /* renderer */ + if (isLoading) return ; + + if (data.length == 0) + return ( + } color="green"> + No tokens configured + + ); + + return ( + + + + + Active + + + Expiry + + + Last Seen + + + Token + + + Name + + + Actions + + + + {rows} +
+ ); +}