From 83508f4b12a4db83460c7caa8f14e6e1f3b638a3 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 10 Jul 2026 18:08:25 +1000 Subject: [PATCH] [UI] Page Load Improvements (#12334) * Parallel initial requests * Remove debouncing * Refactor lazy loading of desktop / mobile view * Reduce initial waiting for loadable components * Fix duplication of API calls * Further reduce duplicate calls * Combine user roles into /user/me/ endpoint * lazy load global import drawer * lazy load table in plugin context * Patch ScanButton * Added playwright tests for login * Adjust thresholds --- .../InvenTree/InvenTree/api_version.py | 5 +- src/backend/InvenTree/users/api.py | 18 ++ src/backend/InvenTree/users/serializers.py | 97 ++++-- .../src/components/buttons/ScanButton.tsx | 42 ++- .../importer/GlobalImporterDrawer.tsx | 11 +- .../src/components/plugins/PluginContext.tsx | 25 +- src/frontend/src/contexts/LanguageContext.tsx | 9 +- src/frontend/src/functions/auth.tsx | 11 +- src/frontend/src/functions/loading.tsx | 61 +++- src/frontend/src/functions/localeReady.ts | 24 ++ src/frontend/src/pages/Auth/LoggedIn.tsx | 4 +- src/frontend/src/pages/Auth/Login.tsx | 16 +- src/frontend/src/router.tsx | 38 ++- src/frontend/src/states/ServerApiState.tsx | 26 +- src/frontend/src/states/UserState.tsx | 69 ++-- src/frontend/src/states/states.tsx | 59 +++- src/frontend/src/views/MainView.tsx | 42 +-- src/frontend/tests/defaults.ts | 5 + src/frontend/tests/pui_login.spec.ts | 306 +++++++++++++++++- 19 files changed, 711 insertions(+), 157 deletions(-) create mode 100644 src/frontend/src/functions/localeReady.ts 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