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,