From f97cdef9fc8c61ceb4733287d0836be4de91bc32 Mon Sep 17 00:00:00 2001 From: Oliver Date: Fri, 2 Feb 2024 12:02:55 +1100 Subject: [PATCH] [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 --- .vscode/launch.json | 7 + InvenTree/InvenTree/api.py | 30 ++- InvenTree/InvenTree/middleware.py | 6 +- InvenTree/InvenTree/settings.py | 2 +- docs/docs/develop/react-frontend.md | 7 + src/frontend/src/App.tsx | 32 ++- .../components/forms/AuthenticationForm.tsx | 21 +- src/frontend/src/components/nav/MainMenu.tsx | 7 +- .../src/components/render/StatusRenderer.tsx | 4 +- .../src/components/settings/SettingItem.tsx | 1 - src/frontend/src/contexts/LanguageContext.tsx | 5 +- src/frontend/src/functions/auth.tsx | 212 +++++++++++------- src/frontend/src/pages/Auth/Login.tsx | 1 + src/frontend/src/states/ApiState.tsx | 26 +-- src/frontend/src/states/SessionState.tsx | 18 +- src/frontend/src/states/SettingsState.tsx | 9 + src/frontend/src/states/StatusState.tsx | 51 +++++ src/frontend/src/states/UserState.tsx | 8 +- src/frontend/src/states/states.tsx | 23 ++ src/frontend/src/tables/Filter.tsx | 4 +- src/frontend/src/views/DesktopAppView.tsx | 5 +- src/frontend/src/views/MainView.tsx | 9 +- 22 files changed, 332 insertions(+), 156 deletions(-) create mode 100644 src/frontend/src/states/StatusState.tsx diff --git a/.vscode/launch.json b/.vscode/launch.json index 55aa17b353..8d01312e36 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -21,6 +21,13 @@ "args": ["runserver"], "django": true, "justMyCode": false + }, + { + "name": "InvenTree Frontend - Vite", + "type": "chrome", + "request": "launch", + "url": "http://localhost:5173", + "webRoot": "${workspaceFolder}/src/frontend" } ] } diff --git a/InvenTree/InvenTree/api.py b/InvenTree/InvenTree/api.py index d47cf4b792..ad8d29666e 100644 --- a/InvenTree/InvenTree/api.py +++ b/InvenTree/InvenTree/api.py @@ -120,36 +120,32 @@ class InfoView(AjaxView): 'email_configured': is_email_configured(), 'debug_mode': settings.DEBUG, '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, 'database': InvenTree.version.inventreeDatabase() if is_staff else None, 'platform': InvenTree.version.inventreePlatform() if is_staff else None, 'installer': InvenTree.version.inventreeInstaller() if is_staff else None, 'target': InvenTree.version.inventreeTarget() if is_staff else None, - 'default_locale': settings.LANGUAGE_CODE, } return JsonResponse(data) def check_auth_header(self, request): """Check if user is authenticated via a token in the header.""" - # TODO @matmair: remove after refacgtor of Token check is done - headers = request.headers.get( - 'Authorization', request.headers.get('authorization') - ) - if not headers: - return False + from InvenTree.middleware import get_token_from_request - auth = headers.strip() - if not (auth.lower().startswith('token') and len(auth.split()) == 2): - return False + if token := get_token_from_request(request): + # Does the provided token match a valid user? + 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 diff --git a/InvenTree/InvenTree/middleware.py b/InvenTree/InvenTree/middleware.py index 1a25202f23..e9a272e13b 100644 --- a/InvenTree/InvenTree/middleware.py +++ b/InvenTree/InvenTree/middleware.py @@ -23,8 +23,6 @@ def get_token_from_request(request): auth_keys = ['Authorization', 'authorization'] token_keys = ['token', 'bearer'] - token = None - for k in auth_keys: if auth_header := request.headers.get(k, None): auth_header = auth_header.strip().lower().split() @@ -32,9 +30,9 @@ def get_token_from_request(request): if len(auth_header) > 1: if auth_header[0].strip().lower().replace(':', '') in token_keys: token = auth_header[1] - break + return token - return token + return None class AuthRequiredMiddleware(object): diff --git a/InvenTree/InvenTree/settings.py b/InvenTree/InvenTree/settings.py index d16c322d3f..ff750a2166 100644 --- a/InvenTree/InvenTree/settings.py +++ b/InvenTree/InvenTree/settings.py @@ -445,9 +445,9 @@ REST_FRAMEWORK = { 'EXCEPTION_HANDLER': 'InvenTree.exceptions.exception_handler', 'DATETIME_FORMAT': '%Y-%m-%d %H:%M', 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'users.authentication.ApiTokenAuthentication', 'rest_framework.authentication.BasicAuthentication', 'rest_framework.authentication.SessionAuthentication', - 'users.authentication.ApiTokenAuthentication', ), 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', 'DEFAULT_PERMISSION_CLASSES': ( diff --git a/docs/docs/develop/react-frontend.md b/docs/docs/develop/react-frontend.md index 5f1d3853c1..227e12c842 100644 --- a/docs/docs/develop/react-frontend.md +++ b/docs/docs/develop/react-frontend.md @@ -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. 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 On Windows, any Docker interaction is run via WSL. Naturally, all containers and devcontainers run through WSL. diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index f7e3a19df1..a0710fdb2b 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -1,22 +1,42 @@ 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'; -// API +// 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.headers.common['Authorization'] = `Token ${token}`; - // CSRF support (needed for POST, PUT, PATCH, DELETE) - api.defaults.withCredentials = true; - api.defaults.xsrfCookieName = 'csrftoken'; - api.defaults.xsrfHeaderName = 'X-CSRFToken'; + if (!!token) { + api.defaults.headers.common['Authorization'] = `Token ${token}`; + } else { + 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(); diff --git a/src/frontend/src/components/forms/AuthenticationForm.tsx b/src/frontend/src/components/forms/AuthenticationForm.tsx index 9d01c79018..3b2019c19b 100644 --- a/src/frontend/src/components/forms/AuthenticationForm.tsx +++ b/src/frontend/src/components/forms/AuthenticationForm.tsx @@ -18,8 +18,9 @@ import { useNavigate } from 'react-router-dom'; import { api } from '../../App'; import { ApiEndpoints } from '../../enums/ApiEndpoints'; -import { doClassicLogin, doSimpleLogin } from '../../functions/auth'; +import { doBasicLogin, doSimpleLogin } from '../../functions/auth'; import { apiUrl, useServerApiState } from '../../states/ApiState'; +import { useSessionState } from '../../states/SessionState'; export function AuthenticationForm() { const classicForm = useForm({ @@ -36,19 +37,13 @@ export function AuthenticationForm() { setIsLoggingIn(true); if (classicLoginMode === true) { - doClassicLogin( + doBasicLogin( classicForm.values.username, classicForm.values.password - ).then((ret) => { + ).then(() => { setIsLoggingIn(false); - if (ret === false) { - notifications.show({ - title: t`Login failed`, - message: t`Check your input and try again.`, - color: 'red' - }); - } else { + if (useSessionState.getState().hasToken()) { notifications.show({ title: t`Login successful`, message: t`Welcome back!`, @@ -56,6 +51,12 @@ export function AuthenticationForm() { icon: }); navigate('/home'); + } else { + notifications.show({ + title: t`Login failed`, + message: t`Check your input and try again.`, + color: 'red' + }); } }); } else { diff --git a/src/frontend/src/components/nav/MainMenu.tsx b/src/frontend/src/components/nav/MainMenu.tsx index 3fb69120da..e98934e56a 100644 --- a/src/frontend/src/components/nav/MainMenu.tsx +++ b/src/frontend/src/components/nav/MainMenu.tsx @@ -7,13 +7,14 @@ import { IconUserBolt, IconUserCog } 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 { useUserState } from '../../states/UserState'; export function MainMenu() { + const navigate = useNavigate(); const { classes, theme } = InvenTreeStyle(); const userState = useUserState(); @@ -63,7 +64,7 @@ export function MainMenu() { } onClick={() => { - doClassicLogout(); + doLogout(navigate); }} > Logout diff --git a/src/frontend/src/components/render/StatusRenderer.tsx b/src/frontend/src/components/render/StatusRenderer.tsx index 4b2ff24827..21bcc549ac 100644 --- a/src/frontend/src/components/render/StatusRenderer.tsx +++ b/src/frontend/src/components/render/StatusRenderer.tsx @@ -2,7 +2,7 @@ import { Badge, Center, MantineSize } from '@mantine/core'; import { colorMap } from '../../defaults/backendMappings'; import { ModelType } from '../../enums/ModelType'; -import { useServerApiState } from '../../states/ApiState'; +import { useGlobalStatusState } from '../../states/StatusState'; interface StatusCodeInterface { key: string; @@ -72,7 +72,7 @@ export const StatusRenderer = ({ type: ModelType | string; options?: renderStatusLabelOptionsInterface; }) => { - const statusCodeList = useServerApiState.getState().status; + const statusCodeList = useGlobalStatusState.getState().status; if (status === undefined) { console.log('StatusRenderer: status is undefined'); diff --git a/src/frontend/src/components/settings/SettingItem.tsx b/src/frontend/src/components/settings/SettingItem.tsx index 4fce3cc145..ceee00f395 100644 --- a/src/frontend/src/components/settings/SettingItem.tsx +++ b/src/frontend/src/components/settings/SettingItem.tsx @@ -47,7 +47,6 @@ function SettingValue({ settingsState.fetchSettings(); }) .catch((error) => { - console.log('Error editing setting', error); showNotification({ title: t`Error editing setting`, message: error.message, diff --git a/src/frontend/src/contexts/LanguageContext.tsx b/src/frontend/src/contexts/LanguageContext.tsx index 4780d1006a..2c926e8ea8 100644 --- a/src/frontend/src/contexts/LanguageContext.tsx +++ b/src/frontend/src/contexts/LanguageContext.tsx @@ -7,6 +7,7 @@ import { useEffect, useRef, useState } from 'react'; import { api } from '../App'; import { useServerApiState } from '../states/ApiState'; import { useLocalState } from '../states/LocalState'; +import { fetchGlobalStates } from '../states/states'; // Definitions export type Locales = keyof typeof languages | 'pseudo-LOCALE'; @@ -90,8 +91,8 @@ export function LanguageContext({ children }: { children: JSX.Element }) { // Update default Accept-Language headers api.defaults.headers.common['Accept-Language'] = locales.join(', '); - // Reload server state (refresh status codes) - useServerApiState.getState().fetchServerApiState(); + // Reload server state (and refresh status codes) + fetchGlobalStates(); // Clear out cached table column names useLocalState.getState().clearTableColumnNames(); diff --git a/src/frontend/src/functions/auth.tsx b/src/frontend/src/functions/auth.tsx index 8451bc9e76..ec49f7fba5 100644 --- a/src/frontend/src/functions/auth.tsx +++ b/src/frontend/src/functions/auth.tsx @@ -1,77 +1,90 @@ import { t } from '@lingui/macro'; -import { notifications, showNotification } from '@mantine/notifications'; +import { notifications } from '@mantine/notifications'; import { IconCheck } from '@tabler/icons-react'; import axios from 'axios'; -import { api } from '../App'; +import { api, setApiDefaults } from '../App'; import { ApiEndpoints } from '../enums/ApiEndpoints'; -import { apiUrl, useServerApiState } from '../states/ApiState'; +import { apiUrl } from '../states/ApiState'; import { useLocalState } from '../states/LocalState'; 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 apiState = useServerApiState.getState(); - // Get token from server - const token = await axios + if (username.length == 0 || password.length == 0) { + return; + } + + // At this stage, we can assume that we are not logged in, and we have no token + useSessionState.getState().clearToken(); + + // Request new token from the server + await axios .get(apiUrl(ApiEndpoints.user_token), { auth: { username, password }, baseURL: host, timeout: 2000, params: { - name: 'inventree-web-app' + name: tokenName } }) - .then((response) => response.data.token) - .catch((error) => { - showNotification({ - title: t`Login failed`, - message: t`Error fetching token from server.`, - color: 'red' - }); - return false; - }); - - if (token === false) return token; - - // log in with token - doTokenLogin(token); - return true; + .then((response) => { + if (response.status == 200 && response.data.token) { + // A valid token has been returned - save, and login + useSessionState.getState().setToken(response.data.token); + } + }) + .catch(() => {}); }; /** - * 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 () => { - // Set token in context - const { setToken } = useSessionState.getState(); - - setToken(undefined); - +export const doLogout = async (navigate: any) => { // Logout from the server session 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({ + id: 'login', title: t`Logout successful`, message: t`You have been logged out`, color: 'green', icon: }); - return true; + navigate('/login'); }; export const doSimpleLogin = async (email: string) => { const { host } = useLocalState.getState(); const mail = await axios - .post(apiUrl(ApiEndpoints.user_simple_login), { - email: email - }) + .post( + apiUrl(ApiEndpoints.user_simple_login), + { + email: email + }, + { + baseURL: host, + timeout: 2000 + } + ) .then((response) => response.data) .catch((_error) => { return false; @@ -79,21 +92,6 @@ export const doSimpleLogin = async (email: string) => { 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 }) { api .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( navigate: any, redirect?: string, no_redirect?: boolean ) { - api - .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); + setApiDefaults(); - notifications.show({ - title: t`Already logged in`, - message: t`Found an existing login - using it to log you in.`, - color: 'green', - icon: - }); - navigate(redirect ?? '/home'); - } else { - navigate('/login'); - } - }) - .catch(() => { - if (!no_redirect) navigate('/login'); + // Callback function when login is successful + const loginSuccess = () => { + notifications.hide('login'); + notifications.show({ + id: 'login', + title: t`Logged In`, + message: t`Found an existing login - welcome back!`, + color: 'green', + icon: }); + 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=/;'; } diff --git a/src/frontend/src/pages/Auth/Login.tsx b/src/frontend/src/pages/Auth/Login.tsx index 16de1b7509..f794bbcfd4 100644 --- a/src/frontend/src/pages/Auth/Login.tsx +++ b/src/frontend/src/pages/Auth/Login.tsx @@ -49,6 +49,7 @@ export default function Login() { // check if user is logged in in PUI checkLoginState(navigate, undefined, true); }, []); + // Fetch server data on mount if no server data is present useEffect(() => { if (server.server === null) { diff --git a/src/frontend/src/states/ApiState.tsx b/src/frontend/src/states/ApiState.tsx index eb144533b4..2b284efcf4 100644 --- a/src/frontend/src/states/ApiState.tsx +++ b/src/frontend/src/states/ApiState.tsx @@ -2,20 +2,14 @@ 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 { emptyServerAPI } from '../defaults/defaults'; import { ApiEndpoints } from '../enums/ApiEndpoints'; -import { ModelType } from '../enums/ModelType'; import { AuthProps, ServerAPIProps } from './states'; -type StatusLookup = Record; - interface ServerApiStateProps { server: ServerAPIProps; setServer: (newServer: ServerAPIProps) => void; fetchServerApiState: () => void; - status?: StatusLookup; auth_settings?: AuthProps; } @@ -31,19 +25,9 @@ export const useServerApiState = create()( .then((response) => { set({ server: response.data }); }) - .catch(() => {}); - // Fetch status data for rendering labels - 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(() => {}); + .catch(() => { + console.error('Error fetching server info'); + }); // Fetch login/SSO behaviour await api @@ -53,7 +37,9 @@ export const useServerApiState = create()( .then((response) => { set({ auth_settings: response.data }); }) - .catch(() => {}); + .catch(() => { + console.error('Error fetching SSO information'); + }); }, status: undefined }), diff --git a/src/frontend/src/states/SessionState.tsx b/src/frontend/src/states/SessionState.tsx index 54f1e58b9e..5ac12407d7 100644 --- a/src/frontend/src/states/SessionState.tsx +++ b/src/frontend/src/states/SessionState.tsx @@ -2,20 +2,32 @@ import { create } from 'zustand'; import { createJSONStorage, persist } from 'zustand/middleware'; import { setApiDefaults } from '../App'; +import { fetchGlobalStates } from './states'; interface SessionStateProps { token?: string; setToken: (newToken?: string) => void; + clearToken: () => void; + hasToken: () => boolean; } +/* + * State manager for user login information. + */ export const useSessionState = create()( persist( - (set) => ({ - token: '', + (set, get) => ({ + token: undefined, + clearToken: () => { + set({ token: undefined }); + }, setToken: (newToken) => { set({ token: newToken }); + setApiDefaults(); - } + fetchGlobalStates(); + }, + hasToken: () => !!get().token }), { name: 'session-state', diff --git a/src/frontend/src/states/SettingsState.tsx b/src/frontend/src/states/SettingsState.tsx index 77e9f12b6e..9ac9b21d5d 100644 --- a/src/frontend/src/states/SettingsState.tsx +++ b/src/frontend/src/states/SettingsState.tsx @@ -7,6 +7,7 @@ import { api } from '../App'; import { ApiEndpoints } from '../enums/ApiEndpoints'; import { isTrue } from '../functions/conversion'; import { PathParams, apiUrl } from './ApiState'; +import { useSessionState } from './SessionState'; import { Setting, SettingsLookup } from './states'; export interface SettingsStateProps { @@ -28,6 +29,10 @@ export const useGlobalSettingsState = create( lookup: {}, endpoint: ApiEndpoints.settings_global_list, fetchSettings: async () => { + if (!useSessionState.getState().hasToken()) { + return; + } + await api .get(apiUrl(ApiEndpoints.settings_global_list)) .then((response) => { @@ -58,6 +63,10 @@ export const useUserSettingsState = create((set, get) => ({ lookup: {}, endpoint: ApiEndpoints.settings_user_list, fetchSettings: async () => { + if (!useSessionState.getState().hasToken()) { + return; + } + await api .get(apiUrl(ApiEndpoints.settings_user_list)) .then((response) => { diff --git a/src/frontend/src/states/StatusState.tsx b/src/frontend/src/states/StatusState.tsx new file mode 100644 index 0000000000..51b31f851d --- /dev/null +++ b/src/frontend/src/states/StatusState.tsx @@ -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; + +interface ServerStateProps { + status?: StatusLookup; + setStatus: (newStatus: StatusLookup) => void; + fetchStatus: () => void; +} + +export const useGlobalStatusState = create()( + 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) + } + ) +); diff --git a/src/frontend/src/states/UserState.tsx b/src/frontend/src/states/UserState.tsx index ffa217febf..ef17a8bdf6 100644 --- a/src/frontend/src/states/UserState.tsx +++ b/src/frontend/src/states/UserState.tsx @@ -3,8 +3,8 @@ import { create } from 'zustand'; import { api } from '../App'; import { ApiEndpoints } from '../enums/ApiEndpoints'; import { UserPermissions, UserRoles } from '../enums/Roles'; -import { doClassicLogout } from '../functions/auth'; import { apiUrl } from './ApiState'; +import { useSessionState } from './SessionState'; import { UserProps } from './states'; interface UserStateProps { @@ -35,6 +35,10 @@ export const useUserState = create((set, get) => ({ }, setUser: (newUser: UserProps) => set({ user: newUser }), fetchUserState: async () => { + if (!useSessionState.getState().hasToken()) { + return; + } + // Fetch user data await api .get(apiUrl(ApiEndpoints.user_me), { @@ -52,8 +56,6 @@ export const useUserState = create((set, get) => ({ }) .catch((error) => { console.error('Error fetching user data:', error); - // Redirect to login page - doClassicLogout(); }); // Fetch role data diff --git a/src/frontend/src/states/states.tsx b/src/frontend/src/states/states.tsx index 27db2fc39a..c2b5f760fc 100644 --- a/src/frontend/src/states/states.tsx +++ b/src/frontend/src/states/states.tsx @@ -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 { host: string; name: string; @@ -111,3 +117,20 @@ export type ErrorResponse = { export type SettingsLookup = { [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(); +} diff --git a/src/frontend/src/tables/Filter.tsx b/src/frontend/src/tables/Filter.tsx index 1208ae36ca..4a61f30a12 100644 --- a/src/frontend/src/tables/Filter.tsx +++ b/src/frontend/src/tables/Filter.tsx @@ -1,7 +1,7 @@ import { t } from '@lingui/macro'; import { ModelType } from '../enums/ModelType'; -import { useServerApiState } from '../states/ApiState'; +import { useGlobalStatusState } from '../states/StatusState'; /** * Interface for the table filter choice @@ -60,7 +60,7 @@ export function StatusFilterOptions( model: ModelType ): () => TableFilterChoice[] { return () => { - const statusCodeList = useServerApiState.getState().status; + const statusCodeList = useGlobalStatusState.getState().status; if (!statusCodeList) { return []; diff --git a/src/frontend/src/views/DesktopAppView.tsx b/src/frontend/src/views/DesktopAppView.tsx index 410b85b94f..a48445272d 100644 --- a/src/frontend/src/views/DesktopAppView.tsx +++ b/src/frontend/src/views/DesktopAppView.tsx @@ -2,7 +2,7 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; import { BrowserRouter } from 'react-router-dom'; -import { queryClient, setApiDefaults } from '../App'; +import { queryClient } from '../App'; import { BaseContext } from '../contexts/BaseContext'; import { defaultHostList } from '../defaults/defaultHostList'; import { base_url } from '../main'; @@ -26,9 +26,6 @@ export default function DesktopAppView() { state.fetchSettings ]); - // Local state initialization - setApiDefaults(); - // Server Session const [fetchedServerSession, setFetchedServerSession] = useState(false); const sessionState = useSessionState.getState(); diff --git a/src/frontend/src/views/MainView.tsx b/src/frontend/src/views/MainView.tsx index 473ff9b7bf..1233038d37 100644 --- a/src/frontend/src/views/MainView.tsx +++ b/src/frontend/src/views/MainView.tsx @@ -1,6 +1,7 @@ import { useViewportSize } from '@mantine/hooks'; -import { lazy } from 'react'; +import { lazy, useEffect } from 'react'; +import { setApiDefaults } from '../App'; import { Loadable } from '../functions/loading'; function checkMobile() { @@ -14,6 +15,12 @@ const DesktopAppView = Loadable(lazy(() => import('./DesktopAppView'))); // Main App export default function MainView() { + // Set initial login status + useEffect(() => { + // Local state initialization + setApiDefaults(); + }, []); + // Check if mobile if (checkMobile()) { return ;