mirror of
https://github.com/inventree/InvenTree.git
synced 2025-08-09 21:30:54 +00:00
Merge branch 'master' of https://github.com/inventree/InvenTree into pui-maintine-v7
This commit is contained in:
@@ -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
|
||||
|
@@ -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',
|
||||
|
@@ -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/',
|
||||
|
@@ -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')}
|
||||
|
@@ -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.
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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();
|
||||
|
9
src/frontend/src/components/charts/tooltipFormatter.tsx
Normal file
9
src/frontend/src/components/charts/tooltipFormatter.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { formatCurrency } from '../../defaults/formatters';
|
||||
|
||||
export function tooltipFormatter(label: any, currency: string) {
|
||||
return (
|
||||
formatCurrency(label, {
|
||||
currency: currency
|
||||
})?.toString() ?? ''
|
||||
);
|
||||
}
|
@@ -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
|
||||
});
|
||||
}
|
||||
});
|
||||
|
@@ -1,15 +1,12 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
interface ApiImageProps extends ImageProps {
|
||||
onClick?: (event: any) => void;
|
||||
@@ -19,61 +16,20 @@ interface ApiImageProps extends ImageProps {
|
||||
* Construct an image container which will load and display the image
|
||||
*/
|
||||
export function ApiImage(props: ApiImageProps) {
|
||||
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 ? (
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
{...props}
|
||||
src={image}
|
||||
//fallbackSrc="https://placehold.co/600x400?text=Placeholder"
|
||||
src={imageUrl}
|
||||
fit="contain"
|
||||
//fallbackSrc="https://placehold.co/600x400?text=Placeholder"
|
||||
/>
|
||||
) : (
|
||||
<Skeleton h={props?.h ?? props.w} w={props?.w ?? props.h} />
|
||||
|
@@ -9,7 +9,9 @@ import {
|
||||
IconLink,
|
||||
IconPhoto
|
||||
} 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
|
||||
@@ -59,10 +61,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 justify="left" gap="sm">
|
||||
{external ? <IconLink /> : attachmentIcon(attachment)}
|
||||
<Anchor href={attachment} target="_blank" rel="noopener noreferrer">
|
||||
<Anchor href={url} target="_blank" rel="noopener noreferrer">
|
||||
{text}
|
||||
</Anchor>
|
||||
</Group>
|
||||
|
@@ -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 * as classes from '../../main.css';
|
||||
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 }} />
|
||||
);
|
||||
|
@@ -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/',
|
||||
|
||||
|
@@ -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 =
|
||||
|
@@ -9,6 +9,7 @@ import {
|
||||
IconBuilding,
|
||||
IconBuildingFactory2,
|
||||
IconBuildingStore,
|
||||
IconBusinessplan,
|
||||
IconCalendar,
|
||||
IconCalendarStats,
|
||||
IconCalendarTime,
|
||||
@@ -99,6 +100,7 @@ const icons = {
|
||||
info: IconInfoCircle,
|
||||
details: IconInfoCircle,
|
||||
parameters: IconList,
|
||||
list: IconList,
|
||||
stock: IconPackages,
|
||||
variants: IconVersions,
|
||||
allocations: IconBookmarks,
|
||||
@@ -170,6 +172,7 @@ const icons = {
|
||||
customer: IconUser,
|
||||
quantity: IconNumbers,
|
||||
progress: IconProgressCheck,
|
||||
total_cost: IconBusinessplan,
|
||||
reference: IconHash,
|
||||
serial: IconHash,
|
||||
website: IconWorld,
|
||||
|
@@ -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
|
||||
});
|
||||
}
|
||||
|
@@ -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 | null): void {
|
||||
@@ -49,6 +50,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
|
||||
|
34
src/frontend/src/pages/Auth/Logout.tsx
Normal file
34
src/frontend/src/pages/Auth/Logout.tsx
Normal 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 justify="center">
|
||||
<Loader />
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
@@ -1,6 +1,6 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Accordion, Alert, LoadingOverlay, Stack, Text } from '@mantine/core';
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
@@ -15,6 +15,19 @@ import SaleHistoryPanel from './pricing/SaleHistoryPanel';
|
||||
import SupplierPricingPanel from './pricing/SupplierPricingPanel';
|
||||
import VariantPricingPanel from './pricing/VariantPricingPanel';
|
||||
|
||||
export enum panelOptions {
|
||||
overview = 'overview',
|
||||
purchase = 'purchase',
|
||||
internal = 'internal',
|
||||
supplier = 'supplier',
|
||||
bom = 'bom',
|
||||
variant = 'variant',
|
||||
sale_pricing = 'sale-pricing',
|
||||
sale_history = 'sale-history',
|
||||
override = 'override',
|
||||
overall = 'overall'
|
||||
}
|
||||
|
||||
export default function PartPricingPanel({ part }: { part: any }) {
|
||||
const user = useUserState();
|
||||
|
||||
@@ -40,6 +53,17 @@ export default function PartPricingPanel({ part }: { part: any }) {
|
||||
return user.hasViewRole(UserRoles.sales_order) && part?.salable;
|
||||
}, [user, part]);
|
||||
|
||||
const [value, setValue] = useState<string[]>([panelOptions.overview]);
|
||||
function doNavigation(panel: panelOptions) {
|
||||
if (!value.includes(panel)) {
|
||||
setValue([...value, panel]);
|
||||
}
|
||||
const element = document.getElementById(panel);
|
||||
if (element) {
|
||||
element.scrollIntoView();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<LoadingOverlay visible={instanceQuery.isLoading} />
|
||||
@@ -49,18 +73,27 @@ export default function PartPricingPanel({ part }: { part: any }) {
|
||||
</Alert>
|
||||
)}
|
||||
{pricing && (
|
||||
<Accordion multiple defaultValue={['overview']}>
|
||||
<Accordion multiple value={value} onChange={setValue}>
|
||||
<PricingPanel
|
||||
content={<PricingOverviewPanel part={part} pricing={pricing} />}
|
||||
label="overview"
|
||||
content={
|
||||
<PricingOverviewPanel
|
||||
part={part}
|
||||
pricing={pricing}
|
||||
doNavigation={doNavigation}
|
||||
/>
|
||||
}
|
||||
label={panelOptions.overview}
|
||||
title={t`Pricing Overview`}
|
||||
visible={true}
|
||||
/>
|
||||
<PricingPanel
|
||||
content={<PurchaseHistoryPanel part={part} />}
|
||||
label="purchase"
|
||||
label={panelOptions.purchase}
|
||||
title={t`Purchase History`}
|
||||
visible={purchaseOrderPricing}
|
||||
disabled={
|
||||
!pricing?.purchase_cost_min || !pricing?.purchase_cost_max
|
||||
}
|
||||
/>
|
||||
<PricingPanel
|
||||
content={
|
||||
@@ -69,27 +102,35 @@ export default function PartPricingPanel({ part }: { part: any }) {
|
||||
endpoint={ApiEndpoints.part_pricing_internal}
|
||||
/>
|
||||
}
|
||||
label="internal"
|
||||
label={panelOptions.internal}
|
||||
title={t`Internal Pricing`}
|
||||
visible={internalPricing}
|
||||
disabled={
|
||||
!pricing?.internal_cost_min || !pricing?.internal_cost_max
|
||||
}
|
||||
/>
|
||||
<PricingPanel
|
||||
content={<SupplierPricingPanel part={part} />}
|
||||
label="supplier"
|
||||
label={panelOptions.supplier}
|
||||
title={t`Supplier Pricing`}
|
||||
visible={purchaseOrderPricing}
|
||||
disabled={
|
||||
!pricing?.supplier_price_min || !pricing?.supplier_price_max
|
||||
}
|
||||
/>
|
||||
<PricingPanel
|
||||
content={<BomPricingPanel part={part} pricing={pricing} />}
|
||||
label="bom"
|
||||
label={panelOptions.bom}
|
||||
title={t`BOM Pricing`}
|
||||
visible={part?.assembly}
|
||||
disabled={!pricing?.bom_cost_min || !pricing?.bom_cost_max}
|
||||
/>
|
||||
<PricingPanel
|
||||
content={<VariantPricingPanel part={part} pricing={pricing} />}
|
||||
label="variant"
|
||||
label={panelOptions.variant}
|
||||
title={t`Variant Pricing`}
|
||||
visible={part?.is_template}
|
||||
disabled={!pricing?.variant_cost_min || !pricing?.variant_cost_max}
|
||||
/>
|
||||
<PricingPanel
|
||||
content={
|
||||
@@ -98,15 +139,17 @@ export default function PartPricingPanel({ part }: { part: any }) {
|
||||
endpoint={ApiEndpoints.part_pricing_sale}
|
||||
/>
|
||||
}
|
||||
label="sale-pricing"
|
||||
label={panelOptions.sale_pricing}
|
||||
title={t`Sale Pricing`}
|
||||
visible={salesOrderPricing}
|
||||
disabled={!pricing?.sale_price_min || !pricing?.sale_price_max}
|
||||
/>
|
||||
<PricingPanel
|
||||
content={<SaleHistoryPanel part={part} />}
|
||||
label="sale-history"
|
||||
label={panelOptions.sale_history}
|
||||
title={t`Sale History`}
|
||||
visible={salesOrderPricing}
|
||||
disabled={!pricing?.sale_history_min || !pricing?.sale_history_max}
|
||||
/>
|
||||
</Accordion>
|
||||
)}
|
||||
|
@@ -21,7 +21,12 @@ import {
|
||||
} from 'recharts';
|
||||
|
||||
import { CHART_COLORS } from '../../../components/charts/colors';
|
||||
import { formatDecimal, formatPriceRange } from '../../../defaults/formatters';
|
||||
import { tooltipFormatter } from '../../../components/charts/tooltipFormatter';
|
||||
import {
|
||||
formatCurrency,
|
||||
formatDecimal,
|
||||
formatPriceRange
|
||||
} from '../../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../../enums/ModelType';
|
||||
import { useTable } from '../../../hooks/UseTable';
|
||||
@@ -32,7 +37,7 @@ import { InvenTreeTable } from '../../../tables/InvenTreeTable';
|
||||
import { NoPricingData } from './PricingPanel';
|
||||
|
||||
// Display BOM data as a pie chart
|
||||
function BomPieChart({ data }: { data: any[] }) {
|
||||
function BomPieChart({ data, currency }: { data: any[]; currency: string }) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={500}>
|
||||
<PieChart>
|
||||
@@ -64,20 +69,30 @@ function BomPieChart({ data }: { data: any[] }) {
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Tooltip
|
||||
formatter={(label, payload) => tooltipFormatter(label, currency)}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
// Display BOM data as a bar chart
|
||||
function BomBarChart({ data }: { data: any[] }) {
|
||||
function BomBarChart({ data, currency }: { data: any[]; currency: string }) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={500}>
|
||||
<BarChart data={data}>
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<YAxis
|
||||
tickFormatter={(value, index) =>
|
||||
formatCurrency(value, {
|
||||
currency: currency
|
||||
})?.toString() ?? ''
|
||||
}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(label, payload) => tooltipFormatter(label, currency)}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="total_price_min"
|
||||
@@ -202,8 +217,12 @@ export default function BomPricingPanel({
|
||||
/>
|
||||
{bomPricingData.length > 0 ? (
|
||||
<Stack gap="xs">
|
||||
{chartType == 'bar' && <BomBarChart data={bomPricingData} />}
|
||||
{chartType == 'pie' && <BomPieChart data={bomPricingData} />}
|
||||
{chartType == 'bar' && (
|
||||
<BomBarChart data={bomPricingData} currency={pricing?.currency} />
|
||||
)}
|
||||
{chartType == 'pie' && (
|
||||
<BomPieChart data={bomPricingData} currency={pricing?.currency} />
|
||||
)}
|
||||
<SegmentedControl
|
||||
value={chartType}
|
||||
onChange={setChartType}
|
||||
|
@@ -13,6 +13,7 @@ import {
|
||||
|
||||
import { AddItemButton } from '../../../components/buttons/AddItemButton';
|
||||
import { CHART_COLORS } from '../../../components/charts/colors';
|
||||
import { tooltipFormatter } from '../../../components/charts/tooltipFormatter';
|
||||
import { ApiFormFieldSet } from '../../../components/forms/fields/ApiFormField';
|
||||
import { formatCurrency } from '../../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../../enums/ApiEndpoints';
|
||||
@@ -144,6 +145,13 @@ export default function PriceBreakPanel({
|
||||
[user]
|
||||
);
|
||||
|
||||
const currency: string = useMemo(() => {
|
||||
if (table.records.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return table.records[0].currency;
|
||||
}, [table.records]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{newPriceBreak.modal}
|
||||
@@ -166,8 +174,18 @@ export default function PriceBreakPanel({
|
||||
<ResponsiveContainer width="100%" height={500}>
|
||||
<BarChart data={table.records}>
|
||||
<XAxis dataKey="quantity" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<YAxis
|
||||
tickFormatter={(value, index) =>
|
||||
formatCurrency(value, {
|
||||
currency: currency
|
||||
})?.toString() ?? ''
|
||||
}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(label, payload) =>
|
||||
tooltipFormatter(label, currency)
|
||||
}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="price"
|
||||
|
@@ -1,5 +1,13 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Alert, Group, Paper, SimpleGrid, Stack, Text } from '@mantine/core';
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBuildingWarehouse,
|
||||
IconChartDonut,
|
||||
@@ -22,11 +30,13 @@ import {
|
||||
} from 'recharts';
|
||||
|
||||
import { CHART_COLORS } from '../../../components/charts/colors';
|
||||
import { tooltipFormatter } from '../../../components/charts/tooltipFormatter';
|
||||
import { formatCurrency, renderDate } from '../../../defaults/formatters';
|
||||
import { panelOptions } from '../PartPricingPanel';
|
||||
|
||||
interface PricingOverviewEntry {
|
||||
icon: ReactNode;
|
||||
name: string;
|
||||
name: panelOptions;
|
||||
title: string;
|
||||
min_value: number | null | undefined;
|
||||
max_value: number | null | undefined;
|
||||
@@ -36,10 +46,12 @@ interface PricingOverviewEntry {
|
||||
|
||||
export default function PricingOverviewPanel({
|
||||
part,
|
||||
pricing
|
||||
pricing,
|
||||
doNavigation
|
||||
}: {
|
||||
part: any;
|
||||
pricing: any;
|
||||
doNavigation: (panel: panelOptions) => void;
|
||||
}): ReactNode {
|
||||
const columns: any[] = useMemo(() => {
|
||||
return [
|
||||
@@ -47,10 +59,17 @@ export default function PricingOverviewPanel({
|
||||
accessor: 'title',
|
||||
title: t`Pricing Category`,
|
||||
render: (record: PricingOverviewEntry) => {
|
||||
const is_link = record.name !== panelOptions.overall;
|
||||
return (
|
||||
<Group justify="left" gap="xs">
|
||||
{record.icon}
|
||||
<Text fw={700}>{record.title}</Text>
|
||||
{is_link ? (
|
||||
<Anchor fw={700} onClick={() => doNavigation(record.name)}>
|
||||
{record.title}
|
||||
</Anchor>
|
||||
) : (
|
||||
<Text fw={700}>{record.title}</Text>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -86,56 +105,70 @@ export default function PricingOverviewPanel({
|
||||
const overviewData: PricingOverviewEntry[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
name: 'internal',
|
||||
name: panelOptions.internal,
|
||||
title: t`Internal Pricing`,
|
||||
icon: <IconList />,
|
||||
min_value: pricing?.internal_cost_min,
|
||||
max_value: pricing?.internal_cost_max
|
||||
},
|
||||
{
|
||||
name: 'bom',
|
||||
name: panelOptions.bom,
|
||||
title: t`BOM Pricing`,
|
||||
icon: <IconChartDonut />,
|
||||
min_value: pricing?.bom_cost_min,
|
||||
max_value: pricing?.bom_cost_max
|
||||
},
|
||||
{
|
||||
name: 'purchase',
|
||||
name: panelOptions.purchase,
|
||||
title: t`Purchase Pricing`,
|
||||
icon: <IconShoppingCart />,
|
||||
min_value: pricing?.purchase_cost_min,
|
||||
max_value: pricing?.purchase_cost_max
|
||||
},
|
||||
{
|
||||
name: 'supplier',
|
||||
name: panelOptions.supplier,
|
||||
title: t`Supplier Pricing`,
|
||||
icon: <IconBuildingWarehouse />,
|
||||
min_value: pricing?.supplier_price_min,
|
||||
max_value: pricing?.supplier_price_max
|
||||
},
|
||||
{
|
||||
name: 'variants',
|
||||
name: panelOptions.variant,
|
||||
title: t`Variant Pricing`,
|
||||
icon: <IconTriangleSquareCircle />,
|
||||
min_value: pricing?.variant_cost_min,
|
||||
max_value: pricing?.variant_cost_max
|
||||
},
|
||||
{
|
||||
name: 'override',
|
||||
name: panelOptions.sale_pricing,
|
||||
title: t`Sale Pricing`,
|
||||
icon: <IconTriangleSquareCircle />,
|
||||
min_value: pricing?.sale_price_min,
|
||||
max_value: pricing?.sale_price_max
|
||||
},
|
||||
{
|
||||
name: panelOptions.sale_history,
|
||||
title: t`Sale History`,
|
||||
icon: <IconTriangleSquareCircle />,
|
||||
min_value: pricing?.sale_history_min,
|
||||
max_value: pricing?.sale_history_max
|
||||
},
|
||||
{
|
||||
name: panelOptions.override,
|
||||
title: t`Override Pricing`,
|
||||
icon: <IconExclamationCircle />,
|
||||
min_value: pricing?.override_min,
|
||||
max_value: pricing?.override_max
|
||||
},
|
||||
{
|
||||
name: 'overall',
|
||||
name: panelOptions.overall,
|
||||
title: t`Overall Pricing`,
|
||||
icon: <IconReportAnalytics />,
|
||||
min_value: pricing?.overall_min,
|
||||
max_value: pricing?.overall_max
|
||||
}
|
||||
].filter((entry) => {
|
||||
return entry.min_value !== null || entry.max_value !== null;
|
||||
return !(entry.min_value == null || entry.max_value == null);
|
||||
});
|
||||
}, [part, pricing]);
|
||||
|
||||
@@ -158,8 +191,18 @@ export default function PricingOverviewPanel({
|
||||
<ResponsiveContainer width="100%" height={500}>
|
||||
<BarChart data={overviewData}>
|
||||
<XAxis dataKey="title" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<YAxis
|
||||
tickFormatter={(value, index) =>
|
||||
formatCurrency(value, {
|
||||
currency: pricing?.currency
|
||||
})?.toString() ?? ''
|
||||
}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(label, payload) =>
|
||||
tooltipFormatter(label, pricing?.currency)
|
||||
}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="min_value"
|
||||
|
@@ -1,28 +1,58 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Accordion, Alert, Space, Stack, Text } from '@mantine/core';
|
||||
import { IconExclamationCircle } from '@tabler/icons-react';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionControlProps,
|
||||
Alert,
|
||||
Box,
|
||||
Space,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip
|
||||
} from '@mantine/core';
|
||||
import { IconAlertCircle, IconExclamationCircle } from '@tabler/icons-react';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { StylishText } from '../../../components/items/StylishText';
|
||||
import { panelOptions } from '../PartPricingPanel';
|
||||
|
||||
function AccordionControl(props: AccordionControlProps) {
|
||||
return (
|
||||
<Box style={{ display: 'flex', alignItems: 'center' }}>
|
||||
{props.disabled && (
|
||||
<Tooltip
|
||||
label={t`No data available`}
|
||||
children={<IconAlertCircle size="1rem" color="gray" />}
|
||||
/>
|
||||
)}
|
||||
<Accordion.Control
|
||||
{...props}
|
||||
pl={props.disabled ? '0.25rem' : '1.25rem'}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PricingPanel({
|
||||
content,
|
||||
label,
|
||||
title,
|
||||
visible
|
||||
visible,
|
||||
disabled = undefined
|
||||
}: {
|
||||
content: ReactNode;
|
||||
label: string;
|
||||
label: panelOptions;
|
||||
title: string;
|
||||
visible: boolean;
|
||||
disabled?: boolean | undefined;
|
||||
}): ReactNode {
|
||||
const is_disabled = disabled === undefined ? false : disabled;
|
||||
return (
|
||||
visible && (
|
||||
<Accordion.Item value={label}>
|
||||
<Accordion.Control>
|
||||
<Accordion.Item value={label} id={label}>
|
||||
<AccordionControl disabled={is_disabled}>
|
||||
<StylishText size="lg">{title}</StylishText>
|
||||
</Accordion.Control>
|
||||
<Accordion.Panel>{content}</Accordion.Panel>
|
||||
</AccordionControl>
|
||||
<Accordion.Panel>{!is_disabled && content}</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
)
|
||||
);
|
||||
|
@@ -12,6 +12,7 @@ import {
|
||||
} from 'recharts';
|
||||
|
||||
import { CHART_COLORS } from '../../../components/charts/colors';
|
||||
import { tooltipFormatter } from '../../../components/charts/tooltipFormatter';
|
||||
import { formatCurrency, renderDate } from '../../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../../enums/ApiEndpoints';
|
||||
import { useTable } from '../../../hooks/UseTable';
|
||||
@@ -95,6 +96,13 @@ export default function PurchaseHistoryPanel({
|
||||
];
|
||||
}, []);
|
||||
|
||||
const currency: string = useMemo(() => {
|
||||
if (table.records.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return table.records[0].purchase_price_currency;
|
||||
}, [table.records]);
|
||||
|
||||
const purchaseHistoryData = useMemo(() => {
|
||||
return table.records.map((record: any) => {
|
||||
return {
|
||||
@@ -126,8 +134,16 @@ export default function PurchaseHistoryPanel({
|
||||
<ResponsiveContainer width="100%" height={500}>
|
||||
<BarChart data={purchaseHistoryData}>
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<YAxis
|
||||
tickFormatter={(value, index) =>
|
||||
formatCurrency(value, {
|
||||
currency: currency
|
||||
})?.toString() ?? ''
|
||||
}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(label, payload) => tooltipFormatter(label, currency)}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="unit_price"
|
||||
|
@@ -12,6 +12,7 @@ import {
|
||||
} from 'recharts';
|
||||
|
||||
import { CHART_COLORS } from '../../../components/charts/colors';
|
||||
import { tooltipFormatter } from '../../../components/charts/tooltipFormatter';
|
||||
import { formatCurrency, renderDate } from '../../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../../enums/ApiEndpoints';
|
||||
import { useTable } from '../../../hooks/UseTable';
|
||||
@@ -60,6 +61,13 @@ export default function SaleHistoryPanel({ part }: { part: any }): ReactNode {
|
||||
];
|
||||
}, []);
|
||||
|
||||
const currency: string = useMemo(() => {
|
||||
if (table.records.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return table.records[0].sale_price_currency;
|
||||
}, [table.records]);
|
||||
|
||||
const saleHistoryData = useMemo(() => {
|
||||
return table.records.map((record: any) => {
|
||||
return {
|
||||
@@ -90,8 +98,16 @@ export default function SaleHistoryPanel({ part }: { part: any }): ReactNode {
|
||||
<ResponsiveContainer width="100%" height={500}>
|
||||
<BarChart data={saleHistoryData}>
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<YAxis
|
||||
tickFormatter={(value, index) =>
|
||||
formatCurrency(value, {
|
||||
currency: currency
|
||||
})?.toString() ?? ''
|
||||
}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(label, payload) => tooltipFormatter(label, currency)}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="sale_price"
|
||||
|
@@ -11,6 +11,8 @@ import {
|
||||
} from 'recharts';
|
||||
|
||||
import { CHART_COLORS } from '../../../components/charts/colors';
|
||||
import { tooltipFormatter } from '../../../components/charts/tooltipFormatter';
|
||||
import { formatCurrency } from '../../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../../enums/ApiEndpoints';
|
||||
import { useTable } from '../../../hooks/UseTable';
|
||||
import { apiUrl } from '../../../states/ApiState';
|
||||
@@ -29,6 +31,13 @@ export default function SupplierPricingPanel({ part }: { part: any }) {
|
||||
return SupplierPriceBreakColumns();
|
||||
}, []);
|
||||
|
||||
const currency: string = useMemo(() => {
|
||||
if (table.records.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return table.records[0].currency;
|
||||
}, [table.records]);
|
||||
|
||||
const supplierPricingData = useMemo(() => {
|
||||
return table.records.map((record: any) => {
|
||||
return {
|
||||
@@ -58,8 +67,16 @@ export default function SupplierPricingPanel({ part }: { part: any }) {
|
||||
<ResponsiveContainer width="100%" height={500}>
|
||||
<BarChart data={supplierPricingData}>
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<YAxis
|
||||
tickFormatter={(value, index) =>
|
||||
formatCurrency(value, {
|
||||
currency: currency
|
||||
})?.toString() ?? ''
|
||||
}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(label, payload) => tooltipFormatter(label, currency)}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="unit_price"
|
||||
fill={CHART_COLORS[0]}
|
||||
|
@@ -12,6 +12,7 @@ import {
|
||||
} from 'recharts';
|
||||
|
||||
import { CHART_COLORS } from '../../../components/charts/colors';
|
||||
import { tooltipFormatter } from '../../../components/charts/tooltipFormatter';
|
||||
import { formatCurrency } from '../../../defaults/formatters';
|
||||
import { ApiEndpoints } from '../../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../../enums/ModelType';
|
||||
@@ -99,8 +100,18 @@ export default function VariantPricingPanel({
|
||||
<ResponsiveContainer width="100%" height={500}>
|
||||
<BarChart data={variantPricingData}>
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<YAxis
|
||||
tickFormatter={(value, index) =>
|
||||
formatCurrency(value, {
|
||||
currency: pricing?.currency
|
||||
})?.toString() ?? ''
|
||||
}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(label, payload) =>
|
||||
tooltipFormatter(label, pricing?.currency)
|
||||
}
|
||||
/>
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="pmin"
|
||||
|
@@ -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 />} />
|
||||
|
@@ -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)
|
||||
}
|
||||
)
|
||||
);
|
@@ -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;
|
||||
}
|
||||
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
|
@@ -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');
|
||||
});
|
||||
|
||||
|
@@ -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;
|
||||
}
|
||||
|
||||
|
@@ -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>
|
||||
|
@@ -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);
|
||||
|
@@ -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'
|
||||
};
|
||||
|
37
src/frontend/tests/login.ts
Normal file
37
src/frontend/tests/login.ts
Normal 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);
|
||||
};
|
@@ -1,28 +1,37 @@
|
||||
import { expect, test } from './baseFixtures.js';
|
||||
import { classicUrl, user } from './defaults.js';
|
||||
import { baseUrl, 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();
|
||||
});
|
||||
|
@@ -1,28 +1,16 @@
|
||||
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: 'Go to the InvenTree dashboard' })
|
||||
.click()
|
||||
.click();
|
||||
await page.locator('p').filter({ hasText: 'Dashboard' }).waitFor();
|
||||
await page.waitForURL('**/platform/dashboard');
|
||||
@@ -44,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();
|
||||
@@ -118,7 +95,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('p').filter({ hasText: 'Playground' }).waitFor();
|
||||
await page.getByRole('button', { name: 'Spotlight actions' }).click();
|
||||
await page.getByRole('button', { name: 'Register extra actions' }).click();
|
||||
|
@@ -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.getByLabel('Details').getByText('DigiKey Electronics').waitFor();
|
||||
await page.getByRole('cell', { name: 'https://www.digikey.com/' }).waitFor();
|
||||
await page.getByRole('tab', { name: 'Supplied Parts' }).click();
|
||||
|
@@ -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();
|
||||
|
Reference in New Issue
Block a user