mirror of
https://github.com/inventree/InvenTree.git
synced 2025-04-28 03:26:45 +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:
parent
5a5f16fd47
commit
f8de4e29a1
@ -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
|
||||
|
@ -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'),
|
||||
]),
|
||||
),
|
||||
|
@ -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."""
|
||||
|
@ -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
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
201
src/frontend/src/tables/settings/ApiTokenTable.tsx
Normal file
201
src/frontend/src/tables/settings/ApiTokenTable.tsx
Normal 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
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user