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();