mirror of
https://github.com/inventree/InvenTree.git
synced 2026-07-17 20:23:50 +00:00
[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
This commit is contained in:
@@ -1,11 +1,14 @@
|
|||||||
"""InvenTree API version information."""
|
"""InvenTree API version information."""
|
||||||
|
|
||||||
# InvenTree API version
|
# 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."""
|
"""Increment this API version number whenever there is a significant change to the API that any clients need to know about."""
|
||||||
|
|
||||||
INVENTREE_API_TEXT = """
|
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
|
v518 -> 2026-07-09 : https://github.com/inventree/InvenTree/pull/12341
|
||||||
- Enable import of internal part prices via the API
|
- Enable import of internal part prices via the API
|
||||||
|
|
||||||
|
|||||||
@@ -248,6 +248,24 @@ class MeUserDetail(RetrieveUpdateAPI, UserDetail):
|
|||||||
"""
|
"""
|
||||||
return None
|
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):
|
class UserList(ListCreateAPI):
|
||||||
"""List endpoint for detail on all users.
|
"""List endpoint for detail on all users.
|
||||||
|
|||||||
@@ -78,6 +78,15 @@ class RoleSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
def get_roles(self, user: User) -> dict:
|
def get_roles(self, user: User) -> dict:
|
||||||
"""Roles associated with the user."""
|
"""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)
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_roles(user: User) -> dict:
|
||||||
|
"""Return a dict of the roles associated with the given user."""
|
||||||
roles = {}
|
roles = {}
|
||||||
|
|
||||||
# Cache the 'groups' queryset for the user
|
# Cache the 'groups' queryset for the user
|
||||||
@@ -99,8 +108,9 @@ class RoleSerializer(InvenTreeModelSerializer):
|
|||||||
|
|
||||||
return roles
|
return roles
|
||||||
|
|
||||||
def get_permissions(self, user: User) -> dict:
|
|
||||||
"""Permissions associated with the user."""
|
def get_user_permissions(user: User) -> dict:
|
||||||
|
"""Return a dict of the permissions associated with the given user."""
|
||||||
if user.is_superuser:
|
if user.is_superuser:
|
||||||
permissions = Permission.objects.all()
|
permissions = Permission.objects.all()
|
||||||
else:
|
else:
|
||||||
@@ -390,7 +400,7 @@ class UserSetPasswordSerializer(serializers.Serializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class MeUserSerializer(ExtendedUserSerializer):
|
class MeUserSerializer(FilterableSerializerMixin, ExtendedUserSerializer):
|
||||||
"""API serializer specifically for the 'me' endpoint."""
|
"""API serializer specifically for the 'me' endpoint."""
|
||||||
|
|
||||||
class Meta(ExtendedUserSerializer.Meta):
|
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
|
# 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 = [
|
read_only_fields = [
|
||||||
*ExtendedUserSerializer.Meta.read_only_fields,
|
*ExtendedUserSerializer.Meta.read_only_fields,
|
||||||
@@ -412,6 +426,31 @@ class MeUserSerializer(ExtendedUserSerializer):
|
|||||||
|
|
||||||
profile = UserProfileSerializer(many=False, read_only=True)
|
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
|
# Redefine the fields from ExtendedUserSerializer, to ensure they are marked as read-only
|
||||||
is_staff = serializers.BooleanField(
|
is_staff = serializers.BooleanField(
|
||||||
label=_('Staff'),
|
label=_('Staff'),
|
||||||
|
|||||||
@@ -4,11 +4,19 @@ import { t } from '@lingui/core/macro';
|
|||||||
import { ActionIcon, Tooltip } from '@mantine/core';
|
import { ActionIcon, Tooltip } from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { IconQrcode } from '@tabler/icons-react';
|
import { IconQrcode } from '@tabler/icons-react';
|
||||||
import BarcodeScanDialog, {
|
import { Suspense, lazy, useState } from 'react';
|
||||||
type BarcodeScanCallback,
|
import type {
|
||||||
type BarcodeScanSuccessCallback
|
BarcodeScanCallback,
|
||||||
|
BarcodeScanSuccessCallback
|
||||||
} from '../barcodes/BarcodeScanDialog';
|
} 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
|
* A button which opens the QR code scanner modal
|
||||||
*/
|
*/
|
||||||
@@ -24,6 +32,12 @@ export function ScanButton({
|
|||||||
hotkey?: boolean;
|
hotkey?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [opened, { open, close }] = useDisclosure(false);
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
const [everOpened, setEverOpened] = useState(false);
|
||||||
|
|
||||||
|
function handleOpen() {
|
||||||
|
setEverOpened(true);
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
|
||||||
if (hotkey) {
|
if (hotkey) {
|
||||||
useInvenTreeHotkeys([
|
useInvenTreeHotkeys([
|
||||||
@@ -31,7 +45,7 @@ export function ScanButton({
|
|||||||
'mod+Shift+B',
|
'mod+Shift+B',
|
||||||
t`Open barcode scanner`,
|
t`Open barcode scanner`,
|
||||||
() => {
|
() => {
|
||||||
open();
|
handleOpen();
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
]);
|
]);
|
||||||
@@ -42,12 +56,14 @@ export function ScanButton({
|
|||||||
<Tooltip position='bottom-end' label={t`Scan Barcode`}>
|
<Tooltip position='bottom-end' label={t`Scan Barcode`}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
aria-label={`barcode-scan-button-${modelType ?? 'any'}`}
|
aria-label={`barcode-scan-button-${modelType ?? 'any'}`}
|
||||||
onClick={open}
|
onClick={handleOpen}
|
||||||
variant='transparent'
|
variant='transparent'
|
||||||
>
|
>
|
||||||
<IconQrcode />
|
<IconQrcode />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
{everOpened && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
<BarcodeScanDialog
|
<BarcodeScanDialog
|
||||||
opened={opened}
|
opened={opened}
|
||||||
modelType={modelType}
|
modelType={modelType}
|
||||||
@@ -55,6 +71,8 @@ export function ScanButton({
|
|||||||
onClose={close}
|
onClose={close}
|
||||||
onScanSuccess={onScanSuccess}
|
onScanSuccess={onScanSuccess}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
|
import { lazy } from 'react';
|
||||||
|
|
||||||
|
import { Loadable } from '../../functions/loading';
|
||||||
import { useImporterState } from '../../states/ImporterState';
|
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() {
|
export default function GlobalImporterDrawer() {
|
||||||
const isOpen = useImporterState((state) => state.isOpen);
|
const isOpen = useImporterState((state) => state.isOpen);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMantineColorScheme, useMantineTheme } from '@mantine/core';
|
import { useMantineColorScheme, useMantineTheme } from '@mantine/core';
|
||||||
import { useMemo } from 'react';
|
import { Suspense, lazy, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { api, queryClient } from '../../App';
|
import { api, queryClient } from '../../App';
|
||||||
@@ -51,7 +51,18 @@ import { EditApiForm } from '../forms/ApiForm';
|
|||||||
import { Thumbnail } from '../images/Thumbnail';
|
import { Thumbnail } from '../images/Thumbnail';
|
||||||
import { RenderInstance, RenderRemoteInstance } from '../render/Instance';
|
import { RenderInstance, RenderRemoteInstance } from '../render/Instance';
|
||||||
import { RenderInlineModel } 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 = () => {
|
export const useInvenTreeContext = () => {
|
||||||
const [locale, host] = useLocalState(useShallow((s) => [s.language, s.host]));
|
const [locale, host] = useLocalState(useShallow((s) => [s.language, s.host]));
|
||||||
@@ -99,10 +110,12 @@ export const useInvenTreeContext = () => {
|
|||||||
},
|
},
|
||||||
tables: {
|
tables: {
|
||||||
renderTable: (props: InvenTreeTableRenderProps<any>) => (
|
renderTable: (props: InvenTreeTableRenderProps<any>) => (
|
||||||
|
<Suspense fallback={null}>
|
||||||
<InvenTreeTableInternal
|
<InvenTreeTableInternal
|
||||||
{...props}
|
{...props}
|
||||||
showContextMenu={showContextMenu}
|
showContextMenu={showContextMenu}
|
||||||
/>
|
/>
|
||||||
|
</Suspense>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
forms: {
|
forms: {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { type JSX, useEffect, useRef, useState } from 'react';
|
|||||||
import { useStoredTableState } from '@lib/states/StoredTableState';
|
import { useStoredTableState } from '@lib/states/StoredTableState';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import { api } from '../App';
|
import { api } from '../App';
|
||||||
|
import { markLocaleReady } from '../functions/localeReady';
|
||||||
import { useLocalState } from '../states/LocalState';
|
import { useLocalState } from '../states/LocalState';
|
||||||
import { useServerApiState } from '../states/ServerApiState';
|
import { useServerApiState } from '../states/ServerApiState';
|
||||||
import { fetchGlobalStates } from '../states/states';
|
import { fetchGlobalStates } from '../states/states';
|
||||||
@@ -140,8 +141,11 @@ export function LanguageContext({
|
|||||||
// Update default Accept-Language headers
|
// Update default Accept-Language headers
|
||||||
api.defaults.headers.common['Accept-Language'] = new_locales;
|
api.defaults.headers.common['Accept-Language'] = new_locales;
|
||||||
|
|
||||||
// Reload server state (and refresh status codes)
|
// Reload server state (and refresh status codes). Forced: the
|
||||||
fetchGlobalStates();
|
// 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
|
// Clear out cached table column names
|
||||||
useStoredTableState.getState().clearTableColumnNames();
|
useStoredTableState.getState().clearTableColumnNames();
|
||||||
@@ -195,6 +199,7 @@ export async function activateLocale(locale: string | null) {
|
|||||||
const { messages } = await import(`../locales/${localeDir}/messages.ts`);
|
const { messages } = await import(`../locales/${localeDir}/messages.ts`);
|
||||||
i18n.load(locale, messages);
|
i18n.load(locale, messages);
|
||||||
i18n.activate(locale);
|
i18n.activate(locale);
|
||||||
|
markLocaleReady();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Failed to load locale ${locale}:`, err);
|
console.error(`Failed to load locale ${locale}:`, err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { api, setApiDefaults } from '../App';
|
|||||||
import { useLocalState } from '../states/LocalState';
|
import { useLocalState } from '../states/LocalState';
|
||||||
import { useServerApiState } from '../states/ServerApiState';
|
import { useServerApiState } from '../states/ServerApiState';
|
||||||
import { useUserState } from '../states/UserState';
|
import { useUserState } from '../states/UserState';
|
||||||
import { fetchGlobalStates } from '../states/states';
|
import { fetchGlobalStates, resetGlobalStatesFetched } from '../states/states';
|
||||||
import { showLoginNotification } from './notifications';
|
import { showLoginNotification } from './notifications';
|
||||||
import { generateUrl } from './urls';
|
import { generateUrl } from './urls';
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ export async function doBasicLogin(
|
|||||||
// we are successfully logged in - gather required states for app
|
// we are successfully logged in - gather required states for app
|
||||||
if (loginDone) {
|
if (loginDone) {
|
||||||
await fetchUserState();
|
await fetchUserState();
|
||||||
await fetchGlobalStates();
|
await fetchGlobalStates(true);
|
||||||
observeProfile();
|
observeProfile();
|
||||||
} else if (!success) {
|
} else if (!success) {
|
||||||
clearUserState();
|
clearUserState();
|
||||||
@@ -240,6 +240,7 @@ export const doLogout = async (navigate: NavigateFunction) => {
|
|||||||
clearUserState();
|
clearUserState();
|
||||||
clearCsrfCookie();
|
clearCsrfCookie();
|
||||||
setAuthContext(undefined);
|
setAuthContext(undefined);
|
||||||
|
resetGlobalStatesFetched();
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -455,6 +456,10 @@ export const checkLoginState = async (
|
|||||||
MfaSetupOk(navigate).then(async (isOk) => {
|
MfaSetupOk(navigate).then(async (isOk) => {
|
||||||
if (isOk) {
|
if (isOk) {
|
||||||
observeProfile();
|
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();
|
await fetchGlobalStates();
|
||||||
|
|
||||||
followRedirect(navigate, redirect);
|
followRedirect(navigate, redirect);
|
||||||
@@ -505,7 +510,7 @@ function handleSuccessFullAuth(
|
|||||||
if (isOk) {
|
if (isOk) {
|
||||||
await fetchUserState();
|
await fetchUserState();
|
||||||
observeProfile();
|
observeProfile();
|
||||||
await fetchGlobalStates();
|
await fetchGlobalStates(true);
|
||||||
|
|
||||||
if (location !== undefined) {
|
if (location !== undefined) {
|
||||||
followRedirect(navigate, location?.state);
|
followRedirect(navigate, location?.state);
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { Center, Loader, MantineProvider, Stack } from '@mantine/core';
|
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 { colorSchema } from '../contexts/colorSchema';
|
||||||
import { theme } from '../theme';
|
import { theme } from '../theme';
|
||||||
@@ -38,3 +44,56 @@ export function LoadingItem({ item }: Readonly<{ item: any }>): JSX.Element {
|
|||||||
const Itm = Loadable(item);
|
const Itm = Loadable(item);
|
||||||
return <Itm />;
|
return <Itm />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<any> }>
|
||||||
|
): ComponentType<any> & { preload: () => Promise<ComponentType<any>> } {
|
||||||
|
let componentPromise: Promise<ComponentType<any>> | null = null;
|
||||||
|
|
||||||
|
function preload() {
|
||||||
|
if (!componentPromise) {
|
||||||
|
componentPromise = importFn().then((m) => m.default);
|
||||||
|
}
|
||||||
|
return componentPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EagerLoaded(props: JSX.IntrinsicAttributes) {
|
||||||
|
const [Component, setComponent] = useState<ComponentType<any> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
preload().then((C) => {
|
||||||
|
if (!cancelled) setComponent(() => C);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!Component) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Component {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
EagerLoaded.preload = preload;
|
||||||
|
return EagerLoaded;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<Listener>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { t } from '@lingui/core/macro';
|
import { t } from '@lingui/core/macro';
|
||||||
import { useDebouncedCallback } from '@mantine/hooks';
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
@@ -9,10 +8,9 @@ import { Wrapper } from './Layout';
|
|||||||
export default function Logged_In() {
|
export default function Logged_In() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const checkLoginStateDebounced = useDebouncedCallback(checkLoginState, 300);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkLoginStateDebounced(navigate, location?.state);
|
checkLoginState(navigate, location?.state);
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from '../../functions/auth';
|
} from '../../functions/auth';
|
||||||
import { useLocalState } from '../../states/LocalState';
|
import { useLocalState } from '../../states/LocalState';
|
||||||
import { useServerApiState } from '../../states/ServerApiState';
|
import { useServerApiState } from '../../states/ServerApiState';
|
||||||
|
import { useUserState } from '../../states/UserState';
|
||||||
import { Wrapper } from './Layout';
|
import { Wrapper } from './Layout';
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
@@ -45,6 +46,9 @@ export default function Login() {
|
|||||||
state.registration_enabled
|
state.registration_enabled
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
const [loginChecked] = useUserState(
|
||||||
|
useShallow((state) => [state.login_checked])
|
||||||
|
);
|
||||||
const any_reg_enabled = registration_enabled() || sso_registration() || false;
|
const any_reg_enabled = registration_enabled() || sso_registration() || false;
|
||||||
|
|
||||||
const LoginMessage = useMemo(() => {
|
const LoginMessage = useMemo(() => {
|
||||||
@@ -64,22 +68,26 @@ export default function Login() {
|
|||||||
}, [server.customize]);
|
}, [server.customize]);
|
||||||
|
|
||||||
// Data manipulation functions
|
// 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;
|
if (newHost === null) return;
|
||||||
setHost(hostList[newHost]?.host, newHost);
|
setHost(hostList[newHost]?.host, newHost);
|
||||||
setApiDefaults();
|
setApiDefaults();
|
||||||
const traceid = setTraceId();
|
const traceid = setTraceId();
|
||||||
fetchServerApiState();
|
fetchServerApiState(force);
|
||||||
removeTraceId(traceid);
|
removeTraceId(traceid);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set default host to localhost if no host is selected
|
// Set default host to localhost if no host is selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (hostKey === '') {
|
if (hostKey === '') {
|
||||||
ChangeHost(defaultHostKey);
|
ChangeHost(defaultHostKey, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only check here if a check hasn't already happened this session
|
||||||
|
if (!loginChecked) {
|
||||||
checkLoginState(navigate, location?.state, true);
|
checkLoginState(navigate, location?.state, true);
|
||||||
|
}
|
||||||
|
|
||||||
// check if we got login params (login and password)
|
// check if we got login params (login and password)
|
||||||
if (searchParams.has('login') && searchParams.has('password')) {
|
if (searchParams.has('login') && searchParams.has('password')) {
|
||||||
|
|||||||
+24
-14
@@ -1,18 +1,18 @@
|
|||||||
import { lazy } from 'react';
|
import { lazy } from 'react';
|
||||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
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
|
// Lazy loaded pages
|
||||||
export const LayoutComponent = Loadable(
|
// These two are mutually exclusive and one of them is always needed
|
||||||
lazy(() => import('./components/nav/Layout')),
|
// immediately on initial load, so they're loaded eagerly rather than via
|
||||||
true,
|
// Loadable/lazy - see EagerLoadable for why.
|
||||||
true
|
export const LayoutComponent = EagerLoadable(
|
||||||
|
() => import('./components/nav/Layout')
|
||||||
);
|
);
|
||||||
export const LoginLayoutComponent = Loadable(
|
export const LoginLayoutComponent = EagerLoadable(
|
||||||
lazy(() => import('./pages/Auth/Layout')),
|
() => import('./pages/Auth/Layout')
|
||||||
true,
|
|
||||||
true
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Home = Loadable(lazy(() => import('./pages/Index/Home')));
|
export const Home = Loadable(lazy(() => import('./pages/Index/Home')));
|
||||||
@@ -127,11 +127,21 @@ export const NotFound = Loadable(
|
|||||||
|
|
||||||
// Auth
|
// Auth
|
||||||
export const Login = Loadable(lazy(() => import('./pages/Auth/Login')));
|
export const Login = Loadable(lazy(() => import('./pages/Auth/Login')));
|
||||||
export const LoggedIn = Loadable(
|
// LoggedIn is the auth-check gateway hit by every fresh, unauthenticated
|
||||||
lazy(() => import('./pages/Auth/LoggedIn')),
|
// page load (redirected to from ProtectedRoute) - load it eagerly too.
|
||||||
true,
|
export const LoggedIn = EagerLoadable(() => import('./pages/Auth/LoggedIn'));
|
||||||
true
|
|
||||||
);
|
// 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 Logout = Loadable(lazy(() => import('./pages/Auth/Logout')));
|
||||||
export const Register = Loadable(lazy(() => import('./pages/Auth/Register')));
|
export const Register = Loadable(lazy(() => import('./pages/Auth/Register')));
|
||||||
export const Mfa = Loadable(lazy(() => import('./pages/Auth/MFA')));
|
export const Mfa = Loadable(lazy(() => import('./pages/Auth/MFA')));
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type { ServerAPIProps } from './states';
|
|||||||
interface ServerApiStateProps {
|
interface ServerApiStateProps {
|
||||||
server: ServerAPIProps;
|
server: ServerAPIProps;
|
||||||
setServer: (newServer: ServerAPIProps) => void;
|
setServer: (newServer: ServerAPIProps) => void;
|
||||||
fetchServerApiState: () => Promise<void>;
|
fetchServerApiState: (force?: boolean) => Promise<void>;
|
||||||
auth_config?: AuthConfig;
|
auth_config?: AuthConfig;
|
||||||
auth_context?: AuthContext;
|
auth_context?: AuthContext;
|
||||||
setAuthContext: (auth_context: AuthContext | undefined) => void;
|
setAuthContext: (auth_context: AuthContext | undefined) => void;
|
||||||
@@ -31,13 +31,24 @@ function get_server_setting(val: any) {
|
|||||||
return val;
|
return val;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let pendingServerApiFetch: Promise<void> | null = null;
|
||||||
|
let serverApiFetched = false;
|
||||||
|
|
||||||
export const useServerApiState = create<ServerApiStateProps>()(
|
export const useServerApiState = create<ServerApiStateProps>()(
|
||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
server: emptyServerAPI,
|
server: emptyServerAPI,
|
||||||
setServer: (newServer: ServerAPIProps) => set({ server: newServer }),
|
setServer: (newServer: ServerAPIProps) => set({ server: newServer }),
|
||||||
fetchServerApiState: async () => {
|
fetchServerApiState: async (force = false) => {
|
||||||
await Promise.all([
|
if (pendingServerApiFetch && !force) {
|
||||||
|
return pendingServerApiFetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serverApiFetched && !force) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingServerApiFetch = Promise.all([
|
||||||
// Fetch server data
|
// Fetch server data
|
||||||
api
|
api
|
||||||
.get(apiUrl(ApiEndpoints.api_server_info))
|
.get(apiUrl(ApiEndpoints.api_server_info))
|
||||||
@@ -59,7 +70,14 @@ export const useServerApiState = create<ServerApiStateProps>()(
|
|||||||
.catch(() => {
|
.catch(() => {
|
||||||
console.error('ERR: Error fetching SSO information');
|
console.error('ERR: Error fetching SSO information');
|
||||||
})
|
})
|
||||||
]);
|
]).then(() => {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pendingServerApiFetch;
|
||||||
|
serverApiFetched = true;
|
||||||
|
} finally {
|
||||||
|
pendingServerApiFetch = null;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
auth_config: undefined,
|
auth_config: undefined,
|
||||||
auth_context: undefined,
|
auth_context: undefined,
|
||||||
|
|||||||
@@ -72,13 +72,22 @@ export const useUserState = create<UserStateProps>((set, get) => ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch user data
|
// Fetch user data along with role/permission data in a single request -
|
||||||
await api
|
// 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), {
|
.get(apiUrl(ApiEndpoints.user_me), {
|
||||||
|
params: { roles: true },
|
||||||
timeout: 2000
|
timeout: 2000
|
||||||
})
|
})
|
||||||
.then((response) => {
|
.catch(() => undefined);
|
||||||
if (response.status == 200) {
|
|
||||||
|
if (response?.status !== 200) {
|
||||||
|
get().clearUserState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const user: UserProps = {
|
const user: UserProps = {
|
||||||
pk: response.data.pk,
|
pk: response.data.pk,
|
||||||
first_name: response.data?.first_name ?? '',
|
first_name: response.data?.first_name ?? '',
|
||||||
@@ -86,45 +95,13 @@ export const useUserState = create<UserStateProps>((set, get) => ({
|
|||||||
email: response.data.email,
|
email: response.data.email,
|
||||||
username: response.data.username,
|
username: response.data.username,
|
||||||
groups: response.data.groups,
|
groups: response.data.groups,
|
||||||
profile: response.data.profile
|
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);
|
get().setUser(user);
|
||||||
// profile info
|
|
||||||
} else {
|
|
||||||
get().clearUserState();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
get().clearUserState();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!get().isLoggedIn()) {
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
isAuthed: () => {
|
isAuthed: () => {
|
||||||
return get().is_authed;
|
return get().is_authed;
|
||||||
|
|||||||
@@ -41,17 +41,37 @@ export interface ServerAPIProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let pendingGlobalStatesFetch: Promise<void> | null = null;
|
||||||
|
let globalStatesFetched = false;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Refetch all global state information.
|
* Refetch all global state information.
|
||||||
* Necessary on login, or if locale is changed.
|
* 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();
|
const { isLoggedIn } = useUserState.getState();
|
||||||
|
|
||||||
if (!isLoggedIn()) {
|
if (!isLoggedIn()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (pendingGlobalStatesFetch) {
|
||||||
|
return pendingGlobalStatesFetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globalStatesFetched && !force) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingGlobalStatesFetch = (async () => {
|
||||||
setApiDefaults();
|
setApiDefaults();
|
||||||
const traceId = setTraceId();
|
const traceId = setTraceId();
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -62,4 +82,21 @@ export async function fetchGlobalStates() {
|
|||||||
useIconState.getState().fetchIcons()
|
useIconState.getState().fetchIcons()
|
||||||
]);
|
]);
|
||||||
removeTraceId(traceId);
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import '@mantine/core/styles.css';
|
import '@mantine/core/styles.css';
|
||||||
import { useViewportSize } from '@mantine/hooks';
|
import { useViewportSize } from '@mantine/hooks';
|
||||||
import { lazy, useEffect } from 'react';
|
import { type ComponentType, useEffect, useState } from 'react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
||||||
import { setApiDefaults } from '../App';
|
import { setApiDefaults } from '../App';
|
||||||
import { Loadable } from '../functions/loading';
|
|
||||||
import { useLocalState } from '../states/LocalState';
|
import { useLocalState } from '../states/LocalState';
|
||||||
|
|
||||||
function checkMobile() {
|
function checkMobile() {
|
||||||
@@ -13,22 +12,22 @@ function checkMobile() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MobileAppView = Loadable(
|
// Import both views eagerly (outside React.lazy/Suspense): a lazy component
|
||||||
lazy(() => import('./MobileAppView')),
|
// always suspends on its first render, and React's Suspense commit-delay
|
||||||
true,
|
// heuristic can then hold that first commit - and every effect beneath it,
|
||||||
true
|
// 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 DesktopAppView = Loadable(
|
const desktopViewPromise = import('./DesktopAppView').then((m) => m.default);
|
||||||
lazy(() => import('./DesktopAppView')),
|
const mobileViewPromise = import('./MobileAppView').then((m) => m.default);
|
||||||
true,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
// Main App
|
// Main App
|
||||||
export default function MainView() {
|
export default function MainView() {
|
||||||
const [allowMobile] = useLocalState(
|
const [allowMobile] = useLocalState(
|
||||||
useShallow((state) => [state.allowMobile])
|
useShallow((state) => [state.allowMobile])
|
||||||
);
|
);
|
||||||
|
const [DesktopView, setDesktopView] = useState<ComponentType | null>(null);
|
||||||
|
const [MobileView, setMobileView] = useState<ComponentType | null>(null);
|
||||||
|
|
||||||
// Set initial login status
|
// Set initial login status
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@@ -39,15 +38,22 @@ export default function MainView() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
desktopViewPromise.then((Component) => setDesktopView(() => Component));
|
||||||
|
mobileViewPromise.then((Component) => setMobileView(() => Component));
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Check if mobile
|
// Check if mobile
|
||||||
if (
|
const isMobile =
|
||||||
!allowMobile &&
|
!allowMobile &&
|
||||||
window.INVENTREE_SETTINGS.mobile_mode !== 'allow-always' &&
|
window.INVENTREE_SETTINGS.mobile_mode !== 'allow-always' &&
|
||||||
checkMobile()
|
checkMobile();
|
||||||
) {
|
|
||||||
return <MobileAppView />;
|
const View = isMobile ? MobileView : DesktopView;
|
||||||
|
|
||||||
|
if (!View) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main App component
|
return <View />;
|
||||||
return <DesktopAppView />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,3 +38,8 @@ export const noaccessuser: UserType = {
|
|||||||
username: 'noaccess',
|
username: 'noaccess',
|
||||||
testcred: 'youshallnotpass'
|
testcred: 'youshallnotpass'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const engineeruser: UserType = {
|
||||||
|
username: 'engineer',
|
||||||
|
testcred: 'partsonly'
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,9 +1,61 @@
|
|||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { TOTP } from 'otpauth';
|
||||||
import { expect, test } from './baseFixtures.js';
|
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 { navigate, openDetailAction } from './helpers.js';
|
||||||
import { doLogin } from './login.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<number> => {
|
||||||
|
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
|
* 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<string, number>();
|
||||||
|
|
||||||
|
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 }) => {
|
test('Login - Change Password', async ({ page }) => {
|
||||||
await doLogin(page, {
|
await doLogin(page, {
|
||||||
user: noaccessuser
|
user: noaccessuser
|
||||||
|
|||||||
Reference in New Issue
Block a user