diff --git a/src/App.tsx b/src/App.tsx index 0f4ed0aaa..a07092be1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -54,6 +54,7 @@ import Project from 'pages/Project' import Policy from 'pages/Policy' import Maintenance from 'pages/Maintenance' import PrivateRoute from 'components/AuthzRoute' +import Logout from 'pages/Logout' import { HttpErrorBadRequest } from './utils/error' import { NotistackProvider, SnackbarUtilsConfigurator } from './utils/snack' @@ -175,6 +176,7 @@ function App() { + diff --git a/src/components/AccountPopover.tsx b/src/components/AccountPopover.tsx index d1a29bffa..3cad85ebd 100644 --- a/src/components/AccountPopover.tsx +++ b/src/components/AccountPopover.tsx @@ -5,6 +5,7 @@ import { useDeleteCloudttyMutation } from 'redux/otomiApi' import { useSession } from 'providers/Session' import { getDomain, getEmailNoSymbols, getUserTeams } from 'layouts/Shell' import { clearLocalStorage } from 'hooks/useLocalStorage' +import { useHistory } from 'react-router-dom' import MenuPopover from './MenuPopover' import { IconButtonAnimate } from './animate' import SettingMode from './SettingMode' @@ -14,6 +15,7 @@ type Props = { } export default function AccountPopover({ email }: Props) { + const history = useHistory() const [open, setOpen] = useState(null) const [del] = useDeleteCloudttyMutation() const { user, oboTeamId } = useSession() @@ -30,16 +32,13 @@ export default function AccountPopover({ email }: Props) { setOpen(null) } - const handleLogout = async () => { - try { - await del({ - body: { teamId: oboTeamId, domain, emailNoSymbols, isAdmin: user.isPlatformAdmin, userTeams }, - }) - } catch (error) { - // cloudtty resources will be automatically deleted by the API after a 2-hour timeout - } - clearLocalStorage('oboTeamId') - window.location.href = '/logout-otomi' + const handleLogout = () => { + del({ + body: { teamId: oboTeamId, domain, emailNoSymbols, isAdmin: user.isPlatformAdmin, userTeams }, + }).finally(() => { + clearLocalStorage('oboTeamId') + history.push('/logout') + }) } return ( diff --git a/src/components/Error.tsx b/src/components/Error.tsx index cda7648ee..a4a63c821 100644 --- a/src/components/Error.tsx +++ b/src/components/Error.tsx @@ -4,6 +4,7 @@ import Helmet from 'react-helmet' import { useTranslation } from 'react-i18next' import { useAppDispatch, useAppSelector } from 'redux/hooks' import { setError } from 'redux/reducers' +import Logout from 'pages/Logout' import { ApiErrorUnauthorized, ApiErrorUnauthorizedNoGroups, HttpError } from '../utils/error' import Iconify from './Iconify' @@ -18,9 +19,8 @@ export default function ({ error }: Props): React.ReactElement { // END HOOKS const err = error ?? globalError if (!err) return null - // redirect to login page if the error is a fetch error (session expired) - // automatically triggers Keycloak to route the user to the Keycloak login page - if (err?.status === 'FETCH_ERROR') window.location.href = '/' + // return the logout page if the error is a fetch error (session expired) + if (err?.status === 'FETCH_ERROR') return const { title, message, data, code, originalStatus, status } = err || {} const errorMessage = title ? `${title}: ${message}` : message || data?.error const errorCode = code || originalStatus || status || message || data?.error @@ -36,6 +36,7 @@ export default function ({ error }: Props): React.ReactElement { case 403: icon = 'ic:baseline-do-not-disturb' break + case 503: case 504: icon = 'ant-design:api-outlined' break @@ -50,7 +51,12 @@ export default function ({ error }: Props): React.ReactElement { {text} ) - if (code === 504 || err instanceof ApiErrorUnauthorized || err instanceof ApiErrorUnauthorizedNoGroups) { + if ( + code === 503 || + code === 504 || + err instanceof ApiErrorUnauthorized || + err instanceof ApiErrorUnauthorizedNoGroups + ) { return renderButton(t('Logout', { ns: 'error' }) as string, () => { window.location.href = '/logout-otomi' }) diff --git a/src/pages/Logout.tsx b/src/pages/Logout.tsx new file mode 100644 index 000000000..868e77ab9 --- /dev/null +++ b/src/pages/Logout.tsx @@ -0,0 +1,21 @@ +import LoadingScreen from 'components/LoadingScreen' +import React, { useEffect } from 'react' + +interface Props { + fetchError?: boolean +} + +export default function Logout({ fetchError = false }: Props): React.ReactElement { + // This component manages the logout process for users authenticated with Keycloak. + // - If a fetch error occurs, the page reloads automatically to handle potential session issues. + // - If no fetch error occurs, the user is redirected to the Keycloak logout page ('/logout-otomi' route). + // - On component unmount, the page reloads to ensure a clean and consistent state. + useEffect(() => { + if (fetchError) window.location.reload() + else window.location.href = '/logout-otomi' + return () => { + window.location.reload() + } + }, [fetchError]) + return +} diff --git a/src/providers/Session.tsx b/src/providers/Session.tsx index 368f810bb..b90c43d36 100644 --- a/src/providers/Session.tsx +++ b/src/providers/Session.tsx @@ -1,3 +1,4 @@ +import { skipToken } from '@reduxjs/toolkit/dist/query' import { setSpec } from 'common/api-spec' import LinkCommit from 'components/LinkCommit' import LoadingScreen from 'components/LoadingScreen' @@ -6,8 +7,10 @@ import MessageTekton from 'components/MessageTekton' import MessageTrans from 'components/MessageTrans' import { useLocalStorage } from 'hooks/useLocalStorage' import { ProviderContext, SnackbarKey } from 'notistack' +import Logout from 'pages/Logout' import React, { useContext, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useLocation } from 'react-router-dom' import { useAppSelector } from 'redux/hooks' import { GetSessionApiResponse, @@ -18,7 +21,12 @@ import { useGetSettingsInfoQuery, } from 'redux/otomiApi' import { useSocket, useSocketEvent } from 'socket.io-react-hook' -import { ApiErrorGatewayTimeout, ApiErrorUnauthorized, ApiErrorUnauthorizedNoGroups } from 'utils/error' +import { + ApiErrorGatewayTimeout, + ApiErrorServiceUnavailable, + ApiErrorUnauthorized, + ApiErrorUnauthorizedNoGroups, +} from 'utils/error' import snack from 'utils/snack' export interface SessionContext extends GetSessionApiResponse { @@ -90,17 +98,28 @@ type DroneBuildEvent = { } export default function SessionProvider({ children }: Props): React.ReactElement { + const { pathname } = useLocation() + const skipFetch = pathname === '/logout' || pathname === '/logout-otomi' const [oboTeamId, setOboTeamId] = useLocalStorage('oboTeamId', undefined) - const { data: session, isLoading: isLoadingSession, refetch: refetchSession } = useGetSessionQuery() + const { + data: session, + isLoading: isLoadingSession, + refetch: refetchSession, + error: sessionError, + } = useGetSessionQuery(skipFetch && skipToken) const url = `${window.location.origin.replace(/^http/, 'ws')}` const path = '/api/ws' - const { data: settings, isLoading: isLoadingSettings, refetch: refetchSettings } = useGetSettingsInfoQuery() + const { + data: settings, + isLoading: isLoadingSettings, + refetch: refetchSettings, + } = useGetSettingsInfoQuery(skipFetch && skipToken) const { data: apps, isLoading: isLoadingApps, refetch: refetchAppsEnabled, } = useGetAppsQuery({ teamId: oboTeamId, picks: ['id', 'enabled'] }, { skip: !oboTeamId }) - const { data: apiDocs, isLoading: isLoadingApiDocs, error: errorApiDocs } = useApiDocsQuery() + const { data: apiDocs, isLoading: isLoadingApiDocs, error: errorApiDocs } = useApiDocsQuery(skipFetch && skipToken) const { socket, error: errorSocket } = useSocket({ url, path }) // eslint-disable-next-line @typescript-eslint/no-unsafe-argument const { lastMessage: lastDbMessage } = useSocketEvent(socket, 'db') @@ -278,7 +297,13 @@ export default function SessionProvider({ children }: Props): React.ReactElement if (errorSocket) keys.socket = snack.warning(`${t('Could not establish socket connection. Retrying...')}`, { key: keys.socket }) // no error and we stopped loading, so we can check the user - if (!session) throw new ApiErrorGatewayTimeout() + if (sessionError) { + const { originalStatus, status } = sessionError as any + if (originalStatus === 503) throw new ApiErrorServiceUnavailable() + if (originalStatus === 504) throw new ApiErrorGatewayTimeout() + // return the logout page if the error is a fetch error (session expired) + if (status === 'FETCH_ERROR') return + } if (!session.user.isPlatformAdmin && session.user.teams.length === 0) throw new ApiErrorUnauthorizedNoGroups() if (isLoadingApiDocs || isLoadingApps || isLoadingSession || isLoadingSettings) return if (apiDocs) setSpec(apiDocs) diff --git a/src/utils/error.tsx b/src/utils/error.tsx index 98701e7d1..86a58d938 100644 --- a/src/utils/error.tsx +++ b/src/utils/error.tsx @@ -28,6 +28,11 @@ class HttpErrorForbidden extends HttpError { } } +class ApiErrorServiceUnavailable extends HttpError { + constructor() { + super(e['The API could not be reached.'], 503) + } +} class ApiErrorGatewayTimeout extends HttpError { constructor() { super(e['The API could not be reached.'], 504) @@ -56,6 +61,7 @@ export { HttpError, HttpErrorBadRequest, HttpErrorForbidden, + ApiErrorServiceUnavailable, ApiErrorGatewayTimeout, ApiErrorUnauthorized, ApiErrorUnauthorizedNoGroups,