[PUI] Login / Logout State Fixes (#6368)

* Fix API endpoint URLs

* Adds "authenticated" field to root API endpoint

* Load global status data separately

- Create new global state manager
- Load *after* login
- Prevents auth popup dialog and failure messages

* Add launch config for frontend dev

* Update docs

* Clear token auth if no token is defined

* remove unneeded import

* Revert format of InfoView endpoint

* Remove "authenticated" from InfoView

* Refactor is_staff token check

- Using new get_token_from_request method

* Cleanup code

- return early

* URL fixes

- More fixes for incorrect api calls

* Better tracking of authenticated status

- track an internal flag in apiState

* Prioritize token auth

* Only fetch userState if authenticated

* Force unauthenticated state on first launch

* Updates to login procedure

- Rename doClassicLogin to doBasicLogin (reflecting "basic" auth)
- Add "loggedIn" attribute to sessionState
- Cleanup procedure for securing a token

* Abort early on checkLoginState

- Prevent failed calls to user_me

* Refactoring

- Simpler to just track token state
- No need for separate status tracker
- Works much cleaner this way

* Remove debug messages

* Cleanup unused imports

* Fix unused variable

* Revert timeout to 2000ms

* Rename doClassicLogout -> doLogout

* Improvements for checkLoginState

- Account for the presence of a CSRF session cookie
- If available, use it to fetch a token

* Clear CSRF cookie on logout

- Forces logout from session
- Tested, works well!
- Clean up notifications

* Cleanup setApiDefaults method

* fix global logout (PUI -> CUI)

---------

Co-authored-by: Matthias Mair <code@mjmair.com>
This commit is contained in:
Oliver 2024-02-02 12:02:55 +11:00 committed by GitHub
parent ec2a66e7a5
commit f97cdef9fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 332 additions and 156 deletions

7
.vscode/launch.json vendored
View File

@ -21,6 +21,13 @@
"args": ["runserver"], "args": ["runserver"],
"django": true, "django": true,
"justMyCode": false "justMyCode": false
},
{
"name": "InvenTree Frontend - Vite",
"type": "chrome",
"request": "launch",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/src/frontend"
} }
] ]
} }

View File

@ -120,36 +120,32 @@ class InfoView(AjaxView):
'email_configured': is_email_configured(), 'email_configured': is_email_configured(),
'debug_mode': settings.DEBUG, 'debug_mode': settings.DEBUG,
'docker_mode': settings.DOCKER, 'docker_mode': settings.DOCKER,
'default_locale': settings.LANGUAGE_CODE,
# Following fields are only available to staff users
'system_health': check_system_health() if is_staff else None, 'system_health': check_system_health() if is_staff else None,
'database': InvenTree.version.inventreeDatabase() if is_staff else None, 'database': InvenTree.version.inventreeDatabase() if is_staff else None,
'platform': InvenTree.version.inventreePlatform() if is_staff else None, 'platform': InvenTree.version.inventreePlatform() if is_staff else None,
'installer': InvenTree.version.inventreeInstaller() if is_staff else None, 'installer': InvenTree.version.inventreeInstaller() if is_staff else None,
'target': InvenTree.version.inventreeTarget() if is_staff else None, 'target': InvenTree.version.inventreeTarget() if is_staff else None,
'default_locale': settings.LANGUAGE_CODE,
} }
return JsonResponse(data) return JsonResponse(data)
def check_auth_header(self, request): def check_auth_header(self, request):
"""Check if user is authenticated via a token in the header.""" """Check if user is authenticated via a token in the header."""
# TODO @matmair: remove after refacgtor of Token check is done from InvenTree.middleware import get_token_from_request
headers = request.headers.get(
'Authorization', request.headers.get('authorization')
)
if not headers:
return False
auth = headers.strip() if token := get_token_from_request(request):
if not (auth.lower().startswith('token') and len(auth.split()) == 2): # Does the provided token match a valid user?
return False try:
token = ApiToken.objects.get(key=token)
# Check if the token is active and the user is a staff member
if token.active and token.user and token.user.is_staff:
return True
except ApiToken.DoesNotExist:
pass
token_key = auth.split()[1]
try:
token = ApiToken.objects.get(key=token_key)
if token.active and token.user and token.user.is_staff:
return True
except ApiToken.DoesNotExist:
pass
return False return False

