diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index 693e0b7539..0f33bbbabe 100644 --- a/.github/workflows/qc_checks.yaml +++ b/.github/workflows/qc_checks.yaml @@ -544,15 +544,6 @@ jobs: - name: Report coverage if: always() 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 uses: codecov/codecov-action@v4.3.0 if: always() diff --git a/RELEASE.md b/RELEASE.md index 44979fa0b2..f4370acbe3 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -4,7 +4,7 @@ Checklist of steps to perform at each code release ### 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 diff --git a/contrib/container/requirements.txt b/contrib/container/requirements.txt index 8450c5251b..47968937a5 100644 --- a/contrib/container/requirements.txt +++ b/contrib/container/requirements.txt @@ -12,7 +12,7 @@ mysqlclient>=2.2.0 mariadb>=1.1.8 # gunicorn web server -gunicorn>=21.2.0 +gunicorn>=22.0.0 # LDAP required packages django-auth-ldap # Django integration for ldap auth diff --git a/docs/docs/develop/contributing.md b/docs/docs/develop/contributing.md index 12ed77faf7..06df11fabb 100644 --- a/docs/docs/develop/contributing.md +++ b/docs/docs/develop/contributing.md @@ -96,7 +96,7 @@ The HEAD of the "stable" branch represents the latest stable release code. ## 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 diff --git a/docs/docs/extend/plugins/urls.md b/docs/docs/extend/plugins/urls.md index efa0ca8f7c..4931904d39 100644 --- a/docs/docs/extend/plugins/urls.md +++ b/docs/docs/extend/plugins/urls.md @@ -65,7 +65,7 @@ Additionally, add the following imports after the extended line. #### 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 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" 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. diff --git a/docs/docs/report/stock_location.md b/docs/docs/report/stock_location.md index 27fb1b729b..e1712c06d9 100644 --- a/docs/docs/report/stock_location.md +++ b/docs/docs/report/stock_location.md @@ -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. -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. diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index c6edfc2d96..b0fde97af8 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,12 +1,16 @@ """InvenTree API version information.""" # 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.""" 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 BomItem list endpoint to be sorted by pricing_min and pricing_max values - Allow InternalPrice and SalePrice endpoints to be sorted by quantity diff --git a/src/backend/InvenTree/InvenTree/settings.py b/src/backend/InvenTree/InvenTree/settings.py index bd60fdf598..a32a2fbd77 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -492,10 +492,18 @@ if DEBUG: 'rest_framework.renderers.BrowsableAPIRenderer' ) -# dj-rest-auth # JWT switch USE_JWT = get_boolean_setting('INVENTREE_USE_JWT', 'use_jwt', False) 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 REST_AUTH_REGISTER_SERIALIZERS = { 'REGISTER_SERIALIZER': 'InvenTree.forms.CustomRegisterSerializer' @@ -510,6 +518,7 @@ if USE_JWT: ) INSTALLED_APPS.append('rest_framework_simplejwt') + # WSGI default setting WSGI_APPLICATION = 'InvenTree.wsgi.application' @@ -1092,6 +1101,13 @@ if not TESTING and len(CSRF_TRUSTED_ORIGINS) == 0: ) 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( 'INVENTREE_USE_X_FORWARDED_HOST', config_key='use_x_forwarded_host', diff --git a/src/backend/InvenTree/InvenTree/urls.py b/src/backend/InvenTree/InvenTree/urls.py index c71f567206..ab3dc008a2 100644 --- a/src/backend/InvenTree/InvenTree/urls.py +++ b/src/backend/InvenTree/InvenTree/urls.py @@ -160,6 +160,7 @@ apipatterns = [ SocialAccountDisconnectView.as_view(), 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( 'login-redirect/', diff --git a/src/backend/InvenTree/users/api.py b/src/backend/InvenTree/users/api.py index 17def22835..43fe8ad521 100644 --- a/src/backend/InvenTree/users/api.py +++ b/src/backend/InvenTree/users/api.py @@ -8,9 +8,11 @@ from django.contrib.auth.models import Group, User from django.urls import include, path, re_path 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 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.views import APIView @@ -205,6 +207,18 @@ class GroupList(ListCreateAPI): 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( post=extend_schema( responses={200: OpenApiResponse(description='User successfully logged out')} diff --git a/src/backend/InvenTree/users/models.py b/src/backend/InvenTree/users/models.py index e2ddcd3417..fc4cc141ec 100644 --- a/src/backend/InvenTree/users/models.py +++ b/src/backend/InvenTree/users/models.py @@ -56,6 +56,17 @@ def default_token_expiry(): 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): """Extends the default token model provided by djangorestframework.authtoken. diff --git a/src/backend/requirements.txt b/src/backend/requirements.txt index 6703456096..e04b24e685 100644 --- a/src/backend/requirements.txt +++ b/src/backend/requirements.txt @@ -134,7 +134,7 @@ googleapis-common-protos==1.63.0 # opentelemetry-exporter-otlp-proto-http grpcio==1.62.1 # via opentelemetry-exporter-otlp-proto-grpc -gunicorn==21.2.0 +gunicorn==22.0.0 html5lib==1.1 # via weasyprint icalendar==5.0.12 diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 0ec3e8576c..fff83a6af8 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,40 +1,24 @@ import { QueryClient } from '@tanstack/react-query'; import axios from 'axios'; -import { getCsrfCookie } from './functions/auth'; import { useLocalState } from './states/LocalState'; -import { useSessionState } from './states/SessionState'; // Global API instance export const api = axios.create({}); /* * Setup default settings for the Axios API instance. - * - * This includes: - * - Base URL - * - Authorization token (if available) - * - CSRF token (if available) */ export function setApiDefaults() { const host = useLocalState.getState().host; - const token = useSessionState.getState().token; api.defaults.baseURL = host; api.defaults.timeout = 2500; - api.defaults.headers.common['Authorization'] = token - ? `Token ${token}` - : undefined; - if (!!getCsrfCookie()) { - api.defaults.withCredentials = true; - api.defaults.xsrfCookieName = 'csrftoken'; - api.defaults.xsrfHeaderName = 'X-CSRFToken'; - } else { - api.defaults.withCredentials = false; - api.defaults.xsrfCookieName = undefined; - api.defaults.xsrfHeaderName = undefined; - } + api.defaults.withCredentials = true; + api.defaults.withXSRFToken = true; + api.defaults.xsrfCookieName = 'csrftoken'; + api.defaults.xsrfHeaderName = 'X-CSRFToken'; } export const queryClient = new QueryClient(); diff --git a/src/frontend/src/components/forms/AuthenticationForm.tsx b/src/frontend/src/components/forms/AuthenticationForm.tsx index 31c2d7c28b..c97d24bd29 100644 --- a/src/frontend/src/components/forms/AuthenticationForm.tsx +++ b/src/frontend/src/components/forms/AuthenticationForm.tsx @@ -12,16 +12,14 @@ import { } from '@mantine/core'; import { useForm } from '@mantine/form'; import { useDisclosure } from '@mantine/hooks'; -import { notifications } from '@mantine/notifications'; -import { IconCheck } from '@tabler/icons-react'; import { useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { api } from '../../App'; 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 { useSessionState } from '../../states/SessionState'; import { SsoButton } from '../buttons/SSOButton'; export function AuthenticationForm() { @@ -46,19 +44,18 @@ export function AuthenticationForm() { ).then(() => { setIsLoggingIn(false); - if (useSessionState.getState().hasToken()) { - notifications.show({ + if (isLoggedIn()) { + showLoginNotification({ title: t`Login successful`, - message: t`Welcome back!`, - color: 'green', - icon: + message: t`Logged in successfully` }); + navigate(location?.state?.redirectFrom ?? '/home'); } else { - notifications.show({ + showLoginNotification({ title: t`Login failed`, message: t`Check your input and try again.`, - color: 'red' + success: false }); } }); @@ -67,18 +64,15 @@ export function AuthenticationForm() { setIsLoggingIn(false); if (ret?.status === 'ok') { - notifications.show({ + showLoginNotification({ 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.`, - color: 'green', - icon: , - autoClose: false + message: t`Check your inbox for the login link. If you have an account, you will receive a login link. Check in spam too.` }); } else { - notifications.show({ - title: t`Input error`, + showLoginNotification({ + title: t`Mail delivery failed`, message: t`Check your input and try again.`, - color: 'red' + success: false }); } }); @@ -193,11 +187,9 @@ export function RegistrationForm() { .then((ret) => { if (ret?.status === 204) { setIsRegistering(false); - notifications.show({ + showLoginNotification({ title: t`Registration successful`, - message: t`Please confirm your email address to complete the registration`, - color: 'green', - icon: + message: t`Please confirm your email address to complete the registration` }); navigate('/home'); } @@ -212,11 +204,10 @@ export function RegistrationForm() { if (err.response?.data?.non_field_errors) { err_msg = err.response.data.non_field_errors; } - notifications.show({ + showLoginNotification({ title: t`Input error`, message: t`Check your input and try again. ` + err_msg, - color: 'red', - autoClose: 30000 + success: false }); } }); diff --git a/src/frontend/src/components/images/ApiImage.tsx b/src/frontend/src/components/images/ApiImage.tsx index d8f457b427..ce123f2ed3 100644 --- a/src/frontend/src/components/images/ApiImage.tsx +++ b/src/frontend/src/components/images/ApiImage.tsx @@ -1,71 +1,27 @@ /** - * Component for loading an image from the InvenTree server, - * using the API's token authentication. + * Component for loading an image from the InvenTree server * * Image caching is handled automagically by the browsers cache */ import { Image, ImageProps, Skeleton, Stack } from '@mantine/core'; -import { useId } from '@mantine/hooks'; -import { useQuery } from '@tanstack/react-query'; -import { useState } from 'react'; +import { useMemo } from 'react'; -import { api } from '../../App'; +import { useLocalState } from '../../states/LocalState'; /** * Construct an image container which will load and display the image */ export function ApiImage(props: ImageProps) { - const [image, setImage] = useState(''); + const { host } = useLocalState.getState(); - const [authorized, setAuthorized] = useState(true); - - const queryKey = useId(); - - 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 - }); + const imageUrl = useMemo(() => { + return `${host}${props.src}`; + }, [host, props.src]); return ( - {image && image.length > 0 ? ( - + {imageUrl ? ( + ) : ( s.host); + + const url = useMemo(() => { + if (external) { + return attachment; + } + + return `${host}${attachment}`; + }, [host, attachment, external]); + return ( {external ? : attachmentIcon(attachment)} - + {text} diff --git a/src/frontend/src/components/nav/Layout.tsx b/src/frontend/src/components/nav/Layout.tsx index 91a6cd00c6..c9a4c436b3 100644 --- a/src/frontend/src/components/nav/Layout.tsx +++ b/src/frontend/src/components/nav/Layout.tsx @@ -6,17 +6,15 @@ import { useEffect, useState } from 'react'; import { Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { getActions } from '../../defaults/actions'; +import { isLoggedIn } from '../../functions/auth'; import { InvenTreeStyle } from '../../globalStyle'; -import { useSessionState } from '../../states/SessionState'; import { Footer } from './Footer'; import { Header } from './Header'; export const ProtectedRoute = ({ children }: { children: JSX.Element }) => { - const [token] = useSessionState((state) => [state.token]); - const location = useLocation(); - if (!token) { + if (!isLoggedIn()) { return ( ); diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx index 821bbd973e..dd5fab30da 100644 --- a/src/frontend/src/enums/ApiEndpoints.tsx +++ b/src/frontend/src/enums/ApiEndpoints.tsx @@ -15,14 +15,15 @@ export enum ApiEndpoints { user_roles = 'user/roles/', user_token = 'user/token/', user_simple_login = 'email/generate/', - user_reset = 'auth/password/reset/', // Note leading prefix here - user_reset_set = 'auth/password/reset/confirm/', // Note leading prefix here + user_reset = 'auth/password/reset/', + user_reset_set = 'auth/password/reset/confirm/', user_sso = 'auth/social/', user_sso_remove = 'auth/social/:id/disconnect/', user_emails = 'auth/emails/', user_email_remove = 'auth/emails/:id/remove/', user_email_verify = 'auth/emails/:id/verify/', user_email_primary = 'auth/emails/:id/primary/', + user_login = 'auth/login/', user_logout = 'auth/logout/', user_register = 'auth/registration/', diff --git a/src/frontend/src/functions/auth.tsx b/src/frontend/src/functions/auth.tsx index d0f010e9c2..c1d7dbd7ac 100644 --- a/src/frontend/src/functions/auth.tsx +++ b/src/frontend/src/functions/auth.tsx @@ -1,15 +1,13 @@ import { t } from '@lingui/macro'; import { notifications } from '@mantine/notifications'; -import { IconCheck } from '@tabler/icons-react'; import axios from 'axios'; import { api, setApiDefaults } from '../App'; import { ApiEndpoints } from '../enums/ApiEndpoints'; import { apiUrl } from '../states/ApiState'; import { useLocalState } from '../states/LocalState'; -import { useSessionState } from '../states/SessionState'; - -const tokenName: string = 'inventree-web-app'; +import { fetchGlobalStates } from '../states/states'; +import { showLoginNotification } from './notifications'; /** * Attempt to login using username:password combination. @@ -24,26 +22,35 @@ export const doBasicLogin = async (username: string, password: string) => { return; } - // At this stage, we can assume that we are not logged in, and we have no token - useSessionState.getState().clearToken(); + clearCsrfCookie(); - // Request new token from the server - await axios - .get(apiUrl(ApiEndpoints.user_token), { - auth: { username, password }, - baseURL: host, - timeout: 2000, - params: { - name: tokenName + const login_url = apiUrl(ApiEndpoints.user_login); + + // Attempt login with + await api + .post( + login_url, + { + username: username, + password: password + }, + { + baseURL: host } - }) + ) .then((response) => { - if (response.status == 200 && response.data.token) { - // A valid token has been returned - save, and login - useSessionState.getState().setToken(response.data.token); + switch (response.status) { + case 200: + 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) => { // Logout from the server session - await api.post(apiUrl(ApiEndpoints.user_logout)).catch(() => { - // If an error occurs here, we are likely already logged out + await api.post(apiUrl(ApiEndpoints.user_logout)).finally(() => { + clearCsrfCookie(); 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: - }); - - navigate('/login'); }; export const doSimpleLogin = async (email: string) => { @@ -134,55 +129,33 @@ export function checkLoginState( ) { setApiDefaults(); + if (redirect == '/') { + redirect = '/home'; + } + // Callback function when login is successful const loginSuccess = () => { - notifications.hide('login'); - notifications.show({ - id: 'login', + showLoginNotification({ title: t`Logged In`, - message: t`Found an existing login - welcome back!`, - color: 'green', - icon: + message: t`Successfully logged in` }); + navigate(redirect ?? '/home'); }; // Callback function when login fails const loginFailure = () => { - useSessionState.getState().clearToken(); if (!no_redirect) { navigate('/login', { state: { redirectFrom: redirect } }); } }; - if (useSessionState.getState().hasToken()) { - // An existing token is available - check if it works + // Check the 'user_me' endpoint to see if the user is logged in + if (isLoggedIn()) { api - .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 - } - }) + .get(apiUrl(ApiEndpoints.user_me)) .then((response) => { - if (response.status == 200 && response.data.token) { - useSessionState.getState().setToken(response.data.token); + if (response.status == 200) { loginSuccess(); } else { loginFailure(); @@ -192,7 +165,6 @@ export function checkLoginState( loginFailure(); }); } else { - // No token, no cookie - redirect to login page loginFailure(); } } @@ -209,8 +181,12 @@ export function getCsrfCookie() { 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() { document.cookie = diff --git a/src/frontend/src/functions/icons.tsx b/src/frontend/src/functions/icons.tsx index 6d8b0bed1d..b362d08ce5 100644 --- a/src/frontend/src/functions/icons.tsx +++ b/src/frontend/src/functions/icons.tsx @@ -7,6 +7,7 @@ import { IconBuilding, IconBuildingFactory2, IconBuildingStore, + IconBusinessplan, IconCalendar, IconCalendarStats, IconCategory, @@ -100,6 +101,7 @@ const icons = { info: IconInfoCircle, details: IconInfoCircle, parameters: IconList, + list: IconList, stock: IconPackages, variants: IconVersions, allocations: IconBookmarks, @@ -171,6 +173,7 @@ const icons = { customer: IconUser, quantity: IconNumbers, progress: IconProgressCheck, + total_cost: IconBusinessplan, reference: IconHash, serial: IconHash, website: IconWorld, diff --git a/src/frontend/src/functions/notifications.tsx b/src/frontend/src/functions/notifications.tsx index 9682e8738c..0306d1d92c 100644 --- a/src/frontend/src/functions/notifications.tsx +++ b/src/frontend/src/functions/notifications.tsx @@ -1,5 +1,6 @@ import { t } from '@lingui/macro'; import { notifications } from '@mantine/notifications'; +import { IconCircleCheck, IconExclamationCircle } from '@tabler/icons-react'; /** * Show a notification that the feature is not yet implemented @@ -34,3 +35,28 @@ export function invalidResponse(returnCode: number) { 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 ? : , + id: 'login', + autoClose: 5000 + }); +} diff --git a/src/frontend/src/pages/Auth/Login.tsx b/src/frontend/src/pages/Auth/Login.tsx index 12f2fcf2b5..77e2b541e5 100644 --- a/src/frontend/src/pages/Auth/Login.tsx +++ b/src/frontend/src/pages/Auth/Login.tsx @@ -2,7 +2,7 @@ import { Trans, t } from '@lingui/macro'; import { Center, Container, Paper, Text } from '@mantine/core'; import { useDisclosure, useToggle } from '@mantine/hooks'; import { useEffect } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { setApiDefaults } from '../../App'; import { AuthFormOptions } from '../../components/forms/AuthFormOptions'; @@ -13,7 +13,7 @@ import { } from '../../components/forms/AuthenticationForm'; import { InstanceOptions } from '../../components/forms/InstanceOptions'; import { defaultHostKey } from '../../defaults/defaultHostList'; -import { checkLoginState } from '../../functions/auth'; +import { checkLoginState, doBasicLogin } from '../../functions/auth'; import { useServerApiState } from '../../states/ApiState'; import { useLocalState } from '../../states/LocalState'; @@ -33,6 +33,7 @@ export default function Login() { const [loginMode, setMode] = useDisclosure(true); const navigate = useNavigate(); const location = useLocation(); + const [searchParams] = useSearchParams(); // Data manipulation functions function ChangeHost(newHost: string): void { @@ -48,6 +49,16 @@ export default function Login() { } 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 diff --git a/src/frontend/src/pages/Auth/Logout.tsx b/src/frontend/src/pages/Auth/Logout.tsx new file mode 100644 index 0000000000..25dfd04c4e --- /dev/null +++ b/src/frontend/src/pages/Auth/Logout.tsx @@ -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 ( + <> + + + + + + Logging out + + + + + + + + + + ); +} diff --git a/src/frontend/src/router.tsx b/src/frontend/src/router.tsx index c376dab343..a80a0fdce1 100644 --- a/src/frontend/src/router.tsx +++ b/src/frontend/src/router.tsx @@ -103,6 +103,7 @@ export const AdminCenter = Loadable( export const NotFound = Loadable(lazy(() => import('./pages/NotFound'))); 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 Reset = Loadable(lazy(() => import('./pages/Auth/Reset'))); export const Set_Password = Loadable( @@ -163,6 +164,7 @@ export const routes = ( }> } />, + } />, } /> } /> } /> diff --git a/src/frontend/src/states/SessionState.tsx b/src/frontend/src/states/SessionState.tsx deleted file mode 100644 index 5ac12407d7..0000000000 --- a/src/frontend/src/states/SessionState.tsx +++ /dev/null @@ -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()( - 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) - } - ) -); diff --git a/src/frontend/src/states/SettingsState.tsx b/src/frontend/src/states/SettingsState.tsx index 61aec8ff08..85fdba8ab3 100644 --- a/src/frontend/src/states/SettingsState.tsx +++ b/src/frontend/src/states/SettingsState.tsx @@ -5,9 +5,9 @@ import { create, createStore } from 'zustand'; import { api } from '../App'; import { ApiEndpoints } from '../enums/ApiEndpoints'; +import { isLoggedIn } from '../functions/auth'; import { isTrue } from '../functions/conversion'; import { PathParams, apiUrl } from './ApiState'; -import { useSessionState } from './SessionState'; import { Setting, SettingsLookup } from './states'; export interface SettingsStateProps { @@ -29,7 +29,7 @@ export const useGlobalSettingsState = create( lookup: {}, endpoint: ApiEndpoints.settings_global_list, fetchSettings: async () => { - if (!useSessionState.getState().hasToken()) { + if (!isLoggedIn()) { return; } @@ -63,7 +63,7 @@ export const useUserSettingsState = create((set, get) => ({ lookup: {}, endpoint: ApiEndpoints.settings_user_list, fetchSettings: async () => { - if (!useSessionState.getState().hasToken()) { + if (!isLoggedIn()) { return; } diff --git a/src/frontend/src/states/StatusState.tsx b/src/frontend/src/states/StatusState.tsx index 51b31f851d..57e845f33a 100644 --- a/src/frontend/src/states/StatusState.tsx +++ b/src/frontend/src/states/StatusState.tsx @@ -6,8 +6,8 @@ import { StatusCodeListInterface } from '../components/render/StatusRenderer'; import { statusCodeList } from '../defaults/backendMappings'; import { ApiEndpoints } from '../enums/ApiEndpoints'; import { ModelType } from '../enums/ModelType'; +import { isLoggedIn } from '../functions/auth'; import { apiUrl } from './ApiState'; -import { useSessionState } from './SessionState'; type StatusLookup = Record; @@ -24,7 +24,7 @@ export const useGlobalStatusState = create()( setStatus: (newStatus: StatusLookup) => set({ status: newStatus }), fetchStatus: async () => { // Fetch status data for rendering labels - if (!useSessionState.getState().hasToken()) { + if (!isLoggedIn()) { return; } diff --git a/src/frontend/src/states/UserState.tsx b/src/frontend/src/states/UserState.tsx index 621a5a5c82..35d8a82979 100644 --- a/src/frontend/src/states/UserState.tsx +++ b/src/frontend/src/states/UserState.tsx @@ -3,8 +3,8 @@ import { create } from 'zustand'; import { api } from '../App'; import { ApiEndpoints } from '../enums/ApiEndpoints'; import { UserPermissions, UserRoles } from '../enums/Roles'; +import { isLoggedIn } from '../functions/auth'; import { apiUrl } from './ApiState'; -import { useSessionState } from './SessionState'; import { UserProps } from './states'; interface UserStateProps { @@ -37,7 +37,7 @@ export const useUserState = create((set, get) => ({ }, setUser: (newUser: UserProps) => set({ user: newUser }), fetchUserState: async () => { - if (!useSessionState.getState().hasToken()) { + if (!isLoggedIn()) { return; } @@ -56,7 +56,7 @@ export const useUserState = create((set, get) => ({ }; set({ user: user }); }) - .catch((_error) => { + .catch((error) => { console.error('Error fetching user data'); }); diff --git a/src/frontend/src/states/states.tsx b/src/frontend/src/states/states.tsx index fba98fc4c6..0ec0139a14 100644 --- a/src/frontend/src/states/states.tsx +++ b/src/frontend/src/states/states.tsx @@ -1,6 +1,6 @@ import { setApiDefaults } from '../App'; +import { isLoggedIn } from '../functions/auth'; import { useServerApiState } from './ApiState'; -import { useSessionState } from './SessionState'; import { useGlobalSettingsState, useUserSettingsState } from './SettingsState'; import { useGlobalStatusState } from './StatusState'; import { useUserState } from './UserState'; @@ -126,7 +126,7 @@ export type SettingsLookup = { * Necessary on login, or if locale is changed. */ export function fetchGlobalStates() { - if (!useSessionState.getState().hasToken()) { + if (!isLoggedIn()) { return; } diff --git a/src/frontend/src/views/DesktopAppView.tsx b/src/frontend/src/views/DesktopAppView.tsx index a48445272d..1ee5f84f2e 100644 --- a/src/frontend/src/views/DesktopAppView.tsx +++ b/src/frontend/src/views/DesktopAppView.tsx @@ -5,10 +5,10 @@ import { BrowserRouter } from 'react-router-dom'; import { queryClient } from '../App'; import { BaseContext } from '../contexts/BaseContext'; import { defaultHostList } from '../defaults/defaultHostList'; +import { isLoggedIn } from '../functions/auth'; import { base_url } from '../main'; import { routes } from '../router'; import { useLocalState } from '../states/LocalState'; -import { useSessionState } from '../states/SessionState'; import { useGlobalSettingsState, useUserSettingsState @@ -28,20 +28,19 @@ export default function DesktopAppView() { // Server Session const [fetchedServerSession, setFetchedServerSession] = useState(false); - const sessionState = useSessionState.getState(); - const [token] = sessionState.token ? [sessionState.token] : [null]; + useEffect(() => { if (Object.keys(hostList).length === 0) { useLocalState.setState({ hostList: defaultHostList }); } - if (token && !fetchedServerSession) { + if (isLoggedIn() && !fetchedServerSession) { setFetchedServerSession(true); fetchUserState(); fetchGlobalSettings(); fetchUserSettings(); } - }, [token, fetchedServerSession]); + }, [fetchedServerSession]); return ( diff --git a/src/frontend/tests/cui.spec.ts b/src/frontend/tests/cui.spec.ts index f33ee66b0e..66f705f25d 100644 --- a/src/frontend/tests/cui.spec.ts +++ b/src/frontend/tests/cui.spec.ts @@ -4,11 +4,10 @@ import { classicUrl, user } from './defaults'; test('CUI - Index', async ({ page }) => { await page.goto(`${classicUrl}/api/`); - await page.goto(`${classicUrl}/index/`); - await expect(page).toHaveTitle('InvenTree Demo Server | Sign In'); - await expect( - page.getByRole('heading', { name: 'InvenTree Demo Server' }) - ).toBeVisible(); + await page.goto(`${classicUrl}/index/`, { timeout: 10000 }); + console.log('Page title:', await page.title()); + await expect(page).toHaveTitle(RegExp('^InvenTree.*Sign In$')); + await expect(page.getByRole('heading', { name: 'Sign In' })).toBeVisible(); await page.getByLabel('username').fill(user.username); await page.getByLabel('password').fill(user.password); diff --git a/src/frontend/tests/defaults.ts b/src/frontend/tests/defaults.ts index 40a0b0a209..3ceaa5b9fa 100644 --- a/src/frontend/tests/defaults.ts +++ b/src/frontend/tests/defaults.ts @@ -1,6 +1,17 @@ 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 = { + name: 'Ally Access', username: 'allaccess', password: 'nolimits' }; + +export const adminuser = { + username: 'admin', + password: 'inventree' +}; diff --git a/src/frontend/tests/login.ts b/src/frontend/tests/login.ts new file mode 100644 index 0000000000..a8165c4f61 --- /dev/null +++ b/src/frontend/tests/login.ts @@ -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); +}; diff --git a/src/frontend/tests/pui_basic.spec.ts b/src/frontend/tests/pui_basic.spec.ts index 9d2eb43cd1..eadb2187b4 100644 --- a/src/frontend/tests/pui_basic.spec.ts +++ b/src/frontend/tests/pui_basic.spec.ts @@ -1,28 +1,37 @@ 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 }) => { - await page.goto(`${classicUrl}/platform/`); - 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/`); +test('PUI - Basic Login Test', async ({ page }) => { + await doLogin(page); - 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 page.goto('./platform/'); - 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 expect(page).toHaveTitle(RegExp('^InvenTree')); + + // Go to the dashboard + await page.goto(baseUrl); 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(); }); diff --git a/src/frontend/tests/pui_command.spec.ts b/src/frontend/tests/pui_command.spec.ts index a50aa96cc5..f722d04bba 100644 --- a/src/frontend/tests/pui_command.spec.ts +++ b/src/frontend/tests/pui_command.spec.ts @@ -1,29 +1,14 @@ -import { expect, systemKey, test } from './baseFixtures.js'; -import { user } from './defaults.js'; +import { systemKey, test } from './baseFixtures.js'; +import { baseUrl } from './defaults.js'; +import { doQuickLogin } from './login.js'; test('PUI - Quick Command', async ({ page }) => { - await page.goto('./platform/'); - 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); + await doQuickLogin(page); // Open Spotlight with Keyboard Shortcut await page.locator('body').press(`${systemKey}+k`); await page.waitForTimeout(200); - await page - .getByRole('button', { name: 'Dashboard Go to the InvenTree dashboard' }) - .click(); + await page.getByRole('tab', { name: 'Dashboard' }).click(); await page .locator('div') .filter({ hasText: /^Dashboard$/ }) @@ -47,19 +32,8 @@ test('PUI - Quick Command', async ({ page }) => { await page.waitForURL('**/platform/dashboard'); }); -test('PUI - Quick Command - no keys', async ({ page }) => { - await page.goto('./platform/'); - 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); +test('PUI - Quick Command - No Keys', async ({ page }) => { + await doQuickLogin(page); // Open Spotlight with Button 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/**'); // Test addition of new actions - await page.goto('./platform/playground'); + await page.goto(`${baseUrl}/playground`); await page .locator('div') .filter({ hasText: /^Playground$/ }) diff --git a/src/frontend/tests/pui_general.spec.ts b/src/frontend/tests/pui_general.spec.ts index 75d9345690..3dead1ce79 100644 --- a/src/frontend/tests/pui_general.spec.ts +++ b/src/frontend/tests/pui_general.spec.ts @@ -1,20 +1,15 @@ 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 }) => { - await page.goto('./platform/'); - 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 doQuickLogin(page); + await page.goto(`${baseUrl}/home`); await page.getByRole('tab', { name: 'Parts' }).click(); - await page.goto('./platform/part/'); + 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.getByRole('tab', { name: 'Allocations' }).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 }) => { - await page.goto('./platform/'); - 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 doQuickLogin(page); + + await page.goto(`${baseUrl}/part/84/manufacturers`); - await page.goto('./platform/part/84/manufacturers'); await page.getByRole('tab', { name: 'Manufacturers' }).click(); await page.getByText('Hammond Manufacturing').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 }) => { - await page.goto('./platform/'); - 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 doQuickLogin(page); + + await page.goto(`${baseUrl}/part/15/suppliers`); - await page.goto('./platform/part/15/suppliers'); await page.getByRole('tab', { name: 'Suppliers' }).click(); await page.getByRole('cell', { name: 'DIG-84670-SJI' }).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 }) => { - await page.goto('./platform/'); - 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 doQuickLogin(page); + + await page.goto(`${baseUrl}/sales/`); - await page.goto('./platform/sales/'); await page.waitForURL('**/platform/sales/**'); await page.waitForURL('**/platform/sales/index/salesorders'); await page.getByRole('tab', { name: 'Return Orders' }).click(); @@ -131,13 +111,7 @@ test('PUI - Sales', async ({ page }) => { }); test('PUI - Scanning', async ({ page }) => { - await page.goto('./platform/'); - 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 doQuickLogin(page); await page.getByLabel('Homenav').click(); await page.getByRole('button', { name: 'System Information' }).click(); @@ -158,13 +132,8 @@ test('PUI - Scanning', async ({ page }) => { }); test('PUI - Admin', async ({ page }) => { - await page.goto('./platform/'); - await expect(page).toHaveTitle('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'); + // Note here we login with admin access + await doQuickLogin(page, 'admin', 'inventree'); // User settings await page.getByRole('button', { name: 'admin' }).click(); @@ -213,13 +182,7 @@ test('PUI - Admin', async ({ page }) => { }); test('PUI - Language / Color', async ({ page }) => { - await page.goto('./platform/'); - 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 doQuickLogin(page); await page.getByRole('button', { name: 'Ally Access' }).click(); await page.getByRole('menuitem', { name: 'Logout' }).click(); @@ -253,15 +216,9 @@ test('PUI - Language / Color', async ({ page }) => { }); test('PUI - Company', async ({ page }) => { - await page.goto('./platform/'); - 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 doQuickLogin(page); - await page.goto('./platform/company/1/details'); + await page.goto(`${baseUrl}/company/1/details`); await page .locator('div') .filter({ hasText: /^DigiKey Electronics$/ }) diff --git a/src/frontend/tests/pui_stock.spec.ts b/src/frontend/tests/pui_stock.spec.ts index b4f7e9ec14..2aea0c0516 100644 --- a/src/frontend/tests/pui_stock.spec.ts +++ b/src/frontend/tests/pui_stock.spec.ts @@ -1,16 +1,11 @@ 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 }) => { - await page.goto('./platform/'); - 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 doQuickLogin(page); - await page.goto('./platform/stock'); + await page.goto(`${baseUrl}/stock`); await page.waitForURL('**/platform/stock/location/index/details'); await page.getByRole('tab', { name: 'Stock Items' }).click(); await page.getByRole('cell', { name: '1551ABK' }).click(); @@ -24,13 +19,7 @@ test('PUI - Stock', async ({ page }) => { }); test('PUI - Build', async ({ page }) => { - await page.goto('./platform/'); - 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 doQuickLogin(page); await page.getByRole('tab', { name: 'Build' }).click(); await page.getByText('Widget Assembly Variant').click(); @@ -44,13 +33,7 @@ test('PUI - Build', async ({ page }) => { }); test('PUI - Purchasing', async ({ page }) => { - await page.goto('./platform/'); - 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 doQuickLogin(page); await page.getByRole('tab', { name: 'Purchasing' }).click(); await page.getByRole('cell', { name: 'PO0012' }).click();