2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 11:36:44 +00:00

feat(frontend): Add token managment tools (#9244)

* Add typing

* feat(frontend): Add token managment tools
Closes https://github.com/inventree/InvenTree/issues/9166

* remove debug msg

* split responsibilities for token endpoint

* move ApiTokenTable

* add option for superusers to show all user tokens

* Add tokens to admin users interface

* adjust api text

* adress raised issues

* make stuff sortable / filterable
This commit is contained in:
Matthias Mair 2025-03-17 21:27:19 +01:00 committed by GitHub
parent 5a5f16fd47
commit f8de4e29a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 340 additions and 115 deletions

View File

@ -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

View File

@ -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('<int:pk>/', TokenListView.as_view(), name='api-token-detail'),
path('<int:pk>/', TokenDetailView.as_view(), name='api-token-detail'),
path('', TokenListView.as_view(), name='api-token-list'),
]),
),

View File

@ -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."""

View File

@ -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() {
<StylishText size='lg'>{t`Access Tokens`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<TokenSection />
<ApiTokenTable only_myself />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
@ -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: <IconCircleX />,
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 (
<InvenTreeTable
tableState={table}
url={apiUrl(ApiEndpoints.user_tokens)}
columns={tableColumns}
props={{
rowActions: rowActions,
enableSearch: false,
enableColumnSwitching: false
}}
/>
);
}

View File

@ -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 (
<>
<Accordion multiple defaultValue={['users']}>
<Accordion.Item value='users' key='users'>
<Accordion.Control>
<StylishText size='lg'>{t`Users`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<UserTable />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='groups' key='groups'>
<Accordion.Control>
<StylishText size='lg'>{t`Groups`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<GroupTable />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='settings' key='settings'>
<Accordion.Control>
<StylishText size='lg'>{t`Settings`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<GlobalSettingList
keys={[
'LOGIN_ENABLE_REG',
'SIGNUP_GROUP',
'LOGIN_ENABLE_SSO_REG'
]}
/>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</>
<Accordion multiple defaultValue={['users']}>
<Accordion.Item value='users' key='users'>
<Accordion.Control>
<StylishText size='lg'>{t`Users`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<UserTable />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='groups' key='groups'>
<Accordion.Control>
<StylishText size='lg'>{t`Groups`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<GroupTable />
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='settings' key='settings'>
<Accordion.Control>
<StylishText size='lg'>{t`Settings`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<GlobalSettingList
keys={['LOGIN_ENABLE_REG', 'SIGNUP_GROUP', 'LOGIN_ENABLE_SSO_REG']}
/>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='tokens' key='tokens'>
<Accordion.Control>
<StylishText size='lg'>{t`Tokens`}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<ApiTokenTable only_myself={false} />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}

View File

@ -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<string>('');
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 [
<AddItemButton
key={'generate'}
tooltip={t`Generate Token`}
onClick={() => 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 ? (
<Badge color='green'>
<Trans>In Use</Trans>
</Badge>
) : 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: <IconCircleX />,
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}
<Modal
opened={opened}
onClose={close}
title={<StylishText size='xl'>{t`Token`}</StylishText>}
centered
>
<Text c='dimmed'>
<Trans>
Tokens are only shown once - make sure to note it down.
</Trans>
</Text>
<Flex>
<Code>{token}</Code>
<CopyButton value={token} />
</Flex>
</Modal>
</>
)}
<InvenTreeTable
tableState={table}
url={apiUrl(ApiEndpoints.user_tokens)}
columns={tableColumns}
props={{
params: urlParams,
rowActions: rowActions,
enableSearch: false,
enableColumnSwitching: false,
tableActions: tableActions,
tableFilters: tableFilters
}}
/>
</>
);
}