diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py
index 86096d66fa..1521f8c5cf 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 = 518
+INVENTREE_API_VERSION = 519
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
INVENTREE_API_TEXT = """
+v519 -> 2026-07-09 : https://github.com/inventree/InvenTree/pull/TODO
+ - Adds optional "roles" and "permissions" fields to the /user/me/ API endpoint, via the "?roles=true" query parameter
+
v518 -> 2026-07-09 : https://github.com/inventree/InvenTree/pull/12341
- Enable import of internal part prices via the API
diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py
index 13ef2fa501..1c37e8f52d 100644
--- a/src/backend/InvenTree/users/api.py
+++ b/src/backend/InvenTree/users/api.py
@@ -248,6 +248,24 @@ class MeUserDetail(RetrieveUpdateAPI, UserDetail):
"""
return None
+ @extend_schema(
+ parameters=[
+ OpenApiParameter(
+ name='roles',
+ type=bool,
+ location=OpenApiParameter.QUERY,
+ description='Include the roles and permissions associated with the current user in the response',
+ )
+ ]
+ )
+ def get(self, request, *args, **kwargs):
+ """Retrieve details for the current user.
+
+ Pass '?roles=true' to also include the user's roles and permissions
+ (previously only available via the separate '/user/me/roles/' endpoint).
+ """
+ return super().get(request, *args, **kwargs)
+
class UserList(ListCreateAPI):
"""List endpoint for detail on all users.
diff --git a/src/backend/InvenTree/users/serializers.py b/src/backend/InvenTree/users/serializers.py
index 417a863ae3..6b13f4577c 100644
--- a/src/backend/InvenTree/users/serializers.py
+++ b/src/backend/InvenTree/users/serializers.py
@@ -78,37 +78,47 @@ class RoleSerializer(InvenTreeModelSerializer):
def get_roles(self, user: User) -> dict:
"""Roles associated with the user."""
- roles = {}
-
- # Cache the 'groups' queryset for the user
- groups = prefetch_rule_sets(user)
-
- for ruleset in RULESET_CHOICES:
- role, _text = ruleset
-
- permissions = []
-
- for permission in RULESET_PERMISSIONS:
- if check_user_role(user, role, permission, groups=groups):
- permissions.append(permission)
-
- if len(permissions) > 0:
- roles[role] = permissions
- else:
- roles[role] = None # pragma: no cover
-
- return roles
+ return get_user_roles(user)
def get_permissions(self, user: User) -> dict:
"""Permissions associated with the user."""
- if user.is_superuser:
- permissions = Permission.objects.all()
- else:
- permissions = Permission.objects.filter(
- Q(user=user) | Q(group__user=user)
- ).distinct()
+ return get_user_permissions(user)
- return generate_permission_dict(permissions)
+
+def get_user_roles(user: User) -> dict:
+ """Return a dict of the roles associated with the given user."""
+ roles = {}
+
+ # Cache the 'groups' queryset for the user
+ groups = prefetch_rule_sets(user)
+
+ for ruleset in RULESET_CHOICES:
+ role, _text = ruleset
+
+ permissions = []
+
+ for permission in RULESET_PERMISSIONS:
+ if check_user_role(user, role, permission, groups=groups):
+ permissions.append(permission)
+
+ if len(permissions) > 0:
+ roles[role] = permissions
+ else:
+ roles[role] = None # pragma: no cover
+
+ return roles
+
+
+def get_user_permissions(user: User) -> dict:
+ """Return a dict of the permissions associated with the given user."""
+ if user.is_superuser:
+ permissions = Permission.objects.all()
+ else:
+ permissions = Permission.objects.filter(
+ Q(user=user) | Q(group__user=user)
+ ).distinct()
+
+ return generate_permission_dict(permissions)
def generate_permission_dict(permissions) -> dict:
@@ -390,7 +400,7 @@ class UserSetPasswordSerializer(serializers.Serializer):
)
-class MeUserSerializer(ExtendedUserSerializer):
+class MeUserSerializer(FilterableSerializerMixin, ExtendedUserSerializer):
"""API serializer specifically for the 'me' endpoint."""
class Meta(ExtendedUserSerializer.Meta):
@@ -401,7 +411,11 @@ class MeUserSerializer(ExtendedUserSerializer):
"""
# Remove the 'group_ids' field, as this is not relevant for the 'me' endpoint
- fields = [f for f in ExtendedUserSerializer.Meta.fields if f != 'group_ids']
+ fields = [
+ *(f for f in ExtendedUserSerializer.Meta.fields if f != 'group_ids'),
+ 'roles',
+ 'permissions',
+ ]
read_only_fields = [
*ExtendedUserSerializer.Meta.read_only_fields,
@@ -412,6 +426,31 @@ class MeUserSerializer(ExtendedUserSerializer):
profile = UserProfileSerializer(many=False, read_only=True)
+ # Roles and permissions are only computed (and included) when the
+ # request explicitly asks for them via '?roles=true' - they require
+ # extra queries, and most callers of this endpoint only want basic
+ # user details. Shares a filter_name so both come back together, since
+ # they were previously served as a single '/user/me/roles/' response.
+ roles = OptionalField(
+ serializer_class=serializers.SerializerMethodField,
+ serializer_kwargs={'read_only': True},
+ filter_name='roles',
+ )
+
+ permissions = OptionalField(
+ serializer_class=serializers.SerializerMethodField,
+ serializer_kwargs={'allow_null': True, 'read_only': True},
+ filter_name='roles',
+ )
+
+ def get_roles(self, user: User) -> dict:
+ """Roles associated with the user."""
+ return get_user_roles(user)
+
+ def get_permissions(self, user: User) -> dict:
+ """Permissions associated with the user."""
+ return get_user_permissions(user)
+
# Redefine the fields from ExtendedUserSerializer, to ensure they are marked as read-only
is_staff = serializers.BooleanField(
label=_('Staff'),
diff --git a/src/frontend/src/components/buttons/ScanButton.tsx b/src/frontend/src/components/buttons/ScanButton.tsx
index b3acfba5f7..220345e8b3 100644
--- a/src/frontend/src/components/buttons/ScanButton.tsx
+++ b/src/frontend/src/components/buttons/ScanButton.tsx
@@ -4,11 +4,19 @@ import { t } from '@lingui/core/macro';
import { ActionIcon, Tooltip } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconQrcode } from '@tabler/icons-react';
-import BarcodeScanDialog, {
- type BarcodeScanCallback,
- type BarcodeScanSuccessCallback
+import { Suspense, lazy, useState } from 'react';
+import type {
+ BarcodeScanCallback,
+ BarcodeScanSuccessCallback
} from '../barcodes/BarcodeScanDialog';
+// Lazy loaded: ScanButton is rendered unconditionally in the nav Header (and
+// elsewhere), but the scan dialog itself is only ever needed once a user
+// actually opens it - deferring the import until first open (rather than
+// just lazy-loading the component, which would still fetch it on every
+// render) avoids pulling its dependency tree into every page load.
+const BarcodeScanDialog = lazy(() => import('../barcodes/BarcodeScanDialog'));
+
/**
* A button which opens the QR code scanner modal
*/
@@ -24,6 +32,12 @@ export function ScanButton({
hotkey?: boolean;
}) {
const [opened, { open, close }] = useDisclosure(false);
+ const [everOpened, setEverOpened] = useState(false);
+
+ function handleOpen() {
+ setEverOpened(true);
+ open();
+ }
if (hotkey) {
useInvenTreeHotkeys([
@@ -31,7 +45,7 @@ export function ScanButton({
'mod+Shift+B',
t`Open barcode scanner`,
() => {
- open();
+ handleOpen();
}
]
]);
@@ -42,19 +56,23 @@ export function ScanButton({
-
+ {everOpened && (
+
+
+
+ )}
>
);
}
diff --git a/src/frontend/src/components/importer/GlobalImporterDrawer.tsx b/src/frontend/src/components/importer/GlobalImporterDrawer.tsx
index de4a5a7427..77f3a987b2 100644
--- a/src/frontend/src/components/importer/GlobalImporterDrawer.tsx
+++ b/src/frontend/src/components/importer/GlobalImporterDrawer.tsx
@@ -1,5 +1,14 @@
+import { lazy } from 'react';
+
+import { Loadable } from '../../functions/loading';
import { useImporterState } from '../../states/ImporterState';
-import ImporterDrawer from './ImporterDrawer';
+
+// Lazy loaded: this pulls in InvenTreeTable (and its own sizeable
+// dependency tree) via ImportDataSelector, but an import session is only
+// ever open for a small fraction of page loads - the isOpen/sessionId
+// check below runs first regardless, so nothing here is fetched at all
+// unless the user actually opens the importer.
+const ImporterDrawer = Loadable(lazy(() => import('./ImporterDrawer')));
export default function GlobalImporterDrawer() {
const isOpen = useImporterState((state) => state.isOpen);
diff --git a/src/frontend/src/components/plugins/PluginContext.tsx b/src/frontend/src/components/plugins/PluginContext.tsx
index 748aaf0e70..f9167be29d 100644
--- a/src/frontend/src/components/plugins/PluginContext.tsx
+++ b/src/frontend/src/components/plugins/PluginContext.tsx
@@ -1,5 +1,5 @@
import { useMantineColorScheme, useMantineTheme } from '@mantine/core';
-import { useMemo } from 'react';
+import { Suspense, lazy, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useShallow } from 'zustand/react/shallow';
import { api, queryClient } from '../../App';
@@ -51,7 +51,18 @@ import { EditApiForm } from '../forms/ApiForm';
import { Thumbnail } from '../images/Thumbnail';
import { RenderInstance, RenderRemoteInstance } from '../render/Instance';
import { RenderInlineModel } from '../render/Instance';
-import { InvenTreeTableInternal } from '../tables/InvenTreeTable';
+
+// Lazy loaded: useInvenTreeContext is used by the always-mounted nav Layout
+// to build the context handed to plugins, but tables.renderTable is only
+// ever actually called by a plugin that chooses to render a table - which
+// is rare. Loading InvenTreeTable's (sizeable) module here unconditionally
+// would mean every page load pays for it regardless of whether any plugin
+// uses it.
+const InvenTreeTableInternal = lazy(() =>
+ import('../tables/InvenTreeTable').then((m) => ({
+ default: m.InvenTreeTableInternal
+ }))
+);
export const useInvenTreeContext = () => {
const [locale, host] = useLocalState(useShallow((s) => [s.language, s.host]));
@@ -99,10 +110,12 @@ export const useInvenTreeContext = () => {
},
tables: {
renderTable: (props: InvenTreeTableRenderProps) => (
-
+
+
+
)
},
forms: {
diff --git a/src/frontend/src/contexts/LanguageContext.tsx b/src/frontend/src/contexts/LanguageContext.tsx
index 2bf33232ad..df245badc6 100644
--- a/src/frontend/src/contexts/LanguageContext.tsx
+++ b/src/frontend/src/contexts/LanguageContext.tsx
@@ -6,6 +6,7 @@ import { type JSX, useEffect, useRef, useState } from 'react';
import { useStoredTableState } from '@lib/states/StoredTableState';
import { useShallow } from 'zustand/react/shallow';
import { api } from '../App';
+import { markLocaleReady } from '../functions/localeReady';
import { useLocalState } from '../states/LocalState';
import { useServerApiState } from '../states/ServerApiState';
import { fetchGlobalStates } from '../states/states';
@@ -140,8 +141,11 @@ export function LanguageContext({
// Update default Accept-Language headers
api.defaults.headers.common['Accept-Language'] = new_locales;
- // Reload server state (and refresh status codes)
- fetchGlobalStates();
+ // Reload server state (and refresh status codes). Forced: the
+ // Accept-Language header actually changed (initial set, or a real
+ // locale change), so this must not be skipped by the "already
+ // fetched" guard even if another caller already fetched once.
+ fetchGlobalStates(true);
// Clear out cached table column names
useStoredTableState.getState().clearTableColumnNames();
@@ -195,6 +199,7 @@ export async function activateLocale(locale: string | null) {
const { messages } = await import(`../locales/${localeDir}/messages.ts`);
i18n.load(locale, messages);
i18n.activate(locale);
+ markLocaleReady();
} catch (err) {
console.error(`Failed to load locale ${locale}:`, err);
}
diff --git a/src/frontend/src/functions/auth.tsx b/src/frontend/src/functions/auth.tsx
index b076fde75c..5e2068583d 100644
--- a/src/frontend/src/functions/auth.tsx
+++ b/src/frontend/src/functions/auth.tsx
@@ -15,7 +15,7 @@ import { api, setApiDefaults } from '../App';
import { useLocalState } from '../states/LocalState';
import { useServerApiState } from '../states/ServerApiState';
import { useUserState } from '../states/UserState';
-import { fetchGlobalStates } from '../states/states';
+import { fetchGlobalStates, resetGlobalStatesFetched } from '../states/states';
import { showLoginNotification } from './notifications';
import { generateUrl } from './urls';
@@ -171,7 +171,7 @@ export async function doBasicLogin(
// we are successfully logged in - gather required states for app
if (loginDone) {
await fetchUserState();
- await fetchGlobalStates();
+ await fetchGlobalStates(true);
observeProfile();
} else if (!success) {
clearUserState();
@@ -240,6 +240,7 @@ export const doLogout = async (navigate: NavigateFunction) => {
clearUserState();
clearCsrfCookie();
setAuthContext(undefined);
+ resetGlobalStatesFetched();
navigate('/login');
};
@@ -455,6 +456,10 @@ export const checkLoginState = async (
MfaSetupOk(navigate).then(async (isOk) => {
if (isOk) {
observeProfile();
+ // Not forced: this runs on every page load's auth check, and
+ // LanguageContext's own locale-activation effect (which always
+ // runs first, since it gates rendering of this component's whole
+ // route tree) will typically have already triggered this fetch.
await fetchGlobalStates();
followRedirect(navigate, redirect);
@@ -505,7 +510,7 @@ function handleSuccessFullAuth(
if (isOk) {
await fetchUserState();
observeProfile();
- await fetchGlobalStates();
+ await fetchGlobalStates(true);
if (location !== undefined) {
followRedirect(navigate, location?.state);
diff --git a/src/frontend/src/functions/loading.tsx b/src/frontend/src/functions/loading.tsx
index f0dbcb0894..2264871376 100644
--- a/src/frontend/src/functions/loading.tsx
+++ b/src/frontend/src/functions/loading.tsx
@@ -1,5 +1,11 @@
import { Center, Loader, MantineProvider, Stack } from '@mantine/core';
-import { type JSX, Suspense } from 'react';
+import {
+ type ComponentType,
+ type JSX,
+ Suspense,
+ useEffect,
+ useState
+} from 'react';
import { colorSchema } from '../contexts/colorSchema';
import { theme } from '../theme';
@@ -38,3 +44,56 @@ export function LoadingItem({ item }: Readonly<{ item: any }>): JSX.Element {
const Itm = Loadable(item);
return ;
}
+
+/**
+ * Like Loadable, but never suspends: the import is resolved into plain
+ * state instead of thrown as a Suspense promise, so React.lazy's commit-
+ * delay heuristic never kicks in (it can otherwise hold the first render -
+ * and everything below it - back by several hundred ms even once the chunk
+ * is cached).
+ *
+ * The import itself is NOT started at module-load: some chunks evaluate
+ * top-level i18n macros, which throws if that happens before the active
+ * locale is set. It starts on mount (same as React.lazy would), or earlier
+ * via the returned component's `.preload()`, which callers can invoke once
+ * they know it's safe to (e.g. once the locale is ready).
+ *
+ * Only use this for components that are needed on (or immediately after)
+ * initial load; for genuinely route-specific pages, prefer Loadable/lazy so
+ * the chunk isn't fetched until it's needed.
+ */
+export function EagerLoadable(
+ importFn: () => Promise<{ default: ComponentType }>
+): ComponentType & { preload: () => Promise> } {
+ let componentPromise: Promise> | null = null;
+
+ function preload() {
+ if (!componentPromise) {
+ componentPromise = importFn().then((m) => m.default);
+ }
+ return componentPromise;
+ }
+
+ function EagerLoaded(props: JSX.IntrinsicAttributes) {
+ const [Component, setComponent] = useState | null>(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ preload().then((C) => {
+ if (!cancelled) setComponent(() => C);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ if (!Component) {
+ return null;
+ }
+
+ return ;
+ }
+
+ EagerLoaded.preload = preload;
+ return EagerLoaded;
+}
diff --git a/src/frontend/src/functions/localeReady.ts b/src/frontend/src/functions/localeReady.ts
new file mode 100644
index 0000000000..3e2a43edbb
--- /dev/null
+++ b/src/frontend/src/functions/localeReady.ts
@@ -0,0 +1,24 @@
+/**
+ * Tiny pub/sub so code outside the React tree (e.g. router.tsx) can safely
+ * prefetch chunks that evaluate top-level i18n macros, without needing to
+ * know how/when LanguageContext finishes activating the locale.
+ */
+type Listener = () => void;
+
+let ready = false;
+const listeners = new Set();
+
+export function markLocaleReady() {
+ if (ready) return;
+ ready = true;
+ for (const listener of listeners) listener();
+ listeners.clear();
+}
+
+export function onLocaleReady(listener: Listener) {
+ if (ready) {
+ listener();
+ } else {
+ listeners.add(listener);
+ }
+}
diff --git a/src/frontend/src/pages/Auth/LoggedIn.tsx b/src/frontend/src/pages/Auth/LoggedIn.tsx
index 750bd86bbd..5907efc889 100644
--- a/src/frontend/src/pages/Auth/LoggedIn.tsx
+++ b/src/frontend/src/pages/Auth/LoggedIn.tsx
@@ -1,5 +1,4 @@
import { t } from '@lingui/core/macro';
-import { useDebouncedCallback } from '@mantine/hooks';
import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
@@ -9,10 +8,9 @@ import { Wrapper } from './Layout';
export default function Logged_In() {
const navigate = useNavigate();
const location = useLocation();
- const checkLoginStateDebounced = useDebouncedCallback(checkLoginState, 300);
useEffect(() => {
- checkLoginStateDebounced(navigate, location?.state);
+ checkLoginState(navigate, location?.state);
}, [navigate]);
return (
diff --git a/src/frontend/src/pages/Auth/Login.tsx b/src/frontend/src/pages/Auth/Login.tsx
index 9e9d0098ec..b58a16a49c 100644
--- a/src/frontend/src/pages/Auth/Login.tsx
+++ b/src/frontend/src/pages/Auth/Login.tsx
@@ -21,6 +21,7 @@ import {
} from '../../functions/auth';
import { useLocalState } from '../../states/LocalState';
import { useServerApiState } from '../../states/ServerApiState';
+import { useUserState } from '../../states/UserState';
import { Wrapper } from './Layout';
export default function Login() {
@@ -45,6 +46,9 @@ export default function Login() {
state.registration_enabled
])
);
+ const [loginChecked] = useUserState(
+ useShallow((state) => [state.login_checked])
+ );
const any_reg_enabled = registration_enabled() || sso_registration() || false;
const LoginMessage = useMemo(() => {
@@ -64,22 +68,26 @@ export default function Login() {
}, [server.customize]);
// Data manipulation functions
- function ChangeHost(newHost: string | null): void {
+ // `force` defaults to true since this is normally a genuine host change
+ function ChangeHost(newHost: string | null, force = true): void {
if (newHost === null) return;
setHost(hostList[newHost]?.host, newHost);
setApiDefaults();
const traceid = setTraceId();
- fetchServerApiState();
+ fetchServerApiState(force);
removeTraceId(traceid);
}
// Set default host to localhost if no host is selected
useEffect(() => {
if (hostKey === '') {
- ChangeHost(defaultHostKey);
+ ChangeHost(defaultHostKey, false);
}
- checkLoginState(navigate, location?.state, true);
+ // Only check here if a check hasn't already happened this session
+ if (!loginChecked) {
+ checkLoginState(navigate, location?.state, true);
+ }
// check if we got login params (login and password)
if (searchParams.has('login') && searchParams.has('password')) {
diff --git a/src/frontend/src/router.tsx b/src/frontend/src/router.tsx
index ddeb3a2136..6c8596712a 100644
--- a/src/frontend/src/router.tsx
+++ b/src/frontend/src/router.tsx
@@ -1,18 +1,18 @@
import { lazy } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
-import { Loadable } from './functions/loading';
+import { EagerLoadable, Loadable } from './functions/loading';
+import { onLocaleReady } from './functions/localeReady';
// Lazy loaded pages
-export const LayoutComponent = Loadable(
- lazy(() => import('./components/nav/Layout')),
- true,
- true
+// These two are mutually exclusive and one of them is always needed
+// immediately on initial load, so they're loaded eagerly rather than via
+// Loadable/lazy - see EagerLoadable for why.
+export const LayoutComponent = EagerLoadable(
+ () => import('./components/nav/Layout')
);
-export const LoginLayoutComponent = Loadable(
- lazy(() => import('./pages/Auth/Layout')),
- true,
- true
+export const LoginLayoutComponent = EagerLoadable(
+ () => import('./pages/Auth/Layout')
);
export const Home = Loadable(lazy(() => import('./pages/Index/Home')));
@@ -127,11 +127,21 @@ export const NotFound = Loadable(
// Auth
export const Login = Loadable(lazy(() => import('./pages/Auth/Login')));
-export const LoggedIn = Loadable(
- lazy(() => import('./pages/Auth/LoggedIn')),
- true,
- true
-);
+// LoggedIn is the auth-check gateway hit by every fresh, unauthenticated
+// page load (redirected to from ProtectedRoute) - load it eagerly too.
+export const LoggedIn = EagerLoadable(() => import('./pages/Auth/LoggedIn'));
+
+// These three are all needed within the first render pass or two of any
+// fresh page load, so start fetching them as soon as it's safe to (i.e. as
+// soon as the active locale is set) rather than waiting for each to mount
+// in turn - mounting only happens after the previous one in the chain has
+// already rendered and redirected, so waiting for mount compounds several
+// round trips of otherwise-avoidable latency.
+onLocaleReady(() => {
+ LayoutComponent.preload();
+ LoginLayoutComponent.preload();
+ LoggedIn.preload();
+});
export const Logout = Loadable(lazy(() => import('./pages/Auth/Logout')));
export const Register = Loadable(lazy(() => import('./pages/Auth/Register')));
export const Mfa = Loadable(lazy(() => import('./pages/Auth/MFA')));
diff --git a/src/frontend/src/states/ServerApiState.tsx b/src/frontend/src/states/ServerApiState.tsx
index 9a3a313981..2d7a99e447 100644
--- a/src/frontend/src/states/ServerApiState.tsx
+++ b/src/frontend/src/states/ServerApiState.tsx
@@ -11,7 +11,7 @@ import type { ServerAPIProps } from './states';
interface ServerApiStateProps {
server: ServerAPIProps;
setServer: (newServer: ServerAPIProps) => void;
- fetchServerApiState: () => Promise;
+ fetchServerApiState: (force?: boolean) => Promise;
auth_config?: AuthConfig;
auth_context?: AuthContext;
setAuthContext: (auth_context: AuthContext | undefined) => void;
@@ -31,13 +31,24 @@ function get_server_setting(val: any) {
return val;
}
+let pendingServerApiFetch: Promise | null = null;
+let serverApiFetched = false;
+
export const useServerApiState = create()(
persist(
(set, get) => ({
server: emptyServerAPI,
setServer: (newServer: ServerAPIProps) => set({ server: newServer }),
- fetchServerApiState: async () => {
- await Promise.all([
+ fetchServerApiState: async (force = false) => {
+ if (pendingServerApiFetch && !force) {
+ return pendingServerApiFetch;
+ }
+
+ if (serverApiFetched && !force) {
+ return;
+ }
+
+ pendingServerApiFetch = Promise.all([
// Fetch server data
api
.get(apiUrl(ApiEndpoints.api_server_info))
@@ -59,7 +70,14 @@ export const useServerApiState = create()(
.catch(() => {
console.error('ERR: Error fetching SSO information');
})
- ]);
+ ]).then(() => {});
+
+ try {
+ await pendingServerApiFetch;
+ serverApiFetched = true;
+ } finally {
+ pendingServerApiFetch = null;
+ }
},
auth_config: undefined,
auth_context: undefined,
diff --git a/src/frontend/src/states/UserState.tsx b/src/frontend/src/states/UserState.tsx
index f6e309861a..c90c769ee7 100644
--- a/src/frontend/src/states/UserState.tsx
+++ b/src/frontend/src/states/UserState.tsx
@@ -72,59 +72,36 @@ export const useUserState = create((set, get) => ({
return;
}
- // Fetch user data
- await api
+ // Fetch user data along with role/permission data in a single request -
+ // the '?roles=true' param asks the API to include the same role and
+ // permission data that used to require a separate request to
+ // user_me_roles.
+ const response = await api
.get(apiUrl(ApiEndpoints.user_me), {
+ params: { roles: true },
timeout: 2000
})
- .then((response) => {
- if (response.status == 200) {
- const user: UserProps = {
- pk: response.data.pk,
- first_name: response.data?.first_name ?? '',
- last_name: response.data?.last_name ?? '',
- email: response.data.email,
- username: response.data.username,
- groups: response.data.groups,
- profile: response.data.profile
- };
- get().setUser(user);
- // profile info
- } else {
- get().clearUserState();
- }
- })
- .catch(() => {
- get().clearUserState();
- });
+ .catch(() => undefined);
- if (!get().isLoggedIn()) {
+ if (response?.status !== 200) {
+ get().clearUserState();
return;
}
- // Fetch role data
- await api
- .get(apiUrl(ApiEndpoints.user_me_roles))
- .then((response) => {
- if (response.status == 200) {
- const user: UserProps = get().user as UserProps;
-
- // Update user with role data
- if (user) {
- user.roles = response.data?.roles ?? {};
- user.permissions = response.data?.permissions ?? {};
- user.is_staff = response.data?.is_staff ?? false;
- user.is_superuser = response.data?.is_superuser ?? false;
- get().setUser(user);
- }
- } else {
- get().clearUserState();
- }
- })
- .catch((_error) => {
- console.error('ERR: Error fetching user roles');
- get().clearUserState();
- });
+ const user: UserProps = {
+ pk: response.data.pk,
+ first_name: response.data?.first_name ?? '',
+ last_name: response.data?.last_name ?? '',
+ email: response.data.email,
+ username: response.data.username,
+ groups: response.data.groups,
+ profile: response.data.profile,
+ roles: response.data?.roles ?? {},
+ permissions: response.data?.permissions ?? {},
+ is_staff: response.data?.is_staff ?? false,
+ is_superuser: response.data?.is_superuser ?? false
+ };
+ get().setUser(user);
},
isAuthed: () => {
return get().is_authed;
diff --git a/src/frontend/src/states/states.tsx b/src/frontend/src/states/states.tsx
index 4e9a1cfe33..667465a404 100644
--- a/src/frontend/src/states/states.tsx
+++ b/src/frontend/src/states/states.tsx
@@ -41,25 +41,62 @@ export interface ServerAPIProps {
};
}
+let pendingGlobalStatesFetch: Promise | null = null;
+let globalStatesFetched = false;
+
/*
* Refetch all global state information.
* Necessary on login, or if locale is changed.
+ *
+ * Calls are deduplicated: a call made while a fetch is already in flight
+ * reuses that fetch instead of starting another, and once a fetch has
+ * completed successfully, later calls are skipped entirely unless `force`
+ * is set. Pass `force` when the caller already knows the data needs to be
+ * current (an actual login, or a genuine locale change) - other callers
+ * (e.g. a page-load auth check that may run after the locale has already
+ * triggered a fetch) can rely on the default to avoid redundant requests.
*/
-export async function fetchGlobalStates() {
+export async function fetchGlobalStates(force = false) {
const { isLoggedIn } = useUserState.getState();
if (!isLoggedIn()) {
return;
}
- setApiDefaults();
- const traceId = setTraceId();
- await Promise.all([
- useServerApiState.getState().fetchServerApiState(),
- useUserSettingsState.getState().fetchSettings(),
- useGlobalSettingsState.getState().fetchSettings(),
- useGlobalStatusState.getState().fetchStatus(),
- useIconState.getState().fetchIcons()
- ]);
- removeTraceId(traceId);
+ if (pendingGlobalStatesFetch) {
+ return pendingGlobalStatesFetch;
+ }
+
+ if (globalStatesFetched && !force) {
+ return;
+ }
+
+ pendingGlobalStatesFetch = (async () => {
+ setApiDefaults();
+ const traceId = setTraceId();
+ await Promise.all([
+ useServerApiState.getState().fetchServerApiState(),
+ useUserSettingsState.getState().fetchSettings(),
+ useGlobalSettingsState.getState().fetchSettings(),
+ useGlobalStatusState.getState().fetchStatus(),
+ useIconState.getState().fetchIcons()
+ ]);
+ removeTraceId(traceId);
+ globalStatesFetched = true;
+ })();
+
+ try {
+ await pendingGlobalStatesFetch;
+ } finally {
+ pendingGlobalStatesFetch = null;
+ }
+}
+
+/*
+ * Reset the "already fetched" guard on fetchGlobalStates, so that the next
+ * call performs a real fetch even without `force`. Call this on logout so a
+ * subsequent login within the same page session (no full reload) refetches.
+ */
+export function resetGlobalStatesFetched() {
+ globalStatesFetched = false;
}
diff --git a/src/frontend/src/views/MainView.tsx b/src/frontend/src/views/MainView.tsx
index c72ce59521..625a841673 100644
--- a/src/frontend/src/views/MainView.tsx
+++ b/src/frontend/src/views/MainView.tsx
@@ -1,10 +1,9 @@
import '@mantine/core/styles.css';
import { useViewportSize } from '@mantine/hooks';
-import { lazy, useEffect } from 'react';
+import { type ComponentType, useEffect, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { setApiDefaults } from '../App';
-import { Loadable } from '../functions/loading';
import { useLocalState } from '../states/LocalState';
function checkMobile() {
@@ -13,22 +12,22 @@ function checkMobile() {
return false;
}
-const MobileAppView = Loadable(
- lazy(() => import('./MobileAppView')),
- true,
- true
-);
-const DesktopAppView = Loadable(
- lazy(() => import('./DesktopAppView')),
- true,
- true
-);
+// Import both views eagerly (outside React.lazy/Suspense): a lazy component
+// always suspends on its first render, and React's Suspense commit-delay
+// heuristic can then hold that first commit - and every effect beneath it,
+// including locale and layout loading - back by several hundred ms, even
+// though the underlying chunk is already cached by the time it's needed.
+const desktopViewPromise = import('./DesktopAppView').then((m) => m.default);
+const mobileViewPromise = import('./MobileAppView').then((m) => m.default);
// Main App
export default function MainView() {
const [allowMobile] = useLocalState(
useShallow((state) => [state.allowMobile])
);
+ const [DesktopView, setDesktopView] = useState(null);
+ const [MobileView, setMobileView] = useState(null);
+
// Set initial login status
useEffect(() => {
try {
@@ -39,15 +38,22 @@ export default function MainView() {
}
}, []);
+ useEffect(() => {
+ desktopViewPromise.then((Component) => setDesktopView(() => Component));
+ mobileViewPromise.then((Component) => setMobileView(() => Component));
+ }, []);
+
// Check if mobile
- if (
+ const isMobile =
!allowMobile &&
window.INVENTREE_SETTINGS.mobile_mode !== 'allow-always' &&
- checkMobile()
- ) {
- return ;
+ checkMobile();
+
+ const View = isMobile ? MobileView : DesktopView;
+
+ if (!View) {
+ return null;
}
- // Main App component
- return ;
+ return ;
}
diff --git a/src/frontend/tests/defaults.ts b/src/frontend/tests/defaults.ts
index b5c6e7120d..e27794a7c5 100644
--- a/src/frontend/tests/defaults.ts
+++ b/src/frontend/tests/defaults.ts
@@ -38,3 +38,8 @@ export const noaccessuser: UserType = {
username: 'noaccess',
testcred: 'youshallnotpass'
};
+
+export const engineeruser: UserType = {
+ username: 'engineer',
+ testcred: 'partsonly'
+};
diff --git a/src/frontend/tests/pui_login.spec.ts b/src/frontend/tests/pui_login.spec.ts
index ef7d7b2f08..66598a94c5 100644
--- a/src/frontend/tests/pui_login.spec.ts
+++ b/src/frontend/tests/pui_login.spec.ts
@@ -1,9 +1,61 @@
+import type { Page } from '@playwright/test';
+import { TOTP } from 'otpauth';
import { expect, test } from './baseFixtures.js';
-import { logoutUrl, noaccessuser } from './defaults.js';
+import { engineeruser, logoutUrl, noaccessuser } from './defaults.js';
import { navigate, openDetailAction } from './helpers.js';
import { doLogin } from './login.js';
-import { TOTP } from 'otpauth';
+const stripQueryAndHash = (url: string): string => {
+ try {
+ const parsed = new URL(url);
+ return `${parsed.origin}${parsed.pathname}`;
+ } catch {
+ return url.split('?')[0].split('#')[0];
+ }
+};
+
+const isScriptOrStyle = (resourceType: string): boolean => {
+ return resourceType === 'script' || resourceType === 'stylesheet';
+};
+
+const isCriticalBundle = (url: string): boolean => {
+ if (!/\.(js|css)$/i.test(url)) {
+ return false;
+ }
+
+ if (/\.map$/i.test(url)) {
+ return false;
+ }
+
+ if (/(@vite\/client|hot-update)/i.test(url)) {
+ return false;
+ }
+
+ return /\/(assets|static)\//i.test(url);
+};
+
+const loginAndMeasure = async (page: Page): Promise => {
+ await navigate(page, logoutUrl, { waitUntil: 'load' });
+ await page.waitForURL('**/web/login');
+
+ await page.getByLabel('login-username').fill(noaccessuser.username);
+ await page.getByLabel('login-password').fill(noaccessuser.testcred);
+
+ const start = Date.now();
+ await page.getByRole('button', { name: 'Log In' }).click();
+
+ await page.getByRole('link', { name: 'Dashboard' }).waitFor();
+ await page.getByRole('button', { name: 'navigation-menu' }).waitFor();
+ await page.waitForURL(/\/web(\/home)?/);
+ await page.waitForLoadState('networkidle');
+
+ // Ensure dashboard has completely loaded
+ await page.getByText('No Widgets Selected').waitFor();
+ await page.getByRole('button', { name: 'Norman Nothington' }).waitFor();
+ await page.waitForLoadState('networkidle');
+
+ return Date.now() - start;
+};
/**
* Test various types of login failure
@@ -90,6 +142,256 @@ test('Login - Failures', async ({ page }) => {
}
});
+// Check that page load times do not exceed thresholds for cold/warm/hot login scenarios
+test('Login - Cold vs Warm vs Hot Load', async ({ page }) => {
+ // Ensure a fresh state for the cold login measurement.
+ await page.context().clearCookies();
+ await navigate(page, logoutUrl, { waitUntil: 'load' });
+ await page.waitForURL('**/web/login');
+ await page.evaluate(() => {
+ localStorage.clear();
+ sessionStorage.clear();
+ });
+
+ // Page load threshold values
+ // Note: Vite server in dev mode is significantly slower than production build
+ const COLD_MS_THRESHOLD: number = 5000;
+ const WARM_MS_THRESHOLD: number = 4000;
+ const HOT_MS_THRESHOLD: number = 3000;
+
+ const coldMs = await loginAndMeasure(page);
+
+ await navigate(page, logoutUrl, { waitUntil: 'load' });
+ await page.waitForURL('**/web/login');
+
+ const warmMs = await loginAndMeasure(page);
+
+ console.log('Cold MS:', coldMs, 'Warm MS:', warmMs);
+ expect(coldMs).toBeLessThan(COLD_MS_THRESHOLD);
+ expect(warmMs).toBeLessThan(WARM_MS_THRESHOLD);
+
+ // Perform a "hot" reload of the dashboard page, which should be faster than the warm login.
+ const start = Date.now();
+ await page.reload();
+ // Ensure dashboard has completely loaded
+ await page.getByText('No Widgets Selected').waitFor();
+ await page.getByRole('button', { name: 'Norman Nothington' }).waitFor();
+ await page.waitForLoadState('networkidle');
+
+ const hotMs = Date.now() - start;
+
+ console.log('Hot MS:', hotMs);
+ expect(hotMs).toBeLessThan(HOT_MS_THRESHOLD);
+});
+
+// Check for JS/CSS request failures and duplicate critical bundles during login boot
+test('Login - JS/CSS Boot Checks', async ({ page }) => {
+ const failedResources: string[] = [];
+ const criticalResourceCounts = new Map();
+
+ page.on('requestfailed', (request) => {
+ if (!isScriptOrStyle(request.resourceType())) {
+ return;
+ }
+
+ failedResources.push(
+ `${request.resourceType()} ${request.url()} (${request.failure()?.errorText ?? 'request failed'})`
+ );
+ });
+
+ page.on('requestfinished', async (request) => {
+ if (!isScriptOrStyle(request.resourceType())) {
+ return;
+ }
+
+ const response = await request.response();
+
+ if (!response) {
+ return;
+ }
+
+ const status = response.status();
+
+ if (status >= 400) {
+ failedResources.push(
+ `${request.resourceType()} ${request.url()} (HTTP ${status})`
+ );
+ }
+
+ const normalizedUrl = stripQueryAndHash(request.url());
+
+ if (isCriticalBundle(normalizedUrl)) {
+ const count = criticalResourceCounts.get(normalizedUrl) ?? 0;
+ criticalResourceCounts.set(normalizedUrl, count + 1);
+ }
+ });
+
+ await loginAndMeasure(page);
+
+ const duplicateCriticalBundles = [...criticalResourceCounts.entries()]
+ .filter(([, count]) => count > 1)
+ .map(([url, count]) => `${url} x${count}`);
+
+ expect(
+ failedResources,
+ `JS/CSS failures during login boot:\n${failedResources.join('\n')}`
+ ).toEqual([]);
+ expect(
+ duplicateCriticalBundles,
+ `Duplicate critical bundles during login boot:\n${duplicateCriticalBundles.join('\n')}`
+ ).toEqual([]);
+});
+
+// Check page redirect after login
+test('Login - Redirect on Login', async ({ page }) => {
+ await navigate(page, logoutUrl, { waitUntil: 'load' });
+ await page.waitForURL('**/web/login');
+
+ await navigate(page, 'settings/user/account', { waitUntil: 'load' });
+ await page.waitForURL('**/web/login');
+
+ await page.getByLabel('login-username').fill(engineeruser.username);
+ await page.getByLabel('login-password').fill(engineeruser.testcred);
+ await page.getByRole('button', { name: 'Log In' }).click();
+
+ await page.waitForURL('**/web/settings/user/account');
+ await page.getByRole('button', { name: 'action-menu-account' }).waitFor();
+ await page.getByRole('button', { name: 'Robert Shuruncle' }).waitFor();
+});
+
+// Test that login session persists across page reload
+test('Login - Session Persistence', async ({ page }) => {
+ await doLogin(page, {
+ user: engineeruser
+ });
+
+ await page.getByText('Use the menu to add widgets').waitFor();
+ await page.reload();
+ await page.getByRole('button', { name: 'navigation-menu' }).waitFor();
+
+ // Once we logout, the user session has been invalidated
+ await navigate(page, logoutUrl, { waitUntil: 'load' });
+ await page.waitForURL('**/web/login');
+
+ await page.goBack();
+ await page.waitForURL('**/web/login');
+ await page.getByLabel('login-username').waitFor();
+});
+
+// Test login session with forced network errors
+test('Login - Network Errors & Retry', async ({ page }) => {
+ await navigate(page, logoutUrl, { waitUntil: 'load' });
+ await page.waitForURL('**/web/login');
+
+ await page.getByLabel('login-username').fill(engineeruser.username);
+ await page.getByLabel('login-password').fill(engineeruser.testcred);
+
+ const loginEndpoint = /auth\/login/;
+
+ await page.route(loginEndpoint, (route) => {
+ route.fulfill({
+ status: 500,
+ contentType: 'application/json',
+ body: JSON.stringify({ detail: 'Simulated server failure' })
+ });
+ });
+
+ const loginButton = page.getByRole('button', { name: 'Log In' });
+ await loginButton.click();
+
+ await page.getByText('Login failed (500)').waitFor();
+ await page.getByText('Simulated server failure').waitFor();
+ await expect(loginButton).toBeEnabled();
+
+ await page.unroute(loginEndpoint);
+ await loginButton.click();
+ await page.getByRole('button', { name: 'navigation-menu' }).waitFor();
+
+ await navigate(page, logoutUrl, { waitUntil: 'load' });
+ await page.waitForURL('**/web/login');
+ await page.getByLabel('login-username').fill(noaccessuser.username);
+ await page.getByLabel('login-password').fill(noaccessuser.testcred);
+
+ await page.route(loginEndpoint, (route) => {
+ route.abort('internetdisconnected');
+ });
+
+ await loginButton.click();
+ await page.getByText('Login failed').first().waitFor();
+ await page.getByText('No response from server.').waitFor();
+ await expect(loginButton).toBeEnabled();
+ await page.getByLabel('login-username').waitFor();
+
+ await page.unroute(loginEndpoint);
+ await loginButton.click();
+ await page.getByRole('button', { name: 'navigation-menu' }).waitFor();
+});
+
+// Check for exposed tokens or cookies after login, and ensure session cookie is secure
+test('Login - Security Regression Checks', async ({ page }) => {
+ await doLogin(page, {
+ user: noaccessuser
+ });
+
+ const url = page.url();
+
+ expect(url).not.toMatch(/[?&](token|access_token|refresh_token|jwt|auth)=/i);
+
+ const storageData = await page.evaluate(() => {
+ return {
+ localStorageEntries: Object.entries(localStorage),
+ sessionStorageEntries: Object.entries(sessionStorage)
+ };
+ });
+
+ const suspiciousStorageEntries = [
+ ...storageData.localStorageEntries,
+ ...storageData.sessionStorageEntries
+ ].filter(([key, value]) => {
+ const combined = `${key} ${value}`;
+
+ return (
+ /(access_token|refresh_token|jwt|bearer)/i.test(combined) ||
+ /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(value)
+ );
+ });
+
+ expect(suspiciousStorageEntries).toEqual([]);
+
+ const cookies = await page.context().cookies();
+ const sessionCookie = cookies.find((cookie) => cookie.name === 'sessionid');
+
+ expect(sessionCookie).toBeDefined();
+ expect(sessionCookie?.httpOnly).toBeTruthy();
+ expect(['Lax', 'None', 'Strict']).toContain(sessionCookie?.sameSite ?? 'Lax');
+});
+
+// Check keyboard navigation of the login screen
+test('Login - Keyboard Focus', async ({ page }) => {
+ await navigate(page, logoutUrl, { waitUntil: 'load' });
+ await page.waitForURL('**/web/login');
+
+ const username = page.getByLabel('login-username');
+ const password = page.getByLabel('login-password');
+
+ await expect(username).toBeVisible();
+ await expect(password).toBeVisible();
+
+ await username.focus();
+ await page.keyboard.type(noaccessuser.username);
+ await page.keyboard.press('Tab');
+ await expect(password).toBeFocused();
+ await page.keyboard.type(noaccessuser.testcred);
+ await page.keyboard.press('Enter');
+
+ await page.getByRole('button', { name: 'navigation-menu' }).waitFor();
+
+ await navigate(page, logoutUrl, { waitUntil: 'load' });
+ await page.waitForURL('**/web/login');
+ await page.getByRole('button', { name: 'Log In' }).click();
+ await expect(page.getByLabel('login-username')).toBeFocused();
+});
+
test('Login - Change Password', async ({ page }) => {
await doLogin(page, {
user: noaccessuser