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 { PureComponent, lazy, Suspense } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import authAutomat from './automats/authAutomat'; import authAutomat from './automats/authAutomat';
@@ -34,7 +35,13 @@ class App extends PureComponent {
} }
componentDidMount() { componentDidMount() {
this.unsubscribe = authAutomat.subscribe(this, ['status', 'user', 'hasAuth', 'hasCompany']); this.unsubscribe = authAutomat.subscribe(this, [
'status',
'user',
'hasAuth',
'hasCompany',
'retryNotification',
]);
} }
componentWillUnmount() { componentWillUnmount() {
@@ -42,7 +49,7 @@ class App extends PureComponent {
} }
render() { render() {
const { status, user, hasAuth, hasCompany } = this.state; const { status, user, hasAuth, hasCompany, retryNotification } = this.state;
const isAuthed = Boolean(hasAuth ?? (status === 'authed' && user)); const isAuthed = Boolean(hasAuth ?? (status === 'authed' && user));
const isCompanySelected = Boolean(hasCompany ?? (isAuthed && user?.selectedCompanyId != null)); const isCompanySelected = Boolean(hasCompany ?? (isAuthed && user?.selectedCompanyId != null));
@@ -51,12 +58,29 @@ class App extends PureComponent {
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
minHeight: 'var(--df-vh, 100vh)', minHeight: 'var(--df-vh, 100vh)',
bgcolor: 'background.default', 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 /> <CircularProgress />
</Box> </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 {'loading'|'anon'|'authed'} status Current authentication lifecycle state.
* @property {boolean} hasAuth True when the session has a valid authenticated user. * @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 {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. */ /** Emitted after an authenticated session has been established. */
@@ -49,11 +50,24 @@ const initialState = {
status: 'loading', status: 'loading',
hasAuth: false, hasAuth: false,
hasCompany: false, hasCompany: false,
retryNotification: null,
}; };
let emit = () => { }; let emit = () => { };
// #region helpers // #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. * Registers the application-level dispatcher used for authentication events.
* *
@@ -108,6 +122,7 @@ function buildAuthState(user, status, extra = {}) {
status, status,
hasAuth, hasAuth,
hasCompany, hasCompany,
retryNotification: null,
...extra, ...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 // #endregion
let authAutomat; let authAutomat;
let inFlightBootstrap = null;
const actions = { const actions = {
// #region bootstrapSession // #region bootstrapSession
// MARK: calls auth.me from ../api // MARK: calls auth.me from ../api
/** /**
* Restores an existing session or requests it from the authentication 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] * @param {{user?: AuthUser|null, googleClientId?: string|null}|null} [session]
* Optional preloaded session. * Optional preloaded session.
* @returns {Promise<Object>} Resolved session response. * @returns {Promise<Object>} Resolved session response.
*/ */
async bootstrapSession(session) { async bootstrapSession(session) {
try { if (session) {
const nextSession = session ?? await auth.me(); return applySession(session);
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 (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 // #endregion
@@ -302,6 +351,7 @@ export const selectHasAuth = () => Boolean(authAutomat.state.hasAuth);
export const selectHasCompany = () => Boolean(authAutomat.state.hasCompany); export const selectHasCompany = () => Boolean(authAutomat.state.hasCompany);
export const selectSelectedCompanyId = () => export const selectSelectedCompanyId = () =>
authAutomat.state.hasCompany ? authAutomat.state.user?.selectedCompanyId ?? null : null; authAutomat.state.hasCompany ? authAutomat.state.user?.selectedCompanyId ?? null : null;
export const selectRetryNotification = () => authAutomat.state.retryNotification;
/** /**
* Re-reads `GET /api/auth/me` from the server. * 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. * @returns {Promise<{user: AuthUser|null, googleClientId: string|null}>} Session.
*/ */
export function bootstrapAuthMe() { export function bootstrapAuthMe() {
return auth.me(); return authAutomat.actions.bootstrapSession();
} }
// #endregion // #endregion

View File

@@ -1,7 +1,7 @@
import { Component } from 'react'; import { Component } from 'react';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import { notifyError } from '../utils/notify'; import { notifyError } from '../utils/notify';
import authAutomat, { bootstrapAuthMe } from '../automats/authAutomat'; import authAutomat, { is502BadGateway } from '../automats/authAutomat';
let sessionBootstrapped = false; let sessionBootstrapped = false;
@@ -10,15 +10,16 @@ class AuthBootstrap extends Component {
if (sessionBootstrapped) return; if (sessionBootstrapped) return;
sessionBootstrapped = true; sessionBootstrapped = true;
bootstrapAuthMe() authAutomat.actions.bootstrapSession()
.then((session) => { .then((session) => {
if (session.forbidden) { if (session?.forbidden) {
enqueueSnackbar(session.message, { variant: 'warning' }); enqueueSnackbar(session.message, { variant: 'warning' });
} }
return authAutomat.actions.bootstrapSession(session);
}) })
.catch((err) => { .catch((err) => {
notifyError(err); if (!is502BadGateway(err)) {
notifyError(err);
}
}); });
} }