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 { 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user