diff --git a/src/App.jsx b/src/App.jsx index 2a72ce1..93e7356 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,4 +1,5 @@ import { PureComponent, lazy, Suspense } from 'react'; +import Alert from '@mui/material/Alert'; import Box from '@mui/material/Box'; import CircularProgress from '@mui/material/CircularProgress'; import authAutomat from './automats/authAutomat'; @@ -34,7 +35,13 @@ class App extends PureComponent { } componentDidMount() { - this.unsubscribe = authAutomat.subscribe(this, ['status', 'user', 'hasAuth', 'hasCompany']); + this.unsubscribe = authAutomat.subscribe(this, [ + 'status', + 'user', + 'hasAuth', + 'hasCompany', + 'retryNotification', + ]); } componentWillUnmount() { @@ -42,7 +49,7 @@ class App extends PureComponent { } render() { - const { status, user, hasAuth, hasCompany } = this.state; + const { status, user, hasAuth, hasCompany, retryNotification } = this.state; const isAuthed = Boolean(hasAuth ?? (status === 'authed' && user)); const isCompanySelected = Boolean(hasCompany ?? (isAuthed && user?.selectedCompanyId != null)); @@ -51,12 +58,29 @@ class App extends PureComponent { + {retryNotification ? ( + + {retryNotification} + + ) : null} ); diff --git a/src/automats/authAutomat.js b/src/automats/authAutomat.js index bbef708..8ea8d6c 100644 --- a/src/automats/authAutomat.js +++ b/src/automats/authAutomat.js @@ -33,6 +33,7 @@ import { auth, company, number as numberApi } from '../api'; * @property {'loading'|'anon'|'authed'} status Current authentication lifecycle state. * @property {boolean} hasAuth True when the session has a valid authenticated user. * @property {boolean} hasCompany True when `hasAuth` is true AND a company is selected. + * @property {string|null} retryNotification Message indicating a pending retry (e.g. 502 Bad Gateway). */ /** Emitted after an authenticated session has been established. */ @@ -49,11 +50,24 @@ const initialState = { status: 'loading', hasAuth: false, hasCompany: false, + retryNotification: null, }; let emit = () => { }; // #region helpers +/** + * Detects whether an error represents an HTTP 502 Bad Gateway response. + * + * @param {any} err + * @returns {boolean} + */ +export function is502BadGateway(err) { + if (!err) return false; + if (Number(err.status) === 502) return true; + const msg = String(err.message || ''); + return /502|bad\s*gateway/i.test(msg); +} /** * Registers the application-level dispatcher used for authentication events. * @@ -108,6 +122,7 @@ function buildAuthState(user, status, extra = {}) { status, hasAuth, hasCompany, + retryNotification: null, ...extra, }; } @@ -126,52 +141,86 @@ function syncSse(state) { } } } + +function applySession(nextSession) { + const googleClientId = + nextSession.googleClientId ?? authAutomat.state.googleClientId; + + if (!nextSession.user) { + if (authAutomat.state.status !== 'authed') { + const nextState = authAutomat.setState( + buildAuthState(null, 'anon', { googleClientId, retryNotification: null }) + ); + syncSse(nextState); + } else { + authAutomat.setState({ googleClientId, retryNotification: null }); + } + return nextSession; + } + + const user = mergeUser(authAutomat.state.user, nextSession.user); + const nextState = authAutomat.setState( + buildAuthState(user, 'authed', { googleClientId, retryNotification: null }) + ); + syncSse(nextState); + emit({ type: AUTH_SESSION_ESTABLISHED }); + return nextSession; +} // #endregion let authAutomat; +let inFlightBootstrap = null; const actions = { // #region bootstrapSession // MARK: calls auth.me from ../api /** * Restores an existing session or requests it from the authentication API. + * Retries automatically every 5 seconds if a 502 Bad Gateway is received. * * @param {{user?: AuthUser|null, googleClientId?: string|null}|null} [session] * Optional preloaded session. * @returns {Promise} Resolved session response. */ async bootstrapSession(session) { - try { - const nextSession = session ?? await auth.me(); - const googleClientId = - nextSession.googleClientId ?? authAutomat.state.googleClientId; - - if (!nextSession.user) { - if (authAutomat.state.status !== 'authed') { - const nextState = authAutomat.setState( - buildAuthState(null, 'anon', { googleClientId }) - ); - syncSse(nextState); - } else { - authAutomat.setState({ googleClientId }); - } - return nextSession; - } - - const user = mergeUser(authAutomat.state.user, nextSession.user); - const nextState = authAutomat.setState( - buildAuthState(user, 'authed', { googleClientId }) - ); - syncSse(nextState); - emit({ type: AUTH_SESSION_ESTABLISHED }); - return nextSession; - } catch (error) { - if (authAutomat.state.status === 'loading') { - const nextState = authAutomat.setState(buildAuthState(null, 'anon')); - syncSse(nextState); - } - throw error; + if (session) { + return applySession(session); } + + if (inFlightBootstrap) { + return inFlightBootstrap; + } + + inFlightBootstrap = (async () => { + let attempt = 0; + while (true) { + try { + const nextSession = await auth.me(); + return applySession(nextSession); + } catch (error) { + if (is502BadGateway(error)) { + attempt++; + authAutomat.setState({ + retryNotification: `Server nicht erreichbar (502 Bad Gateway) – erneuter Versuch in 5s…${attempt > 1 ? ` (Versuch ${attempt})` : ''}`, + }); + await new Promise((resolve) => setTimeout(resolve, 5000)); + continue; + } + + if (authAutomat.state.status === 'loading') { + const nextState = authAutomat.setState( + buildAuthState(null, 'anon', { retryNotification: null }) + ); + syncSse(nextState); + } + throw error; + } + } + })().finally(() => { + inFlightBootstrap = null; + }); + + return inFlightBootstrap; }, // #endregion @@ -302,6 +351,7 @@ export const selectHasAuth = () => Boolean(authAutomat.state.hasAuth); export const selectHasCompany = () => Boolean(authAutomat.state.hasCompany); export const selectSelectedCompanyId = () => authAutomat.state.hasCompany ? authAutomat.state.user?.selectedCompanyId ?? null : null; +export const selectRetryNotification = () => authAutomat.state.retryNotification; /** * Re-reads `GET /api/auth/me` from the server. @@ -312,7 +362,7 @@ export const selectSelectedCompanyId = () => * @returns {Promise<{user: AuthUser|null, googleClientId: string|null}>} Session. */ export function bootstrapAuthMe() { - return auth.me(); + return authAutomat.actions.bootstrapSession(); } // #endregion diff --git a/src/components/AuthBootstrap.jsx b/src/components/AuthBootstrap.jsx index 5e2bfc8..e232ec8 100644 --- a/src/components/AuthBootstrap.jsx +++ b/src/components/AuthBootstrap.jsx @@ -1,7 +1,7 @@ import { Component } from 'react'; import { enqueueSnackbar } from 'notistack'; import { notifyError } from '../utils/notify'; -import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat'; +import authAutomat, { is502BadGateway } from '../automats/authAutomat'; let sessionBootstrapped = false; @@ -10,15 +10,16 @@ class AuthBootstrap extends Component { if (sessionBootstrapped) return; sessionBootstrapped = true; - bootstrapAuthMe() + authAutomat.actions.bootstrapSession() .then((session) => { - if (session.forbidden) { + if (session?.forbidden) { enqueueSnackbar(session.message, { variant: 'warning' }); } - return authAutomat.actions.bootstrapSession(session); }) .catch((err) => { - notifyError(err); + if (!is502BadGateway(err)) { + notifyError(err); + } }); }