[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:
parent
a38c3963f2
commit
83508f4b12
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<Tooltip position='bottom-end' label={t`Scan Barcode`}>
|
||||
<ActionIcon
|
||||
aria-label={`barcode-scan-button-${modelType ?? 'any'}`}
|
||||
onClick={open}
|
||||
onClick={handleOpen}
|
||||
variant='transparent'
|
||||
>
|
||||
<IconQrcode />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<BarcodeScanDialog
|
||||
opened={opened}
|
||||
modelType={modelType}
|
||||
callback={callback}
|
||||
onClose={close}
|
||||
onScanSuccess={onScanSuccess}
|
||||
/>
|
||||
{everOpened && (
|
||||
<Suspense fallback={null}>
|
||||
<BarcodeScanDialog
|
||||
opened={opened}
|
||||
modelType={modelType}
|
||||
callback={callback}
|
||||
onClose={close}
|
||||
onScanSuccess={onScanSuccess}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<any>) => (
|
||||
<InvenTreeTableInternal
|
||||
{...props}
|
||||
showContextMenu={showContextMenu}
|
||||
/>
|
||||
<Suspense fallback={null}>
|
||||
<InvenTreeTableInternal
|
||||
{...props}
|
||||
showContextMenu={showContextMenu}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
},
|
||||
forms: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 <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 { 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 (
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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')));
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import type { ServerAPIProps } from './states';
|
|||
interface ServerApiStateProps {
|
||||
server: ServerAPIProps;
|
||||
setServer: (newServer: ServerAPIProps) => void;
|
||||
fetchServerApiState: () => Promise<void>;
|
||||
fetchServerApiState: (force?: boolean) => Promise<void>;
|
||||
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<void> | null = null;
|
||||
let serverApiFetched = false;
|
||||
|
||||
export const useServerApiState = create<ServerApiStateProps>()(
|
||||
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<ServerApiStateProps>()(
|
|||
.catch(() => {
|
||||
console.error('ERR: Error fetching SSO information');
|
||||
})
|
||||
]);
|
||||
]).then(() => {});
|
||||
|
||||
try {
|
||||
await pendingServerApiFetch;
|
||||
serverApiFetched = true;
|
||||
} finally {
|
||||
pendingServerApiFetch = null;
|
||||
}
|
||||
},
|
||||
auth_config: undefined,
|
||||
auth_context: undefined,
|
||||
|
|
|
|||
|
|
@ -72,59 +72,36 @@ export const useUserState = create<UserStateProps>((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;
|
||||
|
|
|
|||
|
|
@ -41,25 +41,62 @@ export interface ServerAPIProps {
|
|||
};
|
||||
}
|
||||
|
||||
let pendingGlobalStatesFetch: Promise<void> | 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ComponentType | null>(null);
|
||||
const [MobileView, setMobileView] = useState<ComponentType | null>(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 <MobileAppView />;
|
||||
checkMobile();
|
||||
|
||||
const View = isMobile ? MobileView : DesktopView;
|
||||
|
||||
if (!View) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Main App component
|
||||
return <DesktopAppView />;
|
||||
return <View />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,3 +38,8 @@ export const noaccessuser: UserType = {
|
|||
username: 'noaccess',
|
||||
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 { 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<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
|
||||
|
|
@ -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 }) => {
|
||||
await doLogin(page, {
|
||||
user: noaccessuser
|
||||
|
|
|
|||
Loading…
Reference in New Issue