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

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

View File

@ -544,15 +544,6 @@ jobs:
- name: Report coverage
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()

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.

View File

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

View File

@ -1,12 +1,16 @@
"""InvenTree API version information."""
# InvenTree API version
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

View File

@ -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',

View File

@ -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/',

View File

@ -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')}

View File

@ -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.

View File

@ -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

View File

@ -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.withXSRFToken = true;
api.defaults.xsrfCookieName = 'csrftoken';
api.defaults.xsrfHeaderName = 'X-CSRFToken';
} else {
api.defaults.withCredentials = false;
api.defaults.xsrfCookieName = undefined;
api.defaults.xsrfHeaderName = undefined;
}
}
export const queryClient = new QueryClient();

View File

@ -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: <IconCheck size="1rem" />
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: <IconCheck size="1rem" />,
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: <IconCheck size="1rem" />
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
});
}
});

View File

@ -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<string>('');
const { host } = useLocalState.getState();
const [authorized, setAuthorized] = useState<boolean>(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 (
<Stack>
{image && image.length > 0 ? (
<Image {...props} src={image} withPlaceholder fit="contain" />
{imageUrl ? (
<Image {...props} src={imageUrl} withPlaceholder fit="contain" />
) : (
<Skeleton
height={props?.height ?? props.width}

View File

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

View File

@ -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 (
<Navigate to="/logged-in" state={{ redirectFrom: location.pathname }} />
);

View File

@ -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/',

View File

@ -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
navigate('/login');
return;
});
// Logout from this session
// Note that clearToken() then calls setApiDefaults()
await api.post(apiUrl(ApiEndpoints.user_logout)).finally(() => {
clearCsrfCookie();
useSessionState.getState().clearToken();
notifications.hide('login');
notifications.show({
id: 'login',
title: t`Logout successful`,
message: t`You have been logged out`,
color: 'green',
icon: <IconCheck size="1rem" />
});
navigate('/login');
showLoginNotification({
title: t`Logged Out`,
message: t`Successfully logged out`
});
});
};
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: <IconCheck size="1rem" />
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 =

View File

@ -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,

View File

@ -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 ? <IconCircleCheck /> : <IconExclamationCircle />,
id: 'login',
autoClose: 5000
});
}

View File

@ -2,7 +2,7 @@ import { Trans, t } from '@lingui/macro';
import { Center, Container, Paper, Text } from '@mantine/core';
import { 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

View File

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

View File

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

View File

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

View File

@ -5,9 +5,9 @@ import { create, createStore } from 'zustand';
import { api } from '../App';
import { 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<SettingsStateProps>(
lookup: {},
endpoint: ApiEndpoints.settings_global_list,
fetchSettings: async () => {
if (!useSessionState.getState().hasToken()) {
if (!isLoggedIn()) {
return;
}
@ -63,7 +63,7 @@ export const useUserSettingsState = create<SettingsStateProps>((set, get) => ({
lookup: {},
endpoint: ApiEndpoints.settings_user_list,
fetchSettings: async () => {
if (!useSessionState.getState().hasToken()) {
if (!isLoggedIn()) {
return;
}

View File

@ -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<ModelType | string, StatusCodeListInterface>;
@ -24,7 +24,7 @@ export const useGlobalStatusState = create<ServerStateProps>()(
setStatus: (newStatus: StatusLookup) => set({ status: newStatus }),
fetchStatus: async () => {
// Fetch status data for rendering labels
if (!useSessionState.getState().hasToken()) {
if (!isLoggedIn()) {
return;
}

View File

@ -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<UserStateProps>((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<UserStateProps>((set, get) => ({
};
set({ user: user });
})
.catch((_error) => {
.catch((error) => {
console.error('Error fetching user data');
});

View File

@ -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;
}

View File

@ -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 (
<BaseContext>

View File

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

View File

@ -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'
};

View File

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

View File

@ -1,28 +1,37 @@
import { expect, test } from './baseFixtures.js';
import { 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();
});

View File

@ -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$/ })

View File

@ -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$/ })

View File

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