From df7f9521d736a29e91422565b97b67d530c253b2 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Sun, 1 Nov 2026 04:08:58 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + .../InvenTree/InvenTree/api_version.py | 5 +- src/backend/InvenTree/InvenTree/test_api.py | 4 +- .../templates/email/api_token_issued.html | 27 +++++++++++ src/backend/InvenTree/users/api.py | 16 ++++++- src/backend/InvenTree/users/models.py | 19 ++++++++ src/backend/InvenTree/users/serializers.py | 3 +- src/backend/InvenTree/users/tasks.py | 45 ++++++++++++++++- src/backend/InvenTree/users/test_api.py | 48 +++++++++++++++++++ .../src/tables/settings/ApiTokenTable.tsx | 16 ++++++- 10 files changed, 177 insertions(+), 7 deletions(-) create mode 100644 src/backend/InvenTree/templates/email/api_token_issued.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b6b2c9523..c45d925135 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 5bc2857db1..f56c6d1f1c 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -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 diff --git a/src/backend/InvenTree/InvenTree/test_api.py b/src/backend/InvenTree/InvenTree/test_api.py index cb9fc8b344..8491cfa6de 100644 --- a/src/backend/InvenTree/InvenTree/test_api.py +++ b/src/backend/InvenTree/InvenTree/test_api.py @@ -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) diff --git a/src/backend/InvenTree/templates/email/api_token_issued.html b/src/backend/InvenTree/templates/email/api_token_issued.html new file mode 100644 index 0000000000..234eba4884 --- /dev/null +++ b/src/backend/InvenTree/templates/email/api_token_issued.html @@ -0,0 +1,27 @@ +{% extends "email/email.html" %} + +{% load i18n %} +{% load inventree_extras %} + +{% block title %} +{{ message }} +

{% trans "If you did not request this token, revoke it immediately and contact your administrator." %}

+{% if link %} +

{% trans "Click on the following link to review your API tokens" %}: {{ link }}

+{% endif %} +{% endblock title %} + +{% block body %} + + {% trans "Token Name" %} + {% trans "Issued By" %} + {% trans "Expiry Date" %} + + + + {{ token.name }} + {{ token.issued_by|default:"-" }} + {{ token.expiry }} + + +{% endblock body %} diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py index 8410539a14..8113b8780a 100644 --- a/src/backend/InvenTree/users/api.py +++ b/src/backend/InvenTree/users/api.py @@ -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): diff --git a/src/backend/InvenTree/users/models.py b/src/backend/InvenTree/users/models.py index 7175755e8b..33c13e3ca1 100644 --- a/src/backend/InvenTree/users/models.py +++ b/src/backend/InvenTree/users/models.py @@ -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. diff --git a/src/backend/InvenTree/users/serializers.py b/src/backend/InvenTree/users/serializers.py index 393b858cf2..11a1302e6c 100644 --- a/src/backend/InvenTree/users/serializers.py +++ b/src/backend/InvenTree/users/serializers.py @@ -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): diff --git a/src/backend/InvenTree/users/tasks.py b/src/backend/InvenTree/users/tasks.py index 491c233e2b..a412ccf0b9 100644 --- a/src/backend/InvenTree/users/tasks.py +++ b/src/backend/InvenTree/users/tasks.py @@ -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. diff --git a/src/backend/InvenTree/users/test_api.py b/src/backend/InvenTree/users/test_api.py index c332e2d81d..429226d1cb 100644 --- a/src/backend/InvenTree/users/test_api.py +++ b/src/backend/InvenTree/users/test_api.py @@ -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 diff --git a/src/frontend/src/tables/settings/ApiTokenTable.tsx b/src/frontend/src/tables/settings/ApiTokenTable.tsx index 30b18643df..029026cfe2 100644 --- a/src/frontend/src/tables/settings/ApiTokenTable.tsx +++ b/src/frontend/src/tables/settings/ApiTokenTable.tsx @@ -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(''); + const [tokenWarning, setTokenWarning] = useState(''); const [opened, { open, close }] = useDisclosure(false); const [revokeTokenId, setRevokeTokenId] = useState(null); const [revocationReason, setRevocationReason] = useState(''); @@ -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. + {tokenWarning && ( + } + title={t`Notification could not be sent`} + data-testid='api-token-email-warning' + > + {tokenWarning} + + )} {token}