Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
7e01a7c
Add a explicit confirm when MFA Enforcing is turned on
matmair Nov 9, 2025
320ff76
Merge branch 'master' of https://github.com/inventree/InvenTree into …
matmair Nov 9, 2025
812d790
add error boundary for the case of a login enforcement
matmair Nov 9, 2025
0b5e9f9
ensure registration setup is redirected to
matmair Nov 10, 2025
2778d3f
fix auth url
matmair Nov 10, 2025
5881423
Merge branch 'master' of https://github.com/inventree/InvenTree into …
matmair Dec 6, 2025
6b8276e
adjust error boundary
matmair Dec 6, 2025
6bd8624
update test
matmair Dec 6, 2025
2908ea4
Merge branch 'master' into fix_mfa_enforce_sessions
matmair Jan 6, 2026
0d736b4
Merge branch 'master' into fix_mfa_enforce_sessions
matmair Jan 8, 2026
64ab607
Merge branch 'master' into fix_mfa_enforce_sessions
matmair Jan 12, 2026
6e4afad
Merge branch 'master' of https://github.com/inventree/InvenTree into …
matmair Jan 13, 2026
ce847c9
be more specific in enforcement flow
matmair Jan 13, 2026
e36c1b4
ensure we log the admin also out immidiatly after removing all mfa
matmair Jan 14, 2026
6e5cc05
small cleanup
matmair Jan 14, 2026
1cc2d38
sml chg
matmair Jan 14, 2026
e200907
fix execution order issues
matmair Jan 14, 2026
ca20bb9
clean up args
matmair Jan 14, 2026
0403fc0
Merge branch 'master' of https://github.com/inventree/InvenTree into …
matmair Jan 14, 2026
cef7d9d
cleanup
matmair Jan 14, 2026
add36f5
Merge branch 'master' into fix_mfa_enforce_sessions
matmair Jan 14, 2026
66de1bf
Merge branch 'master' into fix_mfa_enforce_sessions
matmair Jan 14, 2026
958483c
Merge branch 'fix_mfa_enforce_sessions' of https://github.com/matmair…
matmair Jan 14, 2026
55bb782
add test for mfa change logout
matmair Jan 14, 2026
952b0ad
fix IP in test
matmair Jan 14, 2026
8bb0c2c
add option to require an explicit confirm
matmair Jan 14, 2026
225126a
adapt ui to ask before patching
matmair Jan 14, 2026
d83deab
bump API version
matmair Jan 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/backend/InvenTree/InvenTree/api_version.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
"""InvenTree API version information."""

# InvenTree API version
INVENTREE_API_VERSION = 439
INVENTREE_API_VERSION = 440
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""

