u
This commit is contained in:
28
src/App.jsx
28
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 {
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user