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}