[PUI] Session authentication (#6970)

* Adjust backend cookie settings

* Allow CORS requests to /accounts/

* Refactor frontend code

- Remove API token functions
- Simplify cookie approach
- Add isLoggedIn method

* Adjust REST_AUTH settings

* Cleanup auth functions in auth.tsx

* Adjust CSRF_COOKIE_SAMESITE value

* Fix login request

* Prevent session auth on login view

- Existing (invalid) session token causes 403

* Refactor ApiImage

- Point to the right host
- Simplify code
- Now we use session cookies, so it *Just Works*

* Fix download for attachment table

- Now works with remote host

* Cleanup settings.py

* Refactor login / logout notifications

* Update API version

* Update src/frontend/src/components/items/AttachmentLink.tsx

Co-authored-by: Lukas <76838159+wolflu05@users.noreply.github.com>

* fix assert url

* Remove comment

* Add explicit page to logout user

* Change tests to first logout

* Prune dead code

* Adjust tests

* Cleanup

* Direct to login view

* Trying something

* Update CUI test

* Fix basic tests

* Refactoring

* Fix basic checks

* Fix for PUI command tests

* More test updates

* Add speciifc test for quick login

* More cleanup of playwright tests

* Add some missing icons

* Fix typo

* Ignore coverage report for playwright test

* Remove coveralls upload task

---------

Co-authored-by: Lukas <76838159+wolflu05@users.noreply.github.com>
Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Oliver 2024-04-17 21:35:20 +10:00 committed by GitHub
parent d24219fec3
commit 0ba7f7ece5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 341 additions and 359 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

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

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

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

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

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

View File

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

View File

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