INVENTREE_API_TEXT = """

v440 -> 2026-01-15 : https://github.com/inventree/InvenTree/pull/10796
- Adds confirm and confirm_text to all settings

v439 -> 2026-01-09 : https://github.com/inventree/InvenTree/pull/11092
- Add missing nullable annotations

Expand Down
16 changes: 16 additions & 0 deletions src/backend/InvenTree/common/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -970,6 +970,22 @@ def model_name(self) -> str:

return setting.get('model', None)

def confirm(self) -> bool:
"""Return if this setting requires confirmation on change."""
setting = self.get_setting_definition(
self.key, **self.get_filters_for_instance()
)

return setting.get('confirm', False)

def confirm_text(self) -> str:
"""Return the confirmation text for this setting, if provided."""
setting = self.get_setting_definition(
self.key, **self.get_filters_for_instance()
)

return setting.get('confirm_text', '')

def model_filters(self) -> Optional[dict]:
"""Return the model filters associated with this setting."""
setting = self.get_setting_definition(
Expand Down
45 changes: 36 additions & 9 deletions src/backend/InvenTree/common/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,18 @@ class SettingsSerializer(InvenTreeModelSerializer):

choices = serializers.SerializerMethodField()

def get_choices(self, obj) -> list:
"""Returns the choices available for a given item."""
results = []

choices = obj.choices()

if choices:
for choice in choices:
results.append({'value': choice[0], 'display_name': choice[1]})

return results

model_name = serializers.CharField(read_only=True, allow_null=True)

model_filters = serializers.DictField(read_only=True)
Expand All @@ -108,17 +120,26 @@ def validate_value(self, value):

typ = serializers.CharField(read_only=True)

def get_choices(self, obj) -> list:
"""Returns the choices available for a given item."""
results = []

choices = obj.choices()
confirm = serializers.BooleanField(
read_only=True,
help_text=_('Indicates if changing this setting requires confirmation'),
)

if choices:
for choice in choices:
results.append({'value': choice[0], 'display_name': choice[1]})
confirm_text = serializers.CharField(read_only=True)

return results
def is_valid(self, *, raise_exception=False):
"""Validate the setting, including confirmation if required."""
ret = super().is_valid(raise_exception=raise_exception)
# Check if confirmation was provided if required
if self.instance.confirm():
req_data = self.context['request'].data
if not 'manual_confirm' in req_data or not req_data['manual_confirm']:
raise serializers.ValidationError({
'manual_confirm': _(
'This setting requires confirmation before changing. Please confirm the change.'
)
})
return ret


class GlobalSettingsSerializer(SettingsSerializer):
Expand All @@ -141,6 +162,8 @@ class Meta:
'api_url',
'typ',
'read_only',
'confirm',
'confirm_text',
]

read_only = serializers.SerializerMethodField(
Expand Down Expand Up @@ -184,6 +207,8 @@ class Meta:
'model_name',
'api_url',
'typ',
'confirm',
'confirm_text',
]

user = serializers.PrimaryKeyRelatedField(read_only=True)
Expand Down Expand Up @@ -232,6 +257,8 @@ class CustomMeta:
'typ',
'units',
'required',
'confirm',
'confirm_text',
]

# set Meta class
Expand Down
19 changes: 19 additions & 0 deletions src/backend/InvenTree/common/setting/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,20 @@ def reload_plugin_registry(setting):
registry.reload_plugins(full_reload=True, force_reload=True, collect=True)


def enforce_mfa(setting):
"""Enforce multifactor authentication for all users."""
from allauth.usersessions.models import UserSession

from common.models import logger

logger.info(
'Enforcing multifactor authentication for all users by signing out all sessions.'
)
for session in UserSession.objects.all():
session.end()
logger.info('All user sessions have been ended.')


def barcode_plugins() -> list:
"""Return a list of plugin choices which can be used for barcode generation."""
try:
Expand Down Expand Up @@ -1007,6 +1021,11 @@ class SystemSetId:
'description': _('Users must use multifactor security.'),
'default': False,
'validator': bool,
'confirm': True,
'confirm_text': _(
'Enabling this setting will require all users to set up multifactor authentication. All sessions will be disconnected immediately.'
),
'after_save': enforce_mfa,
},
'PLUGIN_ON_STARTUP': {
'name': _('Check plugins on startup'),
Expand Down
4 changes: 4 additions & 0 deletions src/backend/InvenTree/common/setting/type.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ class SettingsKeyType(TypedDict, total=False):
protected: Protected values are not returned to the client, instead "***" is returned (optional, default: False)
required: Is this setting required to work, can be used in combination with .check_all_settings(...) (optional, default: False)
model: Auto create a dropdown menu to select an associated model instance (e.g. 'company.company', 'auth.user' and 'auth.group' are possible too, optional)
confirm: Require an explicit confirmation before changing the setting (optional, default: False)
confirm_text: Text to display in the confirmation dialog (optional)
"""

name: str
Expand All @@ -46,6 +48,8 @@ class SettingsKeyType(TypedDict, total=False):
protected: bool
required: bool
model: str
confirm: bool
confirm_text: str


class InvenTreeSettingsKeyType(SettingsKeyType):
Expand Down
14 changes: 14 additions & 0 deletions src/backend/InvenTree/common/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,8 @@ def run_settings_check(self, key, setting):
'requires_restart',
'after_save',
'before_save',
'confirm',
'confirm_text',
]

for k in setting:
Expand Down Expand Up @@ -641,6 +643,18 @@ def test_company_name(self):
setting.refresh_from_db()
self.assertEqual(setting.value, val)

def test_mfa_change(self):
"""Test that changes in LOGIN_ENFORCE_MFA are handled correctly."""
# Setup admin users
self.user.usersession_set.create(ip='192.168.1.1')
self.assertEqual(self.user.usersession_set.count(), 1)

# Enable enforced MFA
set_global_setting('LOGIN_ENFORCE_MFA', True)

# There should be no user sessions now
self.assertEqual(self.user.usersession_set.count(), 0)

