This commit is contained in:
seb
2026-10-28 20:12:13 +01:00
parent b13d994058
commit 003b198b86
3 changed files with 113 additions and 38 deletions

View File

@@ -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 {
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: 'var(--df-vh, 100vh)',
bgcolor: 'background.default',
gap: 2.5,
p: 2,
}}
>
{retryNotification ? (
<Alert
severity="warning"
variant="outlined"
sx={{
maxWidth: 480,
alignItems: 'center',
bgcolor: 'background.paper',
boxShadow: 1,
}}
>
{retryNotification}
</Alert>
) : null}
<CircularProgress />
</Box>
);

View File

@@ -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<Object>} 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

View File

@@ -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);
}
});
}