View File

@ -23,8 +23,6 @@ def get_token_from_request(request):
auth_keys = ['Authorization', 'authorization'] auth_keys = ['Authorization', 'authorization']
token_keys = ['token', 'bearer'] token_keys = ['token', 'bearer']
token = None
for k in auth_keys: for k in auth_keys:
if auth_header := request.headers.get(k, None): if auth_header := request.headers.get(k, None):
auth_header = auth_header.strip().lower().split() auth_header = auth_header.strip().lower().split()
@ -32,9 +30,9 @@ def get_token_from_request(request):
if len(auth_header) > 1: if len(auth_header) > 1:
if auth_header[0].strip().lower().replace(':', '') in token_keys: if auth_header[0].strip().lower().replace(':', '') in token_keys:
token = auth_header[1] token = auth_header[1]
break return token
return token return None
class AuthRequiredMiddleware(object): class AuthRequiredMiddleware(object):

View File

@ -445,9 +445,9 @@ REST_FRAMEWORK = {
'EXCEPTION_HANDLER': 'InvenTree.exceptions.exception_handler', 'EXCEPTION_HANDLER': 'InvenTree.exceptions.exception_handler',
'DATETIME_FORMAT': '%Y-%m-%d %H:%M', 'DATETIME_FORMAT': '%Y-%m-%d %H:%M',
'DEFAULT_AUTHENTICATION_CLASSES': ( 'DEFAULT_AUTHENTICATION_CLASSES': (
'users.authentication.ApiTokenAuthentication',
'rest_framework.authentication.BasicAuthentication', 'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.SessionAuthentication',
'users.authentication.ApiTokenAuthentication',
), ),
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',
'DEFAULT_PERMISSION_CLASSES': ( 'DEFAULT_PERMISSION_CLASSES': (

View File

@ -39,6 +39,13 @@ This command does not run as a background daemon, and will occupy the window it'
When the frontend server is running, it will be available on port 5173. When the frontend server is running, it will be available on port 5173.
i.e: https://localhost:5173/ i.e: https://localhost:5173/
### Debugging
You can attach the vscode debugger to the frontend server to debug the frontend code. With the frontend server running, open the `Run and Debug` view in vscode and select `InvenTree Frontend - Vite` from the dropdown. Click the play button to start debugging. This will attach the debugger to the running vite server, and allow you to place breakpoints in the frontend code.
!!! info "Backend Server"
To debug the frontend code, the backend server must be running (in a separate process). Note that you cannot debug the backend server and the frontend server in the same vscode instance.
### Information ### Information
On Windows, any Docker interaction is run via WSL. Naturally, all containers and devcontainers run through WSL. On Windows, any Docker interaction is run via WSL. Naturally, all containers and devcontainers run through WSL.

View File

@ -1,22 +1,42 @@
import { QueryClient } from '@tanstack/react-query'; import { QueryClient } from '@tanstack/react-query';
import axios from 'axios'; import axios from 'axios';
import { getCsrfCookie } from './functions/auth';
import { useLocalState } from './states/LocalState'; import { useLocalState } from './states/LocalState';
import { useSessionState } from './states/SessionState'; import { useSessionState } from './states/SessionState';
// API // Global API instance
export const api = axios.create({}); 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() { export function setApiDefaults() {
const host = useLocalState.getState().host; const host = useLocalState.getState().host;
const token = useSessionState.getState().token; const token = useSessionState.getState().token;
api.defaults.baseURL = host; api.defaults.baseURL = host;
api.defaults.headers.common['Authorization'] = `Token ${token}`;
// CSRF support (needed for POST, PUT, PATCH, DELETE) if (!!token) {
api.defaults.withCredentials = true; api.defaults.headers.common['Authorization'] = `Token ${token}`;
api.defaults.xsrfCookieName = 'csrftoken'; } else {
api.defaults.xsrfHeaderName = 'X-CSRFToken'; api.defaults.headers.common['Authorization'] = 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;
}
} }
export const queryClient = new QueryClient(); export const queryClient = new QueryClient();

View File

@ -18,8 +18,9 @@ import { useNavigate } from 'react-router-dom';
import { api } from '../../App'; import { api } from '../../App';
import { ApiEndpoints } from '../../enums/ApiEndpoints'; import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { doClassicLogin, doSimpleLogin } from '../../functions/auth'; import { doBasicLogin, doSimpleLogin } from '../../functions/auth';
import { apiUrl, useServerApiState } from '../../states/ApiState'; import { apiUrl, useServerApiState } from '../../states/ApiState';
import { useSessionState } from '../../states/SessionState';
export function AuthenticationForm() { export function AuthenticationForm() {
const classicForm = useForm({ const classicForm = useForm({
@ -36,19 +37,13 @@ export function AuthenticationForm() {
setIsLoggingIn(true); setIsLoggingIn(true);
if (classicLoginMode === true) { if (classicLoginMode === true) {
doClassicLogin( doBasicLogin(
classicForm.values.username, classicForm.values.username,
classicForm.values.password classicForm.values.password
).then((ret) => { ).then(() => {
setIsLoggingIn(false); setIsLoggingIn(false);
if (ret === false) { if (useSessionState.getState().hasToken()) {
notifications.show({
title: t`Login failed`,
message: t`Check your input and try again.`,
color: 'red'
});
} else {
notifications.show({ notifications.show({
title: t`Login successful`, title: t`Login successful`,
message: t`Welcome back!`, message: t`Welcome back!`,
@ -56,6 +51,12 @@ export function AuthenticationForm() {
icon: <IconCheck size="1rem" /> icon: <IconCheck size="1rem" />
}); });
navigate('/home'); navigate('/home');
} else {
notifications.show({
title: t`Login failed`,
message: t`Check your input and try again.`,
color: 'red'
});
} }
}); });
} else { } else {

View File

@ -7,13 +7,14 @@ import {
IconUserBolt, IconUserBolt,
IconUserCog IconUserCog
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { Link } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { doClassicLogout } from '../../functions/auth'; import { doLogout } from '../../functions/auth';
import { InvenTreeStyle } from '../../globalStyle'; import { InvenTreeStyle } from '../../globalStyle';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
export function MainMenu() { export function MainMenu() {
const navigate = useNavigate();
const { classes, theme } = InvenTreeStyle(); const { classes, theme } = InvenTreeStyle();
const userState = useUserState(); const userState = useUserState();
@ -63,7 +64,7 @@ export function MainMenu() {
<Menu.Item <Menu.Item
icon={<IconLogout />} icon={<IconLogout />}
onClick={() => { onClick={() => {
doClassicLogout(); doLogout(navigate);
}} }}
> >
<Trans>Logout</Trans> <Trans>Logout</Trans>

View File

@ -2,7 +2,7 @@ import { Badge, Center, MantineSize } from '@mantine/core';
import { colorMap } from '../../defaults/backendMappings'; import { colorMap } from '../../defaults/backendMappings';
import { ModelType } from '../../enums/ModelType'; import { ModelType } from '../../enums/ModelType';
import { useServerApiState } from '../../states/ApiState'; import { useGlobalStatusState } from '../../states/StatusState';
interface StatusCodeInterface { interface StatusCodeInterface {
key: string; key: string;
@ -72,7 +72,7 @@ export const StatusRenderer = ({
type: ModelType | string; type: ModelType | string;
options?: renderStatusLabelOptionsInterface; options?: renderStatusLabelOptionsInterface;
}) => { }) => {
const statusCodeList = useServerApiState.getState().status; const statusCodeList = useGlobalStatusState.getState().status;
if (status === undefined) { if (status === undefined) {
console.log('StatusRenderer: status is undefined'); console.log('StatusRenderer: status is undefined');

View File

@ -47,7 +47,6 @@ function SettingValue({
settingsState.fetchSettings(); settingsState.fetchSettings();
}) })
.catch((error) => { .catch((error) => {
console.log('Error editing setting', error);
showNotification({ showNotification({
title: t`Error editing setting`, title: t`Error editing setting`,
message: error.message, message: error.message,

View File

@ -7,6 +7,7 @@ import { useEffect, useRef, useState } from 'react';
import { api } from '../App'; import { api } from '../App';
import { useServerApiState } from '../states/ApiState'; import { useServerApiState } from '../states/ApiState';
import { useLocalState } from '../states/LocalState'; import { useLocalState } from '../states/LocalState';
import { fetchGlobalStates } from '../states/states';
// Definitions // Definitions
export type Locales = keyof typeof languages | 'pseudo-LOCALE'; export type Locales = keyof typeof languages | 'pseudo-LOCALE';
@ -90,8 +91,8 @@ export function LanguageContext({ children }: { children: JSX.Element }) {
// Update default Accept-Language headers // Update default Accept-Language headers
api.defaults.headers.common['Accept-Language'] = locales.join(', '); api.defaults.headers.common['Accept-Language'] = locales.join(', ');
// Reload server state (refresh status codes) // Reload server state (and refresh status codes)
useServerApiState.getState().fetchServerApiState(); fetchGlobalStates();
// Clear out cached table column names // Clear out cached table column names
useLocalState.getState().clearTableColumnNames(); useLocalState.getState().clearTableColumnNames();

View File

@ -1,77 +1,90 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { notifications, showNotification } from '@mantine/notifications'; import { notifications } from '@mantine/notifications';
import { IconCheck } from '@tabler/icons-react'; import { IconCheck } from '@tabler/icons-react';
import axios from 'axios'; import axios from 'axios';
import { api } from '../App'; import { api, setApiDefaults } from '../App';
import { ApiEndpoints } from '../enums/ApiEndpoints'; import { ApiEndpoints } from '../enums/ApiEndpoints';
import { apiUrl, useServerApiState } from '../states/ApiState'; import { apiUrl } from '../states/ApiState';
import { useLocalState } from '../states/LocalState'; import { useLocalState } from '../states/LocalState';
import { useSessionState } from '../states/SessionState'; import { useSessionState } from '../states/SessionState';
import {
useGlobalSettingsState,
useUserSettingsState
} from '../states/SettingsState';
import { useUserState } from '../states/UserState';
export const doClassicLogin = async (username: string, password: string) => { const tokenName: string = 'inventree-web-app';
/**
* Attempt to login using username:password combination.
* If login is successful, an API token will be returned.
* This API token is used for any future API requests.
*/
export const doBasicLogin = async (username: string, password: string) => {
const { host } = useLocalState.getState(); const { host } = useLocalState.getState();
// const apiState = useServerApiState.getState();
// Get token from server if (username.length == 0 || password.length == 0) {
const token = await axios return;
}
// At this stage, we can assume that we are not logged in, and we have no token
useSessionState.getState().clearToken();
// Request new token from the server
await axios
.get(apiUrl(ApiEndpoints.user_token), { .get(apiUrl(ApiEndpoints.user_token), {
auth: { username, password }, auth: { username, password },
baseURL: host, baseURL: host,
timeout: 2000, timeout: 2000,
params: { params: {
name: 'inventree-web-app' name: tokenName
} }
}) })
.then((response) => response.data.token) .then((response) => {
.catch((error) => { if (response.status == 200 && response.data.token) {
showNotification({ // A valid token has been returned - save, and login
title: t`Login failed`, useSessionState.getState().setToken(response.data.token);
message: t`Error fetching token from server.`, }
color: 'red' })
}); .catch(() => {});
return false;
});
if (token === false) return token;
// log in with token
doTokenLogin(token);
return true;
}; };
/** /**
* Logout the user (invalidate auth token) * Logout the user from the current session
*
* @arg deleteToken: If true, delete the token from the server
*/ */
export const doClassicLogout = async () => { export const doLogout = async (navigate: any) => {
// Set token in context
const { setToken } = useSessionState.getState();
setToken(undefined);
// Logout from the server session // Logout from the server session
await api.post(apiUrl(ApiEndpoints.user_logout)); await api.post(apiUrl(ApiEndpoints.user_logout));
// Logout from this session
// Note that clearToken() then calls setApiDefaults()
clearCsrfCookie();
useSessionState.getState().clearToken();
notifications.hide('login');
notifications.show({ notifications.show({
id: 'login',
title: t`Logout successful`, title: t`Logout successful`,
message: t`You have been logged out`, message: t`You have been logged out`,
color: 'green', color: 'green',
icon: <IconCheck size="1rem" /> icon: <IconCheck size="1rem" />
}); });
return true; navigate('/login');
}; };
export const doSimpleLogin = async (email: string) => { export const doSimpleLogin = async (email: string) => {
const { host } = useLocalState.getState(); const { host } = useLocalState.getState();
const mail = await axios const mail = await axios
.post(apiUrl(ApiEndpoints.user_simple_login), { .post(
email: email apiUrl(ApiEndpoints.user_simple_login),
}) {
email: email
},
{
baseURL: host,
timeout: 2000
}
)
.then((response) => response.data) .then((response) => response.data)
.catch((_error) => { .catch((_error) => {
return false; return false;
@ -79,21 +92,6 @@ export const doSimpleLogin = async (email: string) => {
return mail; return mail;
}; };
// Perform a login using a token
export const doTokenLogin = (token: string) => {
const { setToken } = useSessionState.getState();
const { fetchUserState } = useUserState.getState();
const { fetchServerApiState } = useServerApiState.getState();
const globalSettingsState = useGlobalSettingsState.getState();
const userSettingsState = useUserSettingsState.getState();
setToken(token);
fetchUserState();
fetchServerApiState();
globalSettingsState.fetchSettings();
userSettingsState.fetchSettings();
};
export function handleReset(navigate: any, values: { email: string }) { export function handleReset(navigate: any, values: { email: string }) {
api api
.post(apiUrl(ApiEndpoints.user_reset), values, { .post(apiUrl(ApiEndpoints.user_reset), values, {
@ -119,36 +117,96 @@ export function handleReset(navigate: any, values: { email: string }) {
} }
/** /**
* Check login state, and redirect the user as required * Check login state, and redirect the user as required.
*
* The user may be logged in via the following methods:
* - An existing API token is stored in the session
* - An existing CSRF cookie is stored in the browser
*/ */
export function checkLoginState( export function checkLoginState(
navigate: any, navigate: any,
redirect?: string, redirect?: string,
no_redirect?: boolean no_redirect?: boolean
) { ) {
api setApiDefaults();
.get(apiUrl(ApiEndpoints.user_token), {
timeout: 2000,
params: {
name: 'inventree-web-app'
}
})
.then((val) => {
if (val.status === 200 && val.data.token) {
doTokenLogin(val.data.token);
notifications.show({ // Callback function when login is successful
title: t`Already logged in`, const loginSuccess = () => {
message: t`Found an existing login - using it to log you in.`, notifications.hide('login');
color: 'green', notifications.show({
icon: <IconCheck size="1rem" /> id: 'login',
}); title: t`Logged In`,
navigate(redirect ?? '/home'); message: t`Found an existing login - welcome back!`,
} else { color: 'green',
navigate('/login'); icon: <IconCheck size="1rem" />
}
})
.catch(() => {
if (!no_redirect) navigate('/login');
}); });
navigate(redirect ?? '/home');
};
// Callback function when login fails
const loginFailure = () => {
useSessionState.getState().clearToken();
if (!no_redirect) navigate('/login');
};
if (useSessionState.getState().hasToken()) {
// An existing token is available - check if it works
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
}
})
.then((response) => {
if (response.status == 200 && response.data.token) {
useSessionState.getState().setToken(response.data.token);
loginSuccess();
} else {
loginFailure();
}
})
.catch(() => {
loginFailure();
});
} else {
// No token, no cookie - redirect to login page
loginFailure();
}
}
/*
* Return the value of the CSRF cookie, if available
*/
export function getCsrfCookie() {
const cookieValue = document.cookie
.split('; ')
.find((row) => row.startsWith('csrftoken='))
?.split('=')[1];
return cookieValue;
}
/*
* Clear out the CSRF cookie (force session logout)
*/
export function clearCsrfCookie() {
document.cookie =
'csrftoken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
} }

View File

@ -49,6 +49,7 @@ export default function Login() {
// check if user is logged in in PUI // check if user is logged in in PUI
checkLoginState(navigate, undefined, true); checkLoginState(navigate, undefined, true);
}, []); }, []);
// Fetch server data on mount if no server data is present // Fetch server data on mount if no server data is present
useEffect(() => { useEffect(() => {
if (server.server === null) { if (server.server === null) {

View File

@ -2,20 +2,14 @@ import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware'; import { createJSONStorage, persist } from 'zustand/middleware';
import { api } from '../App'; import { api } from '../App';
import { StatusCodeListInterface } from '../components/render/StatusRenderer';
import { statusCodeList } from '../defaults/backendMappings';
import { emptyServerAPI } from '../defaults/defaults'; import { emptyServerAPI } from '../defaults/defaults';
import { ApiEndpoints } from '../enums/ApiEndpoints'; import { ApiEndpoints } from '../enums/ApiEndpoints';
import { ModelType } from '../enums/ModelType';
import { AuthProps, ServerAPIProps } from './states'; import { AuthProps, ServerAPIProps } from './states';
type StatusLookup = Record<ModelType | string, StatusCodeListInterface>;
interface ServerApiStateProps { interface ServerApiStateProps {
server: ServerAPIProps; server: ServerAPIProps;
setServer: (newServer: ServerAPIProps) => void; setServer: (newServer: ServerAPIProps) => void;
fetchServerApiState: () => void; fetchServerApiState: () => void;
status?: StatusLookup;
auth_settings?: AuthProps; auth_settings?: AuthProps;
} }
@ -31,19 +25,9 @@ export const useServerApiState = create<ServerApiStateProps>()(
.then((response) => { .then((response) => {
set({ server: response.data }); set({ server: response.data });
}) })
.catch(() => {}); .catch(() => {
// Fetch status data for rendering labels console.error('Error fetching server info');
await api });
.get(apiUrl(ApiEndpoints.global_status))
.then((response) => {
const newStatusLookup: StatusLookup = {} as StatusLookup;
for (const key in response.data) {
newStatusLookup[statusCodeList[key] || key] =
response.data[key].values;
}
set({ status: newStatusLookup });
})
.catch(() => {});
// Fetch login/SSO behaviour // Fetch login/SSO behaviour
await api await api
@ -53,7 +37,9 @@ export const useServerApiState = create<ServerApiStateProps>()(
.then((response) => { .then((response) => {
set({ auth_settings: response.data }); set({ auth_settings: response.data });
}) })
.catch(() => {}); .catch(() => {
console.error('Error fetching SSO information');
});
}, },
status: undefined status: undefined
}), }),

View File

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

View File

@ -7,6 +7,7 @@ import { api } from '../App';
import { ApiEndpoints } from '../enums/ApiEndpoints'; import { ApiEndpoints } from '../enums/ApiEndpoints';
import { isTrue } from '../functions/conversion'; import { isTrue } from '../functions/conversion';
import { PathParams, apiUrl } from './ApiState'; import { PathParams, apiUrl } from './ApiState';
import { useSessionState } from './SessionState';
import { Setting, SettingsLookup } from './states'; import { Setting, SettingsLookup } from './states';
export interface SettingsStateProps { export interface SettingsStateProps {
@ -28,6 +29,10 @@ export const useGlobalSettingsState = create<SettingsStateProps>(
lookup: {}, lookup: {},
endpoint: ApiEndpoints.settings_global_list, endpoint: ApiEndpoints.settings_global_list,
fetchSettings: async () => { fetchSettings: async () => {
if (!useSessionState.getState().hasToken()) {
return;
}
await api await api
.get(apiUrl(ApiEndpoints.settings_global_list)) .get(apiUrl(ApiEndpoints.settings_global_list))
.then((response) => { .then((response) => {
@ -58,6 +63,10 @@ export const useUserSettingsState = create<SettingsStateProps>((set, get) => ({
lookup: {}, lookup: {},
endpoint: ApiEndpoints.settings_user_list, endpoint: ApiEndpoints.settings_user_list,
fetchSettings: async () => { fetchSettings: async () => {
if (!useSessionState.getState().hasToken()) {
return;
}
await api await api
.get(apiUrl(ApiEndpoints.settings_user_list)) .get(apiUrl(ApiEndpoints.settings_user_list))
.then((response) => { .then((response) => {

View File

@ -0,0 +1,51 @@
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
import { api } from '../App';
import { StatusCodeListInterface } from '../components/render/StatusRenderer';
import { statusCodeList } from '../defaults/backendMappings';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { ModelType } from '../enums/ModelType';
import { apiUrl } from './ApiState';
import { useSessionState } from './SessionState';
type StatusLookup = Record<ModelType | string, StatusCodeListInterface>;
interface ServerStateProps {
status?: StatusLookup;
setStatus: (newStatus: StatusLookup) => void;
fetchStatus: () => void;
}
export const useGlobalStatusState = create<ServerStateProps>()(
persist(
(set) => ({
status: undefined,
setStatus: (newStatus: StatusLookup) => set({ status: newStatus }),
fetchStatus: async () => {
// Fetch status data for rendering labels
if (!useSessionState.getState().hasToken()) {
return;
}
await api
.get(apiUrl(ApiEndpoints.global_status))
.then((response) => {
const newStatusLookup: StatusLookup = {} as StatusLookup;
for (const key in response.data) {
newStatusLookup[statusCodeList[key] || key] =
response.data[key].values;
}
set({ status: newStatusLookup });
})
.catch(() => {
console.error('Error fetching global status information');
});
}
}),
{
name: 'global-status-state',
storage: createJSONStorage(() => sessionStorage)
}
)
);

View File

@ -3,8 +3,8 @@ import { create } from 'zustand';
import { api } from '../App'; import { api } from '../App';
import { ApiEndpoints } from '../enums/ApiEndpoints'; import { ApiEndpoints } from '../enums/ApiEndpoints';
import { UserPermissions, UserRoles } from '../enums/Roles'; import { UserPermissions, UserRoles } from '../enums/Roles';
import { doClassicLogout } from '../functions/auth';
import { apiUrl } from './ApiState'; import { apiUrl } from './ApiState';
import { useSessionState } from './SessionState';
import { UserProps } from './states'; import { UserProps } from './states';
interface UserStateProps { interface UserStateProps {
@ -35,6 +35,10 @@ export const useUserState = create<UserStateProps>((set, get) => ({
}, },
setUser: (newUser: UserProps) => set({ user: newUser }), setUser: (newUser: UserProps) => set({ user: newUser }),
fetchUserState: async () => { fetchUserState: async () => {
if (!useSessionState.getState().hasToken()) {
return;
}
// Fetch user data // Fetch user data
await api await api
.get(apiUrl(ApiEndpoints.user_me), { .get(apiUrl(ApiEndpoints.user_me), {
@ -52,8 +56,6 @@ export const useUserState = create<UserStateProps>((set, get) => ({
}) })
.catch((error) => { .catch((error) => {
console.error('Error fetching user data:', error); console.error('Error fetching user data:', error);
// Redirect to login page
doClassicLogout();
}); });
// Fetch role data // Fetch role data

View File

@ -1,3 +1,9 @@
import { setApiDefaults } from '../App';
import { useSessionState } from './SessionState';
import { useGlobalSettingsState, useUserSettingsState } from './SettingsState';
import { useGlobalStatusState } from './StatusState';
import { useUserState } from './UserState';
export interface Host { export interface Host {
host: string; host: string;
name: string; name: string;
@ -111,3 +117,20 @@ export type ErrorResponse = {
export type SettingsLookup = { export type SettingsLookup = {
[key: string]: string; [key: string]: string;
}; };
/*
* Refetch all global state information.
* Necessary on login, or if locale is changed.
*/
export function fetchGlobalStates() {
if (!useSessionState.getState().hasToken()) {
return;
}
setApiDefaults();
useUserState.getState().fetchUserState();
useUserSettingsState.getState().fetchSettings();
useGlobalSettingsState.getState().fetchSettings();
useGlobalStatusState.getState().fetchStatus();
}

View File

@ -1,7 +1,7 @@
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
import { ModelType } from '../enums/ModelType'; import { ModelType } from '../enums/ModelType';
import { useServerApiState } from '../states/ApiState'; import { useGlobalStatusState } from '../states/StatusState';
/** /**
* Interface for the table filter choice * Interface for the table filter choice
@ -60,7 +60,7 @@ export function StatusFilterOptions(
model: ModelType model: ModelType
): () => TableFilterChoice[] { ): () => TableFilterChoice[] {
return () => { return () => {
const statusCodeList = useServerApiState.getState().status; const statusCodeList = useGlobalStatusState.getState().status;
if (!statusCodeList) { if (!statusCodeList) {
return []; return [];

View File

@ -2,7 +2,7 @@ import { QueryClientProvider } from '@tanstack/react-query';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { queryClient, setApiDefaults } from '../App'; import { queryClient } from '../App';
import { BaseContext } from '../contexts/BaseContext'; import { BaseContext } from '../contexts/BaseContext';
import { defaultHostList } from '../defaults/defaultHostList'; import { defaultHostList } from '../defaults/defaultHostList';
import { base_url } from '../main'; import { base_url } from '../main';
@ -26,9 +26,6 @@ export default function DesktopAppView() {
state.fetchSettings state.fetchSettings
]); ]);
// Local state initialization
setApiDefaults();
// Server Session // Server Session
const [fetchedServerSession, setFetchedServerSession] = useState(false); const [fetchedServerSession, setFetchedServerSession] = useState(false);
const sessionState = useSessionState.getState(); const sessionState = useSessionState.getState();

View File

@ -1,6 +1,7 @@
import { useViewportSize } from '@mantine/hooks'; import { useViewportSize } from '@mantine/hooks';
import { lazy } from 'react'; import { lazy, useEffect } from 'react';
import { setApiDefaults } from '../App';
import { Loadable } from '../functions/loading'; import { Loadable } from '../functions/loading';
function checkMobile() { function checkMobile() {
@ -14,6 +15,12 @@ const DesktopAppView = Loadable(lazy(() => import('./DesktopAppView')));
// Main App // Main App
export default function MainView() { export default function MainView() {
// Set initial login status
useEffect(() => {
// Local state initialization
setApiDefaults();
}, []);
// Check if mobile // Check if mobile
if (checkMobile()) { if (checkMobile()) {
return <MobileAppView />; return <MobileAppView />;