Merge branch 'master' of https://github.com/inventree/InvenTree into pui-princing-tests

This commit is contained in:
Matthias Mair 2024-04-17 19:56:38 +02:00
commit cacf1e3045
No known key found for this signature in database
GPG Key ID: A593429DDA23B66A
37 changed files with 363 additions and 400 deletions

View File

@ -544,15 +544,6 @@ jobs:
- name: Report coverage - name: Report coverage
if: always() if: always()
run: cd src/frontend && npx nyc report --report-dir ./coverage --temp-dir .nyc_output --reporter=lcov --exclude-after-remap false run: cd src/frontend && npx nyc report --report-dir ./coverage --temp-dir .nyc_output --reporter=lcov --exclude-after-remap false
- name: Upload Coverage Report to Coveralls
if: always()
uses: coverallsapp/github-action@3dfc5567390f6fa9267c0ee9c251e4c8c3f18949 # pin@v2.2.3
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
flag-name: pui
git-commit: ${{ github.sha }}
git-branch: ${{ github.ref }}
parallel: true
- name: Upload coverage reports to Codecov - name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v4.3.0 uses: codecov/codecov-action@v4.3.0
if: always() if: always()

View File

@ -4,7 +4,7 @@ Checklist of steps to perform at each code release
### Update Version String ### Update Version String
Update `INVENTREE_SW_VERSION` in [version.py](https://github.com/inventree/InvenTree/blob/master/InvenTree/InvenTree/version.py) Update `INVENTREE_SW_VERSION` in [version.py](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/InvenTree/version.py)
### Increment API Version ### Increment API Version

View File

@ -12,7 +12,7 @@ mysqlclient>=2.2.0
mariadb>=1.1.8 mariadb>=1.1.8
# gunicorn web server # gunicorn web server
gunicorn>=21.2.0 gunicorn>=22.0.0
# LDAP required packages # LDAP required packages
django-auth-ldap # Django integration for ldap auth django-auth-ldap # Django integration for ldap auth

View File

@ -96,7 +96,7 @@ The HEAD of the "stable" branch represents the latest stable release code.
## API versioning ## API versioning
The [API version](https://github.com/inventree/InvenTree/blob/master/InvenTree/InvenTree/api_version.py) needs to be bumped every time when the API is changed. The [API version](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/InvenTree/api_version.py) needs to be bumped every time when the API is changed.
## Environment ## Environment

View File

@ -65,7 +65,7 @@ Additionally, add the following imports after the extended line.
#### Blocks #### Blocks
The page_base file is split into multiple sections called blocks. This allows you to implement sections of the webpage while getting many items like navbars, sidebars, and general layout provided for you. The page_base file is split into multiple sections called blocks. This allows you to implement sections of the webpage while getting many items like navbars, sidebars, and general layout provided for you.
The current default page base can be found [here](https://github.com/inventree/InvenTree/blob/master/InvenTree/templates/page_base.html). Look through this file to determine overridable blocks. The [stock app](https://github.com/inventree/InvenTree/tree/master/InvenTree/stock) offers a great example of implementing these blocks. The current default page base can be found [here](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/templates/page_base.html). Look through this file to determine overridable blocks. The [stock app](https://github.com/inventree/InvenTree/tree/master/src/backend/InvenTree/stock) offers a great example of implementing these blocks.
!!! warning "Sidebar Block" !!! warning "Sidebar Block"
You may notice that implementing the `sidebar` block doesn't initially work. Be sure to enable the sidebar using JavaScript. This can be achieved by appending the following code, replacing `label` with a label of your choosing, to the end of your template file. You may notice that implementing the `sidebar` block doesn't initially work. Be sure to enable the sidebar using JavaScript. This can be achieved by appending the following code, replacing `label` with a label of your choosing, to the end of your template file.

View File

@ -13,4 +13,4 @@ You can use all content variables from the [StockLocation](./context_variables.m
A default report template is provided out of the box, which can be used as a starting point for developing custom return order report templates. A default report template is provided out of the box, which can be used as a starting point for developing custom return order report templates.
View the [source code](https://github.com/inventree/InvenTree/blob/master/InvenTree/report/templates/report/inventree_slr_report.html) for the default stock location report template. View the [source code](https://github.com/inventree/InvenTree/blob/master/src/backend/InvenTree/report/templates/report/inventree_slr_report.html) for the default stock location report template.

View File

@ -1,12 +1,16 @@
"""InvenTree API version information.""" """InvenTree API version information."""
# InvenTree API version # InvenTree API version
INVENTREE_API_VERSION = 187 INVENTREE_API_VERSION = 188
"""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 = """
v187 - 2024-03-10 : https://github.com/inventree/InvenTree/pull/6985 v188 - 2024-04-16 : https://github.com/inventree/InvenTree/pull/6970
- Adds session authentication support for the API
- Improvements for login / logout endpoints for better support of React web interface
v187 - 2024-04-10 : https://github.com/inventree/InvenTree/pull/6985
- Allow Part list endpoint to be sorted by pricing_min and pricing_max values - Allow Part list endpoint to be sorted by pricing_min and pricing_max values
- Allow BomItem list endpoint to be sorted by pricing_min and pricing_max values - Allow BomItem list endpoint to be sorted by pricing_min and pricing_max values
- Allow InternalPrice and SalePrice endpoints to be sorted by quantity - Allow InternalPrice and SalePrice endpoints to be sorted by quantity

View File

@ -492,10 +492,18 @@ if DEBUG:
'rest_framework.renderers.BrowsableAPIRenderer' 'rest_framework.renderers.BrowsableAPIRenderer'
) )
# dj-rest-auth
# JWT switch # JWT switch
USE_JWT = get_boolean_setting('INVENTREE_USE_JWT', 'use_jwt', False) USE_JWT = get_boolean_setting('INVENTREE_USE_JWT', 'use_jwt', False)
REST_USE_JWT = USE_JWT REST_USE_JWT = USE_JWT
# dj-rest-auth
REST_AUTH = {
'SESSION_LOGIN': True,
'TOKEN_MODEL': 'users.models.ApiToken',
'TOKEN_CREATOR': 'users.models.default_create_token',
'USE_JWT': USE_JWT,
}
OLD_PASSWORD_FIELD_ENABLED = True OLD_PASSWORD_FIELD_ENABLED = True
REST_AUTH_REGISTER_SERIALIZERS = { REST_AUTH_REGISTER_SERIALIZERS = {
'REGISTER_SERIALIZER': 'InvenTree.forms.CustomRegisterSerializer' 'REGISTER_SERIALIZER': 'InvenTree.forms.CustomRegisterSerializer'
@ -510,6 +518,7 @@ if USE_JWT:
) )
INSTALLED_APPS.append('rest_framework_simplejwt') INSTALLED_APPS.append('rest_framework_simplejwt')
# WSGI default setting # WSGI default setting
WSGI_APPLICATION = 'InvenTree.wsgi.application' WSGI_APPLICATION = 'InvenTree.wsgi.application'
@ -1092,6 +1101,13 @@ if not TESTING and len(CSRF_TRUSTED_ORIGINS) == 0:
) )
sys.exit(-1) sys.exit(-1)
# Additional CSRF settings
CSRF_HEADER_NAME = 'HTTP_X_CSRFTOKEN'
CSRF_COOKIE_NAME = 'csrftoken'
CSRF_COOKIE_SAMESITE = 'Lax'
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = 'Lax'
USE_X_FORWARDED_HOST = get_boolean_setting( USE_X_FORWARDED_HOST = get_boolean_setting(
'INVENTREE_USE_X_FORWARDED_HOST', 'INVENTREE_USE_X_FORWARDED_HOST',
config_key='use_x_forwarded_host', config_key='use_x_forwarded_host',

View File

@ -160,6 +160,7 @@ apipatterns = [
SocialAccountDisconnectView.as_view(), SocialAccountDisconnectView.as_view(),
name='social_account_disconnect', name='social_account_disconnect',
), ),
path('login/', users.api.Login.as_view(), name='api-login'),
path('logout/', users.api.Logout.as_view(), name='api-logout'), path('logout/', users.api.Logout.as_view(), name='api-logout'),
path( path(
'login-redirect/', 'login-redirect/',

View File

@ -8,9 +8,11 @@ from django.contrib.auth.models import Group, User
from django.urls import include, path, re_path from django.urls import include, path, re_path
from django.views.generic.base import RedirectView from django.views.generic.base import RedirectView
from dj_rest_auth.views import LogoutView from dj_rest_auth.views import LoginView, LogoutView
from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view from drf_spectacular.utils import OpenApiResponse, extend_schema, extend_schema_view
from rest_framework import exceptions, permissions from rest_framework import exceptions, permissions
from rest_framework.authentication import BasicAuthentication
from rest_framework.decorators import authentication_classes
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
@ -205,6 +207,18 @@ class GroupList(ListCreateAPI):
ordering_fields = ['name'] ordering_fields = ['name']
@authentication_classes([BasicAuthentication])
@extend_schema_view(
post=extend_schema(
responses={200: OpenApiResponse(description='User successfully logged in')}
)
)
class Login(LoginView):
"""API view for logging in via API."""
...
@extend_schema_view( @extend_schema_view(
post=extend_schema( post=extend_schema(
responses={200: OpenApiResponse(description='User successfully logged out')} responses={200: OpenApiResponse(description='User successfully logged out')}

View File

@ -56,6 +56,17 @@ def default_token_expiry():
return InvenTree.helpers.current_date() + datetime.timedelta(days=365) return InvenTree.helpers.current_date() + datetime.timedelta(days=365)
def default_create_token(token_model, user, serializer):
"""Generate a default value for the token."""
token = token_model.objects.filter(user=user, name='', revoked=False)
if token.exists():
return token.first()
else:
return token_model.objects.create(user=user, name='')
class ApiToken(AuthToken, InvenTree.models.MetadataMixin): class ApiToken(AuthToken, InvenTree.models.MetadataMixin):
"""Extends the default token model provided by djangorestframework.authtoken. """Extends the default token model provided by djangorestframework.authtoken.

View File

@ -134,7 +134,7 @@ googleapis-common-protos==1.63.0
# opentelemetry-exporter-otlp-proto-http # opentelemetry-exporter-otlp-proto-http
grpcio==1.62.1 grpcio==1.62.1
# via opentelemetry-exporter-otlp-proto-grpc # via opentelemetry-exporter-otlp-proto-grpc
gunicorn==21.2.0 gunicorn==22.0.0
html5lib==1.1 html5lib==1.1
# via weasyprint # via weasyprint
icalendar==5.0.12 icalendar==5.0.12

View File

@ -1,40 +1,24 @@
import { QueryClient } from '@tanstack/react-query'; import { QueryClient } from '@tanstack/react-query';
import axios from 'axios'; import axios from 'axios';
import { getCsrfCookie } from './functions/auth';
import { useLocalState } from './states/LocalState'; import { useLocalState } from './states/LocalState';
import { useSessionState } from './states/SessionState';
// Global API instance // Global API instance
export const api = axios.create({}); export const api = axios.create({});
/* /*
* Setup default settings for the Axios API instance. * Setup default settings for the Axios API instance.
*
* This includes:
* - Base URL
* - Authorization token (if available)
* - CSRF token (if available)
*/ */
export function setApiDefaults() { export function setApiDefaults() {
const host = useLocalState.getState().host; const host = useLocalState.getState().host;
const token = useSessionState.getState().token;
api.defaults.baseURL = host; api.defaults.baseURL = host;
api.defaults.timeout = 2500; api.defaults.timeout = 2500;
api.defaults.headers.common['Authorization'] = token
? `Token ${token}`
: undefined;
if (!!getCsrfCookie()) { api.defaults.withCredentials = true;
api.defaults.withCredentials = true; api.defaults.withXSRFToken = true;
api.defaults.xsrfCookieName = 'csrftoken'; api.defaults.xsrfCookieName = 'csrftoken';
api.defaults.xsrfHeaderName = 'X-CSRFToken'; api.defaults.xsrfHeaderName = 'X-CSRFToken';
} else {
api.defaults.withCredentials = false;
api.defaults.xsrfCookieName = undefined;
api.defaults.xsrfHeaderName = undefined;
}
} }
export const queryClient = new QueryClient(); export const queryClient = new QueryClient();

View File

@ -12,16 +12,14 @@ import {
} from '@mantine/core'; } from '@mantine/core';
import { useForm } from '@mantine/form'; import { useForm } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { notifications } from '@mantine/notifications';
import { IconCheck } from '@tabler/icons-react';
import { useState } from 'react'; import { useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../../App'; import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { doBasicLogin, doSimpleLogin } from '../../functions/auth'; import { doBasicLogin, doSimpleLogin, isLoggedIn } from '../../functions/auth';
import { showLoginNotification } from '../../functions/notifications';
import { apiUrl, useServerApiState } from '../../states/ApiState'; import { apiUrl, useServerApiState } from '../../states/ApiState';
import { useSessionState } from '../../states/SessionState';
import { SsoButton } from '../buttons/SSOButton'; import { SsoButton } from '../buttons/SSOButton';
export function AuthenticationForm() { export function AuthenticationForm() {
@ -46,19 +44,18 @@ export function AuthenticationForm() {
).then(() => { ).then(() => {
setIsLoggingIn(false); setIsLoggingIn(false);
if (useSessionState.getState().hasToken()) { if (isLoggedIn()) {
notifications.show({ showLoginNotification({
title: t`Login successful`, title: t`Login successful`,
message: t`Welcome back!`, message: t`Logged in successfully`
color: 'green',
icon: <IconCheck size="1rem" />
}); });
navigate(location?.state?.redirectFrom ?? '/home'); navigate(location?.state?.redirectFrom ?? '/home');
} else { } else {
notifications.show({ showLoginNotification({
title: t`Login failed`, title: t`Login failed`,
message: t`Check your input and try again.`, message: t`Check your input and try again.`,
color: 'red' success: false
}); });
} }
}); });
@ -67,18 +64,15 @@ export function AuthenticationForm() {
setIsLoggingIn(false); setIsLoggingIn(false);
if (ret?.status === 'ok') { if (ret?.status === 'ok') {
notifications.show({ showLoginNotification({
title: t`Mail delivery successful`, title: t`Mail delivery successful`,
message: t`Check your inbox for the login link. If you have an account, you will receive a login link. Check in spam too.`, message: t`Check your inbox for the login link. If you have an account, you will receive a login link. Check in spam too.`
color: 'green',
icon: <IconCheck size="1rem" />,
autoClose: false
}); });
} else { } else {
notifications.show({ showLoginNotification({
title: t`Input error`, title: t`Mail delivery failed`,
message: t`Check your input and try again.`, message: t`Check your input and try again.`,
color: 'red' success: false
}); });
} }
}); });
@ -193,11 +187,9 @@ export function RegistrationForm() {
.then((ret) => { .then((ret) => {
if (ret?.status === 204) { if (ret?.status === 204) {
setIsRegistering(false); setIsRegistering(false);
notifications.show({ showLoginNotification({
title: t`Registration successful`, title: t`Registration successful`,
message: t`Please confirm your email address to complete the registration`, message: t`Please confirm your email address to complete the registration`
color: 'green',
icon: <IconCheck size="1rem" />
}); });
navigate('/home'); navigate('/home');
} }
@ -212,11 +204,10 @@ export function RegistrationForm() {
if (err.response?.data?.non_field_errors) { if (err.response?.data?.non_field_errors) {
err_msg = err.response.data.non_field_errors; err_msg = err.response.data.non_field_errors;
} }
notifications.show({ showLoginNotification({
title: t`Input error`, title: t`Input error`,
message: t`Check your input and try again. ` + err_msg, message: t`Check your input and try again. ` + err_msg,
color: 'red', success: false
autoClose: 30000
}); });
} }
}); });

View File

@ -1,71 +1,27 @@
/** /**
* Component for loading an image from the InvenTree server, * Component for loading an image from the InvenTree server
* using the API's token authentication.
* *
* Image caching is handled automagically by the browsers cache * Image caching is handled automagically by the browsers cache
*/ */
import { Image, ImageProps, Skeleton, Stack } from '@mantine/core'; import { Image, ImageProps, Skeleton, Stack } from '@mantine/core';
import { useId } from '@mantine/hooks'; import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { api } from '../../App'; import { useLocalState } from '../../states/LocalState';
/** /**
* Construct an image container which will load and display the image * Construct an image container which will load and display the image
*/ */
export function ApiImage(props: ImageProps) { export function ApiImage(props: ImageProps) {
const [image, setImage] = useState<string>(''); const { host } = useLocalState.getState();
const [authorized, setAuthorized] = useState<boolean>(true); const imageUrl = useMemo(() => {
return `${host}${props.src}`;
const queryKey = useId(); }, [host, props.src]);
const _imgQuery = useQuery({
queryKey: ['image', queryKey, props.src],
enabled:
authorized &&
props.src != undefined &&
props.src != null &&
props.src != '',
queryFn: async () => {
if (!props.src) {
return null;
}
return api
.get(props.src, {
responseType: 'blob'
})
.then((response) => {
switch (response.status) {
case 200:
let img = new Blob([response.data], {
type: response.headers['content-type']
});
let url = URL.createObjectURL(img);
setImage(url);
break;
default:
// User is not authorized to view this image, or the image is not available
setImage('');
setAuthorized(false);
break;
}
return response;
})
.catch((_error) => {
return null;
});
},
refetchOnMount: true,
refetchOnWindowFocus: false
});
return ( return (
<Stack> <Stack>
{image && image.length > 0 ? ( {imageUrl ? (
<Image {...props} src={image} withPlaceholder fit="contain" /> <Image {...props} src={imageUrl} withPlaceholder fit="contain" />
) : ( ) : (
<Skeleton <Skeleton
height={props?.height ?? props.width} height={props?.height ?? props.width}

View File

@ -8,7 +8,9 @@ import {
IconFileTypeXls, IconFileTypeXls,
IconFileTypeZip IconFileTypeZip
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { ReactNode } from 'react'; import { ReactNode, useMemo } from 'react';
import { useLocalState } from '../../states/LocalState';
/** /**
* Return an icon based on the provided filename * Return an icon based on the provided filename
@ -58,10 +60,20 @@ export function AttachmentLink({
}): ReactNode { }): ReactNode {
let text = external ? attachment : attachment.split('/').pop(); let text = external ? attachment : attachment.split('/').pop();
const host = useLocalState((s) => s.host);
const url = useMemo(() => {
if (external) {
return attachment;
}
return `${host}${attachment}`;
}, [host, attachment, external]);
return ( return (
<Group position="left" spacing="sm"> <Group position="left" spacing="sm">
{external ? <IconLink /> : attachmentIcon(attachment)} {external ? <IconLink /> : attachmentIcon(attachment)}
<Anchor href={attachment} target="_blank" rel="noopener noreferrer"> <Anchor href={url} target="_blank" rel="noopener noreferrer">
{text} {text}
</Anchor> </Anchor>
</Group> </Group>

View File

@ -6,17 +6,15 @@ import { useEffect, useState } from 'react';
import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { getActions } from '../../defaults/actions'; import { getActions } from '../../defaults/actions';
import { isLoggedIn } from '../../functions/auth';
import { InvenTreeStyle } from '../../globalStyle'; import { InvenTreeStyle } from '../../globalStyle';
import { useSessionState } from '../../states/SessionState';
import { Footer } from './Footer'; import { Footer } from './Footer';
import { Header } from './Header'; import { Header } from './Header';
export const ProtectedRoute = ({ children }: { children: JSX.Element }) => { export const ProtectedRoute = ({ children }: { children: JSX.Element }) => {
const [token] = useSessionState((state) => [state.token]);
const location = useLocation(); const location = useLocation();
if (!token) { if (!isLoggedIn()) {
return ( return (
<Navigate to="/logged-in" state={{ redirectFrom: location.pathname }} /> <Navigate to="/logged-in" state={{ redirectFrom: location.pathname }} />
); );

View File

@ -15,14 +15,15 @@ export enum ApiEndpoints {
user_roles = 'user/roles/', user_roles = 'user/roles/',
user_token = 'user/token/', user_token = 'user/token/',
user_simple_login = 'email/generate/', user_simple_login = 'email/generate/',
user_reset = 'auth/password/reset/', // Note leading prefix here user_reset = 'auth/password/reset/',
user_reset_set = 'auth/password/reset/confirm/', // Note leading prefix here user_reset_set = 'auth/password/reset/confirm/',
user_sso = 'auth/social/', user_sso = 'auth/social/',
user_sso_remove = 'auth/social/:id/disconnect/', user_sso_remove = 'auth/social/:id/disconnect/',
user_emails = 'auth/emails/', user_emails = 'auth/emails/',
user_email_remove = 'auth/emails/:id/remove/', user_email_remove = 'auth/emails/:id/remove/',
user_email_verify = 'auth/emails/:id/verify/', user_email_verify = 'auth/emails/:id/verify/',
user_email_primary = 'auth/emails/:id/primary/', user_email_primary = 'auth/emails/:id/primary/',
user_login = 'auth/login/',
user_logout = 'auth/logout/', user_logout = 'auth/logout/',
user_register = 'auth/registration/', user_register = 'auth/registration/',

View File

@ -1,15 +1,13 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { IconCheck } from '@tabler/icons-react';
import axios from 'axios'; import axios from 'axios';
import { api, setApiDefaults } from '../App'; import { api, setApiDefaults } from '../App';
import { ApiEndpoints } from '../enums/ApiEndpoints'; import { ApiEndpoints } from '../enums/ApiEndpoints';
import { apiUrl } from '../states/ApiState'; import { apiUrl } from '../states/ApiState';
import { useLocalState } from '../states/LocalState'; import { useLocalState } from '../states/LocalState';
import { useSessionState } from '../states/SessionState'; import { fetchGlobalStates } from '../states/states';
import { showLoginNotification } from './notifications';
const tokenName: string = 'inventree-web-app';
/** /**
* Attempt to login using username:password combination. * Attempt to login using username:password combination.
@ -24,26 +22,35 @@ export const doBasicLogin = async (username: string, password: string) => {
return; return;
} }
// At this stage, we can assume that we are not logged in, and we have no token clearCsrfCookie();
useSessionState.getState().clearToken();
// Request new token from the server const login_url = apiUrl(ApiEndpoints.user_login);
await axios
.get(apiUrl(ApiEndpoints.user_token), { // Attempt login with
auth: { username, password }, await api
baseURL: host, .post(
timeout: 2000, login_url,
params: { {
name: tokenName username: username,
password: password
},
{
baseURL: host
} }
}) )
.then((response) => { .then((response) => {
if (response.status == 200 && response.data.token) { switch (response.status) {
// A valid token has been returned - save, and login case 200:
useSessionState.getState().setToken(response.data.token); fetchGlobalStates();
break;
default:
clearCsrfCookie();
break;
} }
}) })
.catch(() => {}); .catch(() => {
clearCsrfCookie();
});
}; };
/** /**
@ -53,27 +60,15 @@ export const doBasicLogin = async (username: string, password: string) => {
*/ */
export const doLogout = async (navigate: any) => { export const doLogout = async (navigate: any) => {
// Logout from the server session // Logout from the server session
await api.post(apiUrl(ApiEndpoints.user_logout)).catch(() => { await api.post(apiUrl(ApiEndpoints.user_logout)).finally(() => {
// If an error occurs here, we are likely already logged out clearCsrfCookie();
navigate('/login'); navigate('/login');
return;
showLoginNotification({
title: t`Logged Out`,
message: t`Successfully logged out`
});
}); });
// Logout from this session
// Note that clearToken() then calls setApiDefaults()
clearCsrfCookie();
useSessionState.getState().clearToken();
notifications.hide('login');
notifications.show({
id: 'login',
title: t`Logout successful`,
message: t`You have been logged out`,
color: 'green',
icon: <IconCheck size="1rem" />
});
navigate('/login');
}; };
export const doSimpleLogin = async (email: string) => { export const doSimpleLogin = async (email: string) => {
@ -134,55 +129,33 @@ export function checkLoginState(
) { ) {
setApiDefaults(); setApiDefaults();
if (redirect == '/') {
redirect = '/home';
}
// Callback function when login is successful // Callback function when login is successful
const loginSuccess = () => { const loginSuccess = () => {
notifications.hide('login'); showLoginNotification({
notifications.show({
id: 'login',
title: t`Logged In`, title: t`Logged In`,
message: t`Found an existing login - welcome back!`, message: t`Successfully logged in`
color: 'green',
icon: <IconCheck size="1rem" />
}); });
navigate(redirect ?? '/home'); navigate(redirect ?? '/home');
}; };
// Callback function when login fails // Callback function when login fails
const loginFailure = () => { const loginFailure = () => {
useSessionState.getState().clearToken();
if (!no_redirect) { if (!no_redirect) {
navigate('/login', { state: { redirectFrom: redirect } }); navigate('/login', { state: { redirectFrom: redirect } });
} }
}; };
if (useSessionState.getState().hasToken()) { // Check the 'user_me' endpoint to see if the user is logged in
// An existing token is available - check if it works if (isLoggedIn()) {
api api
.get(apiUrl(ApiEndpoints.user_me), { .get(apiUrl(ApiEndpoints.user_me))
timeout: 2000
})
.then((val) => {
if (val.status === 200) {
// Success: we are logged in (and we already have a token)
loginSuccess();
} else {
loginFailure();
}
})
.catch(() => {
loginFailure();
});
} else if (getCsrfCookie()) {
// Try to fetch a new token using the CSRF cookie
api
.get(apiUrl(ApiEndpoints.user_token), {
params: {
name: tokenName
}
})
.then((response) => { .then((response) => {
if (response.status == 200 && response.data.token) { if (response.status == 200) {
useSessionState.getState().setToken(response.data.token);
loginSuccess(); loginSuccess();
} else { } else {
loginFailure(); loginFailure();
@ -192,7 +165,6 @@ export function checkLoginState(
loginFailure(); loginFailure();
}); });
} else { } else {
// No token, no cookie - redirect to login page
loginFailure(); loginFailure();
} }
} }
@ -209,8 +181,12 @@ export function getCsrfCookie() {
return cookieValue; return cookieValue;
} }
export function isLoggedIn() {
return !!getCsrfCookie();
}
/* /*
* Clear out the CSRF cookie (force session logout) * Clear out the CSRF and session cookies (force session logout)
*/ */
export function clearCsrfCookie() { export function clearCsrfCookie() {
document.cookie = document.cookie =

View File

@ -7,6 +7,7 @@ import {
IconBuilding, IconBuilding,
IconBuildingFactory2, IconBuildingFactory2,
IconBuildingStore, IconBuildingStore,
IconBusinessplan,
IconCalendar, IconCalendar,
IconCalendarStats, IconCalendarStats,
IconCategory, IconCategory,
@ -100,6 +101,7 @@ const icons = {
info: IconInfoCircle, info: IconInfoCircle,
details: IconInfoCircle, details: IconInfoCircle,
parameters: IconList, parameters: IconList,
list: IconList,
stock: IconPackages, stock: IconPackages,
variants: IconVersions, variants: IconVersions,
allocations: IconBookmarks, allocations: IconBookmarks,
@ -171,6 +173,7 @@ const icons = {
customer: IconUser, customer: IconUser,
quantity: IconNumbers, quantity: IconNumbers,
progress: IconProgressCheck, progress: IconProgressCheck,
total_cost: IconBusinessplan,
reference: IconHash, reference: IconHash,
serial: IconHash, serial: IconHash,
website: IconWorld, website: IconWorld,

View File

@ -1,5 +1,6 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { notifications } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { IconCircleCheck, IconExclamationCircle } from '@tabler/icons-react';
/** /**
* Show a notification that the feature is not yet implemented * Show a notification that the feature is not yet implemented
@ -34,3 +35,28 @@ export function invalidResponse(returnCode: number) {
color: 'red' color: 'red'
}); });
} }
/*
* Display a login / logout notification message.
* Any existing login notification(s) will be hidden.
*/
export function showLoginNotification({
title,
message,
success = true
}: {
title: string;
message: string;
success?: boolean;
}) {
notifications.hide('login');
notifications.show({
title: title,
message: message,
color: success ? 'green' : 'red',
icon: success ? <IconCircleCheck /> : <IconExclamationCircle />,
id: 'login',
autoClose: 5000
});
}

View File

@ -2,7 +2,7 @@ import { Trans, t } from '@lingui/macro';
import { Center, Container, Paper, Text } from '@mantine/core'; import { Center, Container, Paper, Text } from '@mantine/core';
import { useDisclosure, useToggle } from '@mantine/hooks'; import { useDisclosure, useToggle } from '@mantine/hooks';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { setApiDefaults } from '../../App'; import { setApiDefaults } from '../../App';
import { AuthFormOptions } from '../../components/forms/AuthFormOptions'; import { AuthFormOptions } from '../../components/forms/AuthFormOptions';
@ -13,7 +13,7 @@ import {
} from '../../components/forms/AuthenticationForm'; } from '../../components/forms/AuthenticationForm';
import { InstanceOptions } from '../../components/forms/InstanceOptions'; import { InstanceOptions } from '../../components/forms/InstanceOptions';
import { defaultHostKey } from '../../defaults/defaultHostList'; import { defaultHostKey } from '../../defaults/defaultHostList';
import { checkLoginState } from '../../functions/auth'; import { checkLoginState, doBasicLogin } from '../../functions/auth';
import { useServerApiState } from '../../states/ApiState'; import { useServerApiState } from '../../states/ApiState';
import { useLocalState } from '../../states/LocalState'; import { useLocalState } from '../../states/LocalState';
@ -33,6 +33,7 @@ export default function Login() {
const [loginMode, setMode] = useDisclosure(true); const [loginMode, setMode] = useDisclosure(true);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [searchParams] = useSearchParams();
// Data manipulation functions // Data manipulation functions
function ChangeHost(newHost: string): void { function ChangeHost(newHost: string): void {
@ -48,6 +49,16 @@ export default function Login() {
} }
checkLoginState(navigate, location?.state?.redirectFrom, true); checkLoginState(navigate, location?.state?.redirectFrom, true);
// check if we got login params (login and password)
if (searchParams.has('login') && searchParams.has('password')) {
doBasicLogin(
searchParams.get('login') ?? '',
searchParams.get('password') ?? ''
).then(() => {
navigate(location?.state?.redirectFrom ?? '/home');
});
}
}, []); }, []);
// Fetch server data on mount if no server data is present // Fetch server data on mount if no server data is present

View File

@ -0,0 +1,34 @@
import { Trans } from '@lingui/macro';
import { Card, Container, Group, Loader, Stack, Text } from '@mantine/core';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { doLogout } from '../../functions/auth';
/* Expose a route for explicit logout via URL */
export default function Logout() {
const navigate = useNavigate();
useEffect(() => {
doLogout(navigate);
}, []);
return (
<>
<Container>
<Stack align="center">
<Card shadow="sm" padding="lg" radius="md">
<Stack>
<Text size="lg">
<Trans>Logging out</Trans>
</Text>
<Group position="center">
<Loader />
</Group>
</Stack>
</Card>
</Stack>
</Container>
</>
);
}

View File

@ -103,6 +103,7 @@ export const AdminCenter = Loadable(
export const NotFound = Loadable(lazy(() => import('./pages/NotFound'))); export const NotFound = Loadable(lazy(() => import('./pages/NotFound')));
export const Login = Loadable(lazy(() => import('./pages/Auth/Login'))); export const Login = Loadable(lazy(() => import('./pages/Auth/Login')));
export const Logout = Loadable(lazy(() => import('./pages/Auth/Logout')));
export const Logged_In = Loadable(lazy(() => import('./pages/Auth/Logged-In'))); export const Logged_In = Loadable(lazy(() => import('./pages/Auth/Logged-In')));
export const Reset = Loadable(lazy(() => import('./pages/Auth/Reset'))); export const Reset = Loadable(lazy(() => import('./pages/Auth/Reset')));
export const Set_Password = Loadable( export const Set_Password = Loadable(
@ -163,6 +164,7 @@ export const routes = (
</Route> </Route>
<Route path="/" errorElement={<ErrorPage />}> <Route path="/" errorElement={<ErrorPage />}>
<Route path="/login" element={<Login />} />, <Route path="/login" element={<Login />} />,
<Route path="/logout" element={<Logout />} />,
<Route path="/logged-in" element={<Logged_In />} /> <Route path="/logged-in" element={<Logged_In />} />
<Route path="/reset-password" element={<Reset />} /> <Route path="/reset-password" element={<Reset />} />
<Route path="/set-password" element={<Set_Password />} /> <Route path="/set-password" element={<Set_Password />} />

View File

@ -1,37 +0,0 @@
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import { setApiDefaults } from '../App';
import { fetchGlobalStates } from './states';
interface SessionStateProps {
token?: string;
setToken: (newToken?: string) => void;
clearToken: () => void;
hasToken: () => boolean;
}
/*
* State manager for user login information.
*/
export const useSessionState = create<SessionStateProps>()(
persist(
(set, get) => ({
token: undefined,
clearToken: () => {
set({ token: undefined });
},
setToken: (newToken) => {
set({ token: newToken });
setApiDefaults();
fetchGlobalStates();
},
hasToken: () => !!get().token
}),
{
name: 'session-state',
storage: createJSONStorage(() => sessionStorage)
}
)
);

View File

@ -5,9 +5,9 @@ import { create, createStore } from 'zustand';
import { api } from '../App'; import { api } from '../App';
import { ApiEndpoints } from '../enums/ApiEndpoints'; import { ApiEndpoints } from '../enums/ApiEndpoints';
import { isLoggedIn } from '../functions/auth';
import { isTrue } from '../functions/conversion'; import { isTrue } from '../functions/conversion';
import { PathParams, apiUrl } from './ApiState'; import { PathParams, apiUrl } from './ApiState';
import { useSessionState } from './SessionState';
import { Setting, SettingsLookup } from './states'; import { Setting, SettingsLookup } from './states';
export interface SettingsStateProps { export interface SettingsStateProps {
@ -29,7 +29,7 @@ export const useGlobalSettingsState = create<SettingsStateProps>(
lookup: {}, lookup: {},
endpoint: ApiEndpoints.settings_global_list, endpoint: ApiEndpoints.settings_global_list,
fetchSettings: async () => { fetchSettings: async () => {
if (!useSessionState.getState().hasToken()) { if (!isLoggedIn()) {
return; return;
} }
@ -63,7 +63,7 @@ export const useUserSettingsState = create<SettingsStateProps>((set, get) => ({
lookup: {}, lookup: {},
endpoint: ApiEndpoints.settings_user_list, endpoint: ApiEndpoints.settings_user_list,
fetchSettings: async () => { fetchSettings: async () => {
if (!useSessionState.getState().hasToken()) { if (!isLoggedIn()) {
return; return;
} }

View File

@ -6,8 +6,8 @@ import { StatusCodeListInterface } from '../components/render/StatusRenderer';
import { statusCodeList } from '../defaults/backendMappings'; import { statusCodeList } from '../defaults/backendMappings';
import { ApiEndpoints } from '../enums/ApiEndpoints'; import { ApiEndpoints } from '../enums/ApiEndpoints';
import { ModelType } from '../enums/ModelType'; import { ModelType } from '../enums/ModelType';
import { isLoggedIn } from '../functions/auth';
import { apiUrl } from './ApiState'; import { apiUrl } from './ApiState';
import { useSessionState } from './SessionState';
type StatusLookup = Record<ModelType | string, StatusCodeListInterface>; type StatusLookup = Record<ModelType | string, StatusCodeListInterface>;
@ -24,7 +24,7 @@ export const useGlobalStatusState = create<ServerStateProps>()(
setStatus: (newStatus: StatusLookup) => set({ status: newStatus }), setStatus: (newStatus: StatusLookup) => set({ status: newStatus }),
fetchStatus: async () => { fetchStatus: async () => {
// Fetch status data for rendering labels // Fetch status data for rendering labels
if (!useSessionState.getState().hasToken()) { if (!isLoggedIn()) {
return; return;
} }

View File

@ -3,8 +3,8 @@ import { create } from 'zustand';
import { api } from '../App'; import { api } from '../App';
import { ApiEndpoints } from '../enums/ApiEndpoints'; import { ApiEndpoints } from '../enums/ApiEndpoints';
import { UserPermissions, UserRoles } from '../enums/Roles'; import { UserPermissions, UserRoles } from '../enums/Roles';
import { isLoggedIn } from '../functions/auth';
import { apiUrl } from './ApiState'; import { apiUrl } from './ApiState';
import { useSessionState } from './SessionState';
import { UserProps } from './states'; import { UserProps } from './states';
interface UserStateProps { interface UserStateProps {
@ -37,7 +37,7 @@ export const useUserState = create<UserStateProps>((set, get) => ({
}, },
setUser: (newUser: UserProps) => set({ user: newUser }), setUser: (newUser: UserProps) => set({ user: newUser }),
fetchUserState: async () => { fetchUserState: async () => {
if (!useSessionState.getState().hasToken()) { if (!isLoggedIn()) {
return; return;
} }
@ -56,7 +56,7 @@ export const useUserState = create<UserStateProps>((set, get) => ({
}; };
set({ user: user }); set({ user: user });
}) })
.catch((_error) => { .catch((error) => {
console.error('Error fetching user data'); console.error('Error fetching user data');
}); });

View File

@ -1,6 +1,6 @@
import { setApiDefaults } from '../App'; import { setApiDefaults } from '../App';
import { isLoggedIn } from '../functions/auth';
import { useServerApiState } from './ApiState'; import { useServerApiState } from './ApiState';
import { useSessionState } from './SessionState';
import { useGlobalSettingsState, useUserSettingsState } from './SettingsState'; import { useGlobalSettingsState, useUserSettingsState } from './SettingsState';
import { useGlobalStatusState } from './StatusState'; import { useGlobalStatusState } from './StatusState';
import { useUserState } from './UserState'; import { useUserState } from './UserState';
@ -126,7 +126,7 @@ export type SettingsLookup = {
* Necessary on login, or if locale is changed. * Necessary on login, or if locale is changed.
*/ */
export function fetchGlobalStates() { export function fetchGlobalStates() {
if (!useSessionState.getState().hasToken()) { if (!isLoggedIn()) {
return; return;
} }

View File

@ -5,10 +5,10 @@ import { BrowserRouter } from 'react-router-dom';
import { queryClient } from '../App'; import { queryClient } from '../App';
import { BaseContext } from '../contexts/BaseContext'; import { BaseContext } from '../contexts/BaseContext';
import { defaultHostList } from '../defaults/defaultHostList'; import { defaultHostList } from '../defaults/defaultHostList';
import { isLoggedIn } from '../functions/auth';
import { base_url } from '../main'; import { base_url } from '../main';
import { routes } from '../router'; import { routes } from '../router';
import { useLocalState } from '../states/LocalState'; import { useLocalState } from '../states/LocalState';
import { useSessionState } from '../states/SessionState';
import { import {
useGlobalSettingsState, useGlobalSettingsState,
useUserSettingsState useUserSettingsState
@ -28,20 +28,19 @@ export default function DesktopAppView() {
// Server Session // Server Session
const [fetchedServerSession, setFetchedServerSession] = useState(false); const [fetchedServerSession, setFetchedServerSession] = useState(false);
const sessionState = useSessionState.getState();
const [token] = sessionState.token ? [sessionState.token] : [null];
useEffect(() => { useEffect(() => {
if (Object.keys(hostList).length === 0) { if (Object.keys(hostList).length === 0) {
useLocalState.setState({ hostList: defaultHostList }); useLocalState.setState({ hostList: defaultHostList });
} }
if (token && !fetchedServerSession) { if (isLoggedIn() && !fetchedServerSession) {
setFetchedServerSession(true); setFetchedServerSession(true);
fetchUserState(); fetchUserState();
fetchGlobalSettings(); fetchGlobalSettings();
fetchUserSettings(); fetchUserSettings();
} }
}, [token, fetchedServerSession]); }, [fetchedServerSession]);
return ( return (
<BaseContext> <BaseContext>

View File

@ -4,11 +4,10 @@ import { classicUrl, user } from './defaults';
test('CUI - Index', async ({ page }) => { test('CUI - Index', async ({ page }) => {
await page.goto(`${classicUrl}/api/`); await page.goto(`${classicUrl}/api/`);
await page.goto(`${classicUrl}/index/`); await page.goto(`${classicUrl}/index/`, { timeout: 10000 });
await expect(page).toHaveTitle('InvenTree Demo Server | Sign In'); console.log('Page title:', await page.title());
await expect( await expect(page).toHaveTitle(RegExp('^InvenTree.*Sign In$'));
page.getByRole('heading', { name: 'InvenTree Demo Server' }) await expect(page.getByRole('heading', { name: 'Sign In' })).toBeVisible();
).toBeVisible();
await page.getByLabel('username').fill(user.username); await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password); await page.getByLabel('password').fill(user.password);

View File

@ -1,6 +1,17 @@
export const classicUrl = 'http://127.0.0.1:8000'; export const classicUrl = 'http://127.0.0.1:8000';
export const baseUrl = `${classicUrl}/platform`;
export const loginUrl = `${baseUrl}/login`;
export const logoutUrl = `${baseUrl}/logout`;
export const homeUrl = `${baseUrl}/home`;
export const user = { export const user = {
name: 'Ally Access',
username: 'allaccess', username: 'allaccess',
password: 'nolimits' password: 'nolimits'
}; };
export const adminuser = {
username: 'admin',
password: 'inventree'
};

View File

@ -0,0 +1,37 @@
import { expect } from './baseFixtures.js';
import { baseUrl, loginUrl, logoutUrl, user } from './defaults';
/*
* Perform form based login operation from the "login" URL
*/
export const doLogin = async (page, username?: string, password?: string) => {
username = username ?? user.username;
password = password ?? user.password;
await page.goto(logoutUrl);
await page.goto(loginUrl);
await expect(page).toHaveTitle(RegExp('^InvenTree.*$'));
await page.waitForURL('**/platform/login');
await page.getByLabel('username').fill(username);
await page.getByLabel('password').fill(password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform/home');
await page.waitForTimeout(250);
};
/*
* Perform a quick login based on passing URL parameters
*/
export const doQuickLogin = async (
page,
username?: string,
password?: string
) => {
username = username ?? user.username;
password = password ?? user.password;
// await page.goto(logoutUrl);
await page.goto(`${baseUrl}/login/?login=${username}&password=${password}`);
await page.waitForURL('**/platform/home');
await page.waitForTimeout(250);
};

View File

@ -1,28 +1,37 @@
import { expect, test } from './baseFixtures.js'; import { expect, test } from './baseFixtures.js';
import { classicUrl, user } from './defaults.js'; import { baseUrl, loginUrl, logoutUrl, user } from './defaults.js';
import { doLogin, doQuickLogin } from './login.js';
test('PUI - Basic test via django', async ({ page }) => { test('PUI - Basic Login Test', async ({ page }) => {
await page.goto(`${classicUrl}/platform/`); await doLogin(page);
await expect(page).toHaveTitle('InvenTree Demo Server');
await page.waitForURL('**/platform/');
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform/*');
await page.goto(`${classicUrl}/platform/`);
await expect(page).toHaveTitle('InvenTree Demo Server'); // Check that the username is provided
}); await page.getByText(user.username);
test('PUI - Basic test', async ({ page }) => { await expect(page).toHaveTitle(RegExp('^InvenTree'));
await page.goto('./platform/');
await expect(page).toHaveTitle('InvenTree'); // Go to the dashboard
await page.waitForURL('**/platform/'); await page.goto(baseUrl);
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform'); await page.waitForURL('**/platform');
await page.goto('./platform/');
await expect(page).toHaveTitle('InvenTree'); await page
.getByRole('heading', { name: `Welcome to your Dashboard, ${user.name}` })
.click();
});
test('PUI - Quick Login Test', async ({ page }) => {
await doQuickLogin(page);
// Check that the username is provided
await page.getByText(user.username);
await expect(page).toHaveTitle(RegExp('^InvenTree'));
// Go to the dashboard
await page.goto(baseUrl);
await page.waitForURL('**/platform');
await page
.getByRole('heading', { name: `Welcome to your Dashboard, ${user.name}` })
.click();
}); });

View File

@ -1,29 +1,14 @@
import { expect, systemKey, test } from './baseFixtures.js'; import { systemKey, test } from './baseFixtures.js';
import { user } from './defaults.js'; import { baseUrl } from './defaults.js';
import { doQuickLogin } from './login.js';
test('PUI - Quick Command', async ({ page }) => { test('PUI - Quick Command', async ({ page }) => {
await page.goto('./platform/'); await doQuickLogin(page);
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/');
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform');
await page.goto('./platform/');
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/');
await page
.getByRole('heading', { name: 'Welcome to your Dashboard,' })
.click();
await page.waitForTimeout(500);
// Open Spotlight with Keyboard Shortcut // Open Spotlight with Keyboard Shortcut
await page.locator('body').press(`${systemKey}+k`); await page.locator('body').press(`${systemKey}+k`);
await page.waitForTimeout(200); await page.waitForTimeout(200);
await page await page.getByRole('tab', { name: 'Dashboard' }).click();
.getByRole('button', { name: 'Dashboard Go to the InvenTree dashboard' })
.click();
await page await page
.locator('div') .locator('div')
.filter({ hasText: /^Dashboard$/ }) .filter({ hasText: /^Dashboard$/ })
@ -47,19 +32,8 @@ test('PUI - Quick Command', async ({ page }) => {
await page.waitForURL('**/platform/dashboard'); await page.waitForURL('**/platform/dashboard');
}); });
test('PUI - Quick Command - no keys', async ({ page }) => { test('PUI - Quick Command - No Keys', async ({ page }) => {
await page.goto('./platform/'); await doQuickLogin(page);
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/');
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform');
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform');
// wait for the page to load - 0.5s
await page.waitForTimeout(500);
// Open Spotlight with Button // Open Spotlight with Button
await page.getByRole('button', { name: 'Open spotlight' }).click(); await page.getByRole('button', { name: 'Open spotlight' }).click();
@ -125,7 +99,7 @@ test('PUI - Quick Command - no keys', async ({ page }) => {
await page.waitForURL('https://docs.inventree.org/**'); await page.waitForURL('https://docs.inventree.org/**');
// Test addition of new actions // Test addition of new actions
await page.goto('./platform/playground'); await page.goto(`${baseUrl}/playground`);
await page await page
.locator('div') .locator('div')
.filter({ hasText: /^Playground$/ }) .filter({ hasText: /^Playground$/ })

View File

@ -1,20 +1,15 @@
import { expect, test } from './baseFixtures.js'; import { expect, test } from './baseFixtures.js';
import { user } from './defaults.js'; import { baseUrl } from './defaults.js';
import { doQuickLogin } from './login.js';
test('PUI - Parts', async ({ page }) => { test('PUI - Parts', async ({ page }) => {
await page.goto('./platform/'); await doQuickLogin(page);
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/');
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform');
await page.goto('./platform/home');
await page.goto(`${baseUrl}/home`);
await page.getByRole('tab', { name: 'Parts' }).click(); await page.getByRole('tab', { name: 'Parts' }).click();
await page.goto('./platform/part/');
await page.waitForURL('**/platform/part/category/index/details'); await page.waitForURL('**/platform/part/category/index/details');
await page.goto('./platform/part/category/index/parts'); await page.goto(`${baseUrl}/part/category/index/parts`);
await page.getByText('1551ABK').click(); await page.getByText('1551ABK').click();
await page.getByRole('tab', { name: 'Allocations' }).click(); await page.getByRole('tab', { name: 'Allocations' }).click();
await page.getByRole('tab', { name: 'Used In' }).click(); await page.getByRole('tab', { name: 'Used In' }).click();
@ -39,15 +34,10 @@ test('PUI - Parts', async ({ page }) => {
}); });
test('PUI - Parts - Manufacturer Parts', async ({ page }) => { test('PUI - Parts - Manufacturer Parts', async ({ page }) => {
await page.goto('./platform/'); await doQuickLogin(page);
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/'); await page.goto(`${baseUrl}/part/84/manufacturers`);
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform');
await page.goto('./platform/part/84/manufacturers');
await page.getByRole('tab', { name: 'Manufacturers' }).click(); await page.getByRole('tab', { name: 'Manufacturers' }).click();
await page.getByText('Hammond Manufacturing').click(); await page.getByText('Hammond Manufacturing').click();
await page.getByRole('tab', { name: 'Parameters' }).click(); await page.getByRole('tab', { name: 'Parameters' }).click();
@ -57,15 +47,10 @@ test('PUI - Parts - Manufacturer Parts', async ({ page }) => {
}); });
test('PUI - Parts - Supplier Parts', async ({ page }) => { test('PUI - Parts - Supplier Parts', async ({ page }) => {
await page.goto('./platform/'); await doQuickLogin(page);
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/'); await page.goto(`${baseUrl}/part/15/suppliers`);
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform');
await page.goto('./platform/part/15/suppliers');
await page.getByRole('tab', { name: 'Suppliers' }).click(); await page.getByRole('tab', { name: 'Suppliers' }).click();
await page.getByRole('cell', { name: 'DIG-84670-SJI' }).click(); await page.getByRole('cell', { name: 'DIG-84670-SJI' }).click();
await page.getByRole('tab', { name: 'Received Stock' }).click(); // await page.getByRole('tab', { name: 'Received Stock' }).click(); //
@ -75,15 +60,10 @@ test('PUI - Parts - Supplier Parts', async ({ page }) => {
}); });
test('PUI - Sales', async ({ page }) => { test('PUI - Sales', async ({ page }) => {
await page.goto('./platform/'); await doQuickLogin(page);
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/'); await page.goto(`${baseUrl}/sales/`);
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform');
await page.goto('./platform/sales/');
await page.waitForURL('**/platform/sales/**'); await page.waitForURL('**/platform/sales/**');
await page.waitForURL('**/platform/sales/index/salesorders'); await page.waitForURL('**/platform/sales/index/salesorders');
await page.getByRole('tab', { name: 'Return Orders' }).click(); await page.getByRole('tab', { name: 'Return Orders' }).click();
@ -131,13 +111,7 @@ test('PUI - Sales', async ({ page }) => {
}); });
test('PUI - Scanning', async ({ page }) => { test('PUI - Scanning', async ({ page }) => {
await page.goto('./platform/'); await doQuickLogin(page);
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/');
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform');
await page.getByLabel('Homenav').click(); await page.getByLabel('Homenav').click();
await page.getByRole('button', { name: 'System Information' }).click(); await page.getByRole('button', { name: 'System Information' }).click();
@ -158,13 +132,8 @@ test('PUI - Scanning', async ({ page }) => {
}); });
test('PUI - Admin', async ({ page }) => { test('PUI - Admin', async ({ page }) => {
await page.goto('./platform/'); // Note here we login with admin access
await expect(page).toHaveTitle('InvenTree'); await doQuickLogin(page, 'admin', 'inventree');
await page.waitForURL('**/platform/*');
await page.getByLabel('username').fill('admin');
await page.getByLabel('password').fill('inventree');
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform');
// User settings // User settings
await page.getByRole('button', { name: 'admin' }).click(); await page.getByRole('button', { name: 'admin' }).click();
@ -213,13 +182,7 @@ test('PUI - Admin', async ({ page }) => {
}); });
test('PUI - Language / Color', async ({ page }) => { test('PUI - Language / Color', async ({ page }) => {
await page.goto('./platform/'); await doQuickLogin(page);
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/*');
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform');
await page.getByRole('button', { name: 'Ally Access' }).click(); await page.getByRole('button', { name: 'Ally Access' }).click();
await page.getByRole('menuitem', { name: 'Logout' }).click(); await page.getByRole('menuitem', { name: 'Logout' }).click();
@ -253,15 +216,9 @@ test('PUI - Language / Color', async ({ page }) => {
}); });
test('PUI - Company', async ({ page }) => { test('PUI - Company', async ({ page }) => {
await page.goto('./platform/'); await doQuickLogin(page);
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/');
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform');
await page.goto('./platform/company/1/details'); await page.goto(`${baseUrl}/company/1/details`);
await page await page
.locator('div') .locator('div')
.filter({ hasText: /^DigiKey Electronics$/ }) .filter({ hasText: /^DigiKey Electronics$/ })

View File

@ -1,16 +1,11 @@
import { expect, test } from './baseFixtures.js'; import { expect, test } from './baseFixtures.js';
import { user } from './defaults.js'; import { baseUrl, user } from './defaults.js';
import { doQuickLogin } from './login.js';
test('PUI - Stock', async ({ page }) => { test('PUI - Stock', async ({ page }) => {
await page.goto('./platform/'); await doQuickLogin(page);
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/');
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform');
await page.goto('./platform/stock'); await page.goto(`${baseUrl}/stock`);
await page.waitForURL('**/platform/stock/location/index/details'); await page.waitForURL('**/platform/stock/location/index/details');
await page.getByRole('tab', { name: 'Stock Items' }).click(); await page.getByRole('tab', { name: 'Stock Items' }).click();
await page.getByRole('cell', { name: '1551ABK' }).click(); await page.getByRole('cell', { name: '1551ABK' }).click();
@ -24,13 +19,7 @@ test('PUI - Stock', async ({ page }) => {
}); });
test('PUI - Build', async ({ page }) => { test('PUI - Build', async ({ page }) => {
await page.goto('./platform/'); await doQuickLogin(page);
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/');
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform');
await page.getByRole('tab', { name: 'Build' }).click(); await page.getByRole('tab', { name: 'Build' }).click();
await page.getByText('Widget Assembly Variant').click(); await page.getByText('Widget Assembly Variant').click();
@ -44,13 +33,7 @@ test('PUI - Build', async ({ page }) => {
}); });
test('PUI - Purchasing', async ({ page }) => { test('PUI - Purchasing', async ({ page }) => {
await page.goto('./platform/'); await doQuickLogin(page);
await expect(page).toHaveTitle('InvenTree');
await page.waitForURL('**/platform/');
await page.getByLabel('username').fill(user.username);
await page.getByLabel('password').fill(user.password);
await page.getByRole('button', { name: 'Log in' }).click();
await page.waitForURL('**/platform');
await page.getByRole('tab', { name: 'Purchasing' }).click(); await page.getByRole('tab', { name: 'Purchasing' }).click();
await page.getByRole('cell', { name: 'PO0012' }).click(); await page.getByRole('cell', { name: 'PO0012' }).click();