def test_api_detail(self):
"""Test that we can access the detail view for a setting based on the <key>."""
# These keys are invalid, and should return 404
Expand Down
1 change: 1 addition & 0 deletions src/frontend/lib/enums/ApiEndpoints.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export enum ApiEndpoints {
user_simple_login = 'email/generate/',

// User auth endpoints
auth_base = '/auth/',
user_reset = 'auth/v1/auth/password/request',
user_reset_set = 'auth/v1/auth/password/reset',
auth_pwd_change = 'auth/v1/account/password/change',
Expand Down
3 changes: 2 additions & 1 deletion src/frontend/lib/types/Auth.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ export enum FlowEnum {
MfaAuthenticate = 'mfa_authenticate',
Reauthenticate = 'reauthenticate',
MfaReauthenticate = 'mfa_reauthenticate',
MfaTrust = 'mfa_trust'
MfaTrust = 'mfa_trust',
MfaRegister = 'mfa_register'
}

export interface Flow {
Expand Down
2 changes: 2 additions & 0 deletions src/frontend/lib/types/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export interface Setting {
method?: string;
required?: boolean;
read_only?: boolean;
confirm?: boolean;
confirm_text?: string;
}

export interface SettingChoice {
Expand Down
4 changes: 3 additions & 1 deletion src/frontend/src/components/Boundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { ErrorBoundary, type FallbackRender } from '@sentry/react';
import { IconExclamationCircle } from '@tabler/icons-react';
import { type ReactNode, useCallback } from 'react';

function DefaultFallback({ title }: Readonly<{ title: string }>): ReactNode {
export function DefaultFallback({
title
}: Readonly<{ title: string }>): ReactNode {
return (
<Alert
color='red'
Expand Down
49 changes: 42 additions & 7 deletions src/frontend/src/components/settings/SettingItem.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { t } from '@lingui/core/macro';
import {
Button,
Group,
Expand All @@ -6,6 +7,7 @@ import {
Stack,
Switch,
Text,
Tooltip,
useMantineColorScheme
} from '@mantine/core';
import { IconEdit } from '@tabler/icons-react';
Expand All @@ -20,6 +22,24 @@ import { vars } from '../../theme';
import { Boundary } from '../Boundary';
import { RenderInstance } from '../render/Instance';

type ConfirmResult = {
requires_confirmation: boolean;
confirmed: boolean;
proceed?: boolean;
};
function confirmSettingChange(setting: Setting): ConfirmResult {
if (setting.confirm) {
const confirmed = window.confirm(
setting.confirm_text || t`Do you want to proceed to change this setting?`
);
return {
requires_confirmation: true,
confirmed: confirmed || false,
proceed: confirmed
};
}
return { requires_confirmation: false, confirmed: false, proceed: true };
}
/**
* Render a single setting value
*/
Expand All @@ -29,8 +49,8 @@ function SettingValue({
onToggle
}: Readonly<{
setting: Setting;
onEdit: (setting: Setting) => void;
onToggle: (setting: Setting, value: boolean) => void;
onEdit: (setting: Setting, confirmed: boolean) => void;
onToggle: (setting: Setting, value: boolean, confirmed: boolean) => void;
}>) {
// Determine the text to display for the setting value
const valueText: string = useMemo(() => {
Expand All @@ -54,15 +74,19 @@ function SettingValue({
// Launch the edit dialog for this setting
const editSetting = useCallback(() => {
if (!setting.read_only) {
onEdit(setting);
const confirm = confirmSettingChange(setting);
if (!confirm.proceed) return;
onEdit(setting, confirm.confirmed);
}
}, [setting, onEdit]);

// Toggle the setting value (if it is a boolean)
const toggleSetting = useCallback(
(event: any) => {
if (!setting.read_only) {
onToggle(setting, event.currentTarget.checked);
const confirm = confirmSettingChange(setting);
if (!confirm.proceed) return;
onToggle(setting, event.currentTarget.checked, confirm.confirmed);
}
},
[setting, onToggle]
Expand Down Expand Up @@ -170,8 +194,8 @@ export function SettingItem({
}: Readonly<{
setting: Setting;
shaded: boolean;
onEdit: (setting: Setting) => void;
onToggle: (setting: Setting, value: boolean) => void;
onEdit: (setting: Setting, confirmed: boolean) => void;
onToggle: (setting: Setting, value: boolean, confirmed: boolean) => void;
}>) {
const { colorScheme } = useMantineColorScheme();

Expand All @@ -192,7 +216,18 @@ export function SettingItem({
<Text size='xs'>{setting.description}</Text>
</Stack>
<Boundary label={`setting-value-${setting.key}`}>
<SettingValue setting={setting} onEdit={onEdit} onToggle={onToggle} />
<Group gap='xs' justify='right'>
{setting.confirm && (
<Tooltip label={t`This setting requires confirmation`}>
<IconEdit color={vars.colors.yellow[7]} size={16} />
</Tooltip>
)}
<SettingValue
setting={setting}
onEdit={onEdit}
onToggle={onToggle}
/>
</Group>
</Boundary>
</Group>
</Paper>
Expand Down
14 changes: 9 additions & 5 deletions src/frontend/src/components/settings/SettingList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export function SettingList({

// Callback for editing a single setting instance
const onValueEdit = useCallback(
(setting: Setting) => {
(setting: Setting, confirmed: boolean) => {
setSetting(setting);
editSettingModal.open();
},
Expand All @@ -100,13 +100,17 @@ export function SettingList({

// Callback for toggling a single boolean setting instance
const onValueToggle = useCallback(
(setting: Setting, value: boolean) => {
(setting: Setting, value: boolean, confirmed: boolean) => {
let data: any = {
value: value
};
if (confirmed) {
data = { ...data, manual_confirm: true };
}
api
.patch(
apiUrl(settingsState.endpoint, setting.key, settingsState.pathParams),
{
value: value
}
data
)
.then(() => {
notifications.hide('setting');
Expand Down
Loading
Loading