feat: add notification on token issuance (#13141)

* add notification on token issuance

* add entries

* fix link url

* increate test timing

* ignore fallback line

* add test for notification
This commit is contained in:
Matthias Mair authored and GitHub committed 2026-11-01 14:08:58 +11:00
1 parent daa1c304f4
commit df7f9521d7
10 files changed
+177 -7

No files matched your search

+1
View File
@@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- [#13141](https://github.com/inventree/InvenTree/pull/13141) Adds a notification when a token is issued and warns if that notification can not be send
- [#13069](https://github.com/inventree/InvenTree/pull/13069) Adds an audit log which tracks changes made to internal data and exposes read-only API endpoints (/api/audit/) for accessing the audit log.
### Changed
@@ -1,11 +1,14 @@
"""InvenTree API version information."""
# InvenTree API version
INVENTREE_API_VERSION = 567
INVENTREE_API_VERSION = 568
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
v568 -> 2026-10-30 : https://github.com/inventree/InvenTree/pull/13141
- token endpoints now have a warning if notifications cannot be sent
v567 -> 2026-10-28 : https://github.com/inventree/InvenTree/pull/13128
- Allow ordering of SupplierPart API by "on_order" field
+2 -2
View File
@@ -867,7 +867,7 @@ class GeneralApiTests(InvenTreeAPITestCase):
self.assertEqual('InvenTree', data['server'])
# Test with token
token = self.get(url=reverse('api-token'), max_query_count=20).data['token']
token = self.get(url=reverse('api-token'), max_query_count=40).data['token']
self.client.logout()
# Anon
@@ -878,7 +878,7 @@ class GeneralApiTests(InvenTreeAPITestCase):
# Staff
response = self.get(
url, headers={'Authorization': f'Token {token}'}, max_query_count=20
url, headers={'Authorization': f'Token {token}'}, max_query_count=40
)
self.assertIsNotNone(data.get('active_plugins'))
self.assertGreater(len(response.json()['database']), 4)
@@ -0,0 +1,27 @@
{% extends "email/email.html" %}
{% load i18n %}
{% load inventree_extras %}
{% block title %}
{{ message }}
<p>{% trans "If you did not request this token, revoke it immediately and contact your administrator." %}</p>
{% if link %}
<p>{% trans "Click on the following link to review your API tokens" %}: <a href="{{ link }}">{{ link }}</a></p>
{% endif %}
{% endblock title %}
{% block body %}
<tr style="height: 3rem; border-bottom: 1px solid">
<th>{% trans "Token Name" %}</th>
<th>{% trans "Issued By" %}</th>
<th>{% trans "Expiry Date" %}</th>
</tr>
<tr style="height: 3rem">
<td style="text-align: center;">{{ token.name }}</td>
<td style="text-align: center;">{{ token.issued_by|default:"-" }}</td>
<td style="text-align: center;">{{ token.expiry }}</td>
</tr>
{% endblock body %}
+15 -1
View File
@@ -36,7 +36,14 @@ from InvenTree.mixins import (
)
from InvenTree.schema import exclude_from_schema
from InvenTree.settings import FRONTEND_URL_BASE
from users.models import ApiToken, Owner, RuleSet, TipResult, UserProfile
from users.models import (
ApiToken,
Owner,
RuleSet,
TipResult,
UserProfile,
check_email_warning,
)
from users.serializers import (
ApiTokenSerializer,
ExtendedUserSerializer,
@@ -441,6 +448,9 @@ class GetAuthToken(GenericAPIView):
'expiry': token.expiry,
}
if warning := check_email_warning():
data['warning'] = warning
# Ensure that the users session is logged in
if not get_user(request).is_authenticated:
login(request, user, backend='django.contrib.auth.backends.ModelBackend')
@@ -513,6 +523,10 @@ class TokenListView(TokenMixin, ListCreateAPI):
"""Create token and show key to user."""
resp = super().create(request, *args, **kwargs)
resp.data['token'] = self._created_token.token
if warning := check_email_warning():
resp.data['warning'] = warning
return resp
def get(self, request, *args, **kwargs):
+19
View File
@@ -385,6 +385,25 @@ class ApiToken(AuthToken, InvenTree.models.MetadataMixin):
return not self.revoked and not self.expired
def check_email_warning():
"""Check if email is configured and return a warning if not."""
from InvenTree.helpers_email import is_email_configured
if is_email_configured():
return None # pragma: no cover
return _('Email is not configured on this server - this is a security risk.')
@receiver(post_save, sender=ApiToken, dispatch_uid='api_token_notification')
def api_token_notification(sender, instance: ApiToken, created, **kwargs):
"""Notify the token owner that a token was made for them."""
if not created or isImportingData() or isReadOnlyCommand():
return
from users.tasks import notify_api_token
notify_api_token(instance)
class RuleSet(models.Model):
"""A RuleSet is somewhat like a superset of the django permission class, in that in encapsulates a bunch of permissions.
+2 -1
View File
@@ -142,11 +142,12 @@ class GetAuthTokenSerializer(serializers.Serializer):
"""Meta options for GetAuthTokenSerializer."""
model = ApiToken
fields = ['token', 'name', 'expiry']
fields = ['token', 'name', 'expiry', 'warning']
token = serializers.CharField(read_only=True)
name = serializers.CharField()
expiry = serializers.DateField(read_only=True)
warning = serializers.CharField(read_only=True, required=False)
class BriefUserProfileSerializer(InvenTreeModelSerializer):
+44 -1
View File
@@ -4,17 +4,60 @@ from typing import Any
from django.contrib.auth.models import Group, Permission
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _
import structlog
import common.notifications
import InvenTree.helpers
from InvenTree.helpers_model import construct_absolute_url
from InvenTree.ready import canAppAccessDatabase
from users.models import RuleSet
from users.models import ApiToken, RuleSet, check_email_warning
from users.permissions import get_model_permission_string, split_permission
from users.ruleset import RULESET_CHANGE_INHERIT, RULESET_CHOICES, RULESET_NAMES
logger = structlog.get_logger('inventree')
def notify_api_token(token: ApiToken):
"""Send warning that a token has been issued."""
user = token.user
issuer = token.issued_by
if issuer and issuer != user:
message = _(
f"A new API token '{token.name}' was issued for your account by {issuer.username}"
)
else:
message = _(f"A new API token '{token.name}' was issued for your account")
name = _('API Token Issued')
context = {
'name': name,
'message': message,
'token': token,
'link': construct_absolute_url(
InvenTree.helpers.pui_url('settings/user/security')
),
'template': {'html': 'email/api_token_issued.html', 'subject': name},
}
if check_email_warning():
logger.warning(
"INVE-W7: API token '%s' issued for user '%s' - but email not set up",
token.name,
user.username,
)
common.notifications.trigger_notification(
None,
'users.api_token_issued',
targets=[user],
context=context,
check_recent=False,
notification_uid=f'api_token_{token.pk}',
)
def rebuild_all_permissions() -> None:
"""Rebuild all user permissions.
+48
View File
@@ -8,6 +8,7 @@ from django.urls import reverse
from allauth.account.models import EmailAddress
from common.models import NotificationMessage
from InvenTree.unit_test import InvenTreeAPITestCase
from users.models import ApiToken, default_token
from users.ruleset import RULESET_NAMES, get_ruleset_models
@@ -502,6 +503,53 @@ class UserTokenTests(InvenTreeAPITestCase):
for k in keys:
self.assertIn(k, token.metadata)
def test_token_issued_notification(self):
"""A notification is sent whenever a token is issued, with a warning if email is unavailable."""
self.ensurePluginsLoaded()
url = reverse('api-token')
category = 'users.api_token_issued'
self.assertEqual(
NotificationMessage.objects.filter(category=category).count(), 0
)
# No email configured -> warning is returned, UI notification is still created
with self.assertLogs('inventree', level='WARNING') as logs:
data = self.get(url, data={'name': 'notify'}, expected_code=200).data
self.assertIn('warning', data)
self.assertIn('Email is not configured', data['warning'])
self.assertTrue(any('INVE-W7' in msg for msg in logs.output))
msgs = NotificationMessage.objects.filter(category=category, user=self.user)
self.assertEqual(msgs.count(), 1)
self.assertIn("'notify'", msgs.first().message)
# Re-issuing a token with the same name sends another notification
self.get(url, data={'name': 'notify'}, expected_code=200)
self.assertEqual(msgs.count(), 2)
# Email configured -> no warning
with self.settings(EMAIL_HOST='localhost', TESTING_BYPASS_MAILCHECK=True):
data = self.get(url, data={'name': 'mailed'}, expected_code=200).data
self.assertNotIn('warning', data)
# Superuser issuing a token for another user notifies that user
self.user.is_superuser = True
self.user.save()
other = User.objects.exclude(pk=self.user.pk).first()
data = self.post(
reverse('api-token-list'),
data={'name': 'for-other', 'user': other.pk},
expected_code=201,
).data
self.assertIn('warning', data)
msg = NotificationMessage.objects.get(category=category, user=other)
self.assertIn(self.user.username, msg.message)
def test_token_auth(self):
"""Test user token authentication."""
# Create a new token
@@ -9,6 +9,7 @@ import type { TableFilter } from '@lib/types/Filters';
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import {
Alert,
Badge,
Button,
Code,
@@ -20,7 +21,7 @@ import {
Textarea
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconCircleX } from '@tabler/icons-react';
import { IconAlertTriangle, IconCircleX } from '@tabler/icons-react';
import { useCallback, useMemo, useState } from 'react';
import { api } from '../../App';
import {
@@ -36,6 +37,7 @@ export function ApiTokenTable({
only_myself = true
}: Readonly<{ only_myself: boolean }>) {
const [token, setToken] = useState<string>('');
const [tokenWarning, setTokenWarning] = useState<string>('');
const [opened, { open, close }] = useDisclosure(false);
const [revokeTokenId, setRevokeTokenId] = useState<string | null>(null);
const [revocationReason, setRevocationReason] = useState<string>('');
@@ -48,6 +50,7 @@ export function ApiTokenTable({
successMessage: t`Token generated`,
onFormSuccess: (data: any) => {
setToken(data.token);
setTokenWarning(data.warning ?? '');
open();
table.refreshTable();
}
@@ -233,6 +236,17 @@ export function ApiTokenTable({
Tokens are only shown once - make sure to note it down.
</Trans>
</Text>
{tokenWarning && (
<Alert
color='red'
variant='filled'
icon={<IconAlertTriangle />}
title={t`Notification could not be sent`}
data-testid='api-token-email-warning'
>
{tokenWarning}
</Alert>
)}
<Paper p='sm'>
<Flex>
<Code>{token}</Code>