diff --git a/.github/workflows/qc_checks.yaml b/.github/workflows/qc_checks.yaml index 693e0b7539a3..0f33bbbabede 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/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index c6edfc2d962e..b0fde97af8a2 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 bd60fdf598c7..a32a2fbd774d 100644 --- a/src/backend/InvenTree/InvenTree/settings.py +++ b/src/backend/InvenTree/InvenTree/settings.py @@ -492,10 +492,18 @@ '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 @@ ) INSTALLED_APPS.append('rest_framework_simplejwt') + # WSGI default setting WSGI_APPLICATION = 'InvenTree.wsgi.application' @@ -1092,6 +1101,13 @@ ) 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 c71f56720642..ab3dc008a25a 100644 --- a/src/backend/InvenTree/InvenTree/urls.py +++ b/src/backend/InvenTree/InvenTree/urls.py @@ -160,6 +160,7 @@ 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 17def22835e0..43fe8ad5217e 100644 --- a/src/backend/InvenTree/users/api.py +++ b/src/backend/InvenTree/users/api.py @@ -8,9 +8,11 @@ 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 e2ddcd34179d..fc4cc141ec1e 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/frontend/src/App.tsx b/src/frontend/src/App.tsx index 0ec3e8576c56..fff83a6af88a 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 31c2d7c28b8c..c97d24bd29f3 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 d8f457b427f9..ce123f2ed335 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 91a6cd00c68e..c9a4c436b3c1 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 821bbd973e67..dd5fab30dacc 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 d0f010e9c262..c1d7dbd7acdd 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(); - - // Request new token from the server - await axios - .get(apiUrl(ApiEndpoints.user_token), { - auth: { username, password }, - baseURL: host, - timeout: 2000, - params: { - name: tokenName + clearCsrfCookie(); + + 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; - }); - // 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: + showLoginNotification({ + title: t`Logged Out`, + message: t`Successfully logged out` + }); }); - - 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 6d8b0bed1d2e..b362d08ce54e 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 9682e8738c2b..0306d1d92c7d 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/Logout.tsx b/src/frontend/src/pages/Auth/Logout.tsx new file mode 100644 index 000000000000..25dfd04c4e1f --- /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 c376dab343c9..a80a0fdce1e4 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 5ac12407d725..000000000000 --- 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 61aec8ff08ae..85fdba8ab336 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 51b31f851d58..57e845f33a6c 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 621a5a5c82a4..35d8a82979bd 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 fba98fc4c603..0ec0139a1405 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 a48445272d1a..1ee5f84f2e9b 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 f33ee66b0e22..66f705f25df3 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 5f6bf71a5a5a..3ceaa5b9fadb 100644 --- a/src/frontend/tests/defaults.ts +++ b/src/frontend/tests/defaults.ts @@ -1,6 +1,12 @@ 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' }; diff --git a/src/frontend/tests/login.ts b/src/frontend/tests/login.ts new file mode 100644 index 000000000000..a8165c4f6165 --- /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 9d2eb43cd109..eadb2187b469 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'; - -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/`); - - await expect(page).toHaveTitle('InvenTree Demo Server'); +import { baseUrl, loginUrl, logoutUrl, user } from './defaults.js'; +import { doLogin, doQuickLogin } from './login.js'; + +test('PUI - Basic Login Test', async ({ page }) => { + await doLogin(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(); }); -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(); +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.goto('./platform/'); - await expect(page).toHaveTitle('InvenTree'); + 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 520542d99db5..f722d04bbaed 100644 --- a/src/frontend/tests/pui_command.spec.ts +++ b/src/frontend/tests/pui_command.spec.ts @@ -1,26 +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/login/?login=${user.username}&password=${user.password}` - ); - 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$/ }) @@ -44,15 +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/login/?login=${user.username}&password=${user.password}` - ); - await page.waitForURL('**/platform/*'); - await page.goto('./platform/'); - - // wait for the page to load - await page.waitForTimeout(200); +test('PUI - Quick Command - No Keys', async ({ page }) => { + await doQuickLogin(page); // Open Spotlight with Button await page.getByRole('button', { name: 'Open spotlight' }).click(); @@ -118,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 55896a11c3e4..3dead1ce790b 100644 --- a/src/frontend/tests/pui_general.spec.ts +++ b/src/frontend/tests/pui_general.spec.ts @@ -1,17 +1,15 @@ -import { test } from './baseFixtures.js'; -import { adminuser, user } from './defaults.js'; +import { expect, test } from './baseFixtures.js'; +import { baseUrl } from './defaults.js'; +import { doQuickLogin } from './login.js'; test('PUI - Parts', async ({ page }) => { - await page.goto( - `./platform/login/?login=${user.username}&password=${user.password}` - ); - 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(); @@ -36,12 +34,10 @@ test('PUI - Parts', async ({ page }) => { }); test('PUI - Parts - Manufacturer Parts', async ({ page }) => { - await page.goto( - `./platform/login/?login=${user.username}&password=${user.password}` - ); - 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(); @@ -51,12 +47,10 @@ test('PUI - Parts - Manufacturer Parts', async ({ page }) => { }); test('PUI - Parts - Supplier Parts', async ({ page }) => { - await page.goto( - `./platform/login/?login=${user.username}&password=${user.password}` - ); - 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(); // @@ -66,12 +60,10 @@ test('PUI - Parts - Supplier Parts', async ({ page }) => { }); test('PUI - Sales', async ({ page }) => { - await page.goto( - `./platform/login/?login=${user.username}&password=${user.password}` - ); - 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(); @@ -119,11 +111,7 @@ test('PUI - Sales', async ({ page }) => { }); test('PUI - Scanning', async ({ page }) => { - await page.goto( - `./platform/login/?login=${user.username}&password=${user.password}` - ); - await page.waitForURL('**/platform/*'); - await page.goto('./platform/'); + await doQuickLogin(page); await page.getByLabel('Homenav').click(); await page.getByRole('button', { name: 'System Information' }).click(); @@ -144,11 +132,8 @@ test('PUI - Scanning', async ({ page }) => { }); test('PUI - Admin', async ({ page }) => { - await page.goto( - `./platform/login/?login=${adminuser.username}&password=${adminuser.password}` - ); - await page.waitForURL('**/platform/*'); - await page.goto('./platform/'); + // Note here we login with admin access + await doQuickLogin(page, 'admin', 'inventree'); // User settings await page.getByRole('button', { name: 'admin' }).click(); @@ -197,11 +182,7 @@ test('PUI - Admin', async ({ page }) => { }); test('PUI - Language / Color', async ({ page }) => { - await page.goto( - `./platform/login/?login=${user.username}&password=${user.password}` - ); - await page.waitForURL('**/platform/*'); - await page.goto('./platform/'); + await doQuickLogin(page); await page.getByRole('button', { name: 'Ally Access' }).click(); await page.getByRole('menuitem', { name: 'Logout' }).click(); @@ -235,12 +216,9 @@ test('PUI - Language / Color', async ({ page }) => { }); test('PUI - Company', async ({ page }) => { - await page.goto( - `./platform/login/?login=${user.username}&password=${user.password}` - ); - 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 dd70f442c40a..2aea0c0516f8 100644 --- a/src/frontend/tests/pui_stock.spec.ts +++ b/src/frontend/tests/pui_stock.spec.ts @@ -1,13 +1,11 @@ -import { test } from './baseFixtures.js'; -import { user } from './defaults.js'; +import { expect, test } from './baseFixtures.js'; +import { baseUrl, user } from './defaults.js'; +import { doQuickLogin } from './login.js'; test('PUI - Stock', async ({ page }) => { - await page.goto( - `./platform/login/?login=${user.username}&password=${user.password}` - ); - 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(); @@ -21,11 +19,7 @@ test('PUI - Stock', async ({ page }) => { }); test('PUI - Build', async ({ page }) => { - await page.goto( - `./platform/login/?login=${user.username}&password=${user.password}` - ); - await page.waitForURL('**/platform/*'); - await page.goto('./platform/'); + await doQuickLogin(page); await page.getByRole('tab', { name: 'Build' }).click(); await page.getByText('Widget Assembly Variant').click(); @@ -39,11 +33,7 @@ test('PUI - Build', async ({ page }) => { }); test('PUI - Purchasing', async ({ page }) => { - await page.goto( - `./platform/login/?login=${user.username}&password=${user.password}` - ); - await page.waitForURL('**/platform/*'); - await page.goto('./platform/'); + await doQuickLogin(page); await page.getByRole('tab', { name: 'Purchasing' }).click(); await page.getByRole('cell', { name: 'PO0012' }).click();