0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-27 21:39:16 -05:00

refactor(console): reorg error rendering (#4082)

* refactor(console): reorg error rendering

* refactor(console): reorg error rendering
This commit is contained in:
Gao Sun 2023-06-27 11:31:03 +08:00 committed by GitHub
parent 850cd3e342
commit ad6dde7f18
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 99 additions and 96 deletions

View file

@ -96,7 +96,12 @@ export default function withSecurityHeaders<InputContext extends RequestContext>
directives: {
'upgrade-insecure-requests': null,
imgSrc: ["'self'", 'data:', 'https:'],
scriptSrc: ["'self'", ...gtagOrigins],
scriptSrc: [
"'self'",
...gtagOrigins,
// Non-production environment allow "unsafe-eval" and "unsafe-inline" for debugging purpose
...conditionalArray(!isProduction && ["'unsafe-eval'", "'unsafe-inline'"]),
],
connectSrc: [
"'self'",
...adminOrigins,

View file

@ -19,6 +19,7 @@ import { cloudApi, getManagementApi, meApi } from '@/consts/resources';
import { adminTenantEndpoint, mainTitle } from './consts';
import { isCloud } from './consts/env';
import ErrorBoundary from './containers/ErrorBoundary';
import LogtoErrorBoundary from './containers/LogtoErrorBoundary';
import TenantAppContainer from './containers/TenantAppContainer';
import AppConfirmModalProvider from './contexts/AppConfirmModalProvider';
import AppEndpointsProvider from './contexts/AppEndpointsProvider';
@ -76,19 +77,21 @@ function Content() {
<AppInsightsBoundary cloudRole="console">
<Helmet titleTemplate={`%s - ${mainTitle}`} defaultTitle={mainTitle} />
<ErrorBoundary>
{/**
* If it's not Cloud (OSS), render the tenant app container directly since only default tenant is available;
* if it's Cloud, render the tenant app container only when a tenant ID is available (in a tenant context).
*/}
{!isCloud || currentTenantId ? (
<AppEndpointsProvider>
<AppConfirmModalProvider>
<TenantAppContainer />
</AppConfirmModalProvider>
</AppEndpointsProvider>
) : (
<CloudApp />
)}
<LogtoErrorBoundary>
{/**
* If it's not Cloud (OSS), render the tenant app container directly since only default tenant is available;
* if it's Cloud, render the tenant app container only when a tenant ID is available (in a tenant context).
*/}
{!isCloud || currentTenantId ? (
<AppEndpointsProvider>
<AppConfirmModalProvider>
<TenantAppContainer />
</AppConfirmModalProvider>
</AppEndpointsProvider>
) : (
<CloudApp />
)}
</LogtoErrorBoundary>
</ErrorBoundary>
</AppInsightsBoundary>
</AppThemeProvider>

View file

@ -1,7 +1,7 @@
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import ProtectedRoutes from '@/containers/ProtectedRoutes';
import Callback from '@cloud/pages/Callback';
import Callback from '@/pages/Callback';
import * as styles from './App.module.scss';
import Main from './pages/Main';

View file

@ -1,26 +0,0 @@
import { useHandleSignInCallback } from '@logto/react';
import { useNavigate } from 'react-router-dom';
import AppLoading from '@/components/AppLoading';
import { getUserTenantId } from '@/consts/tenants';
function Callback() {
const navigate = useNavigate();
const { error } = useHandleSignInCallback(() => {
navigate('/' + getUserTenantId(), { replace: true });
});
if (error) {
return (
<div>
Error Occurred:
<br />
{error.message}
</div>
);
}
return <AppLoading />;
}
export default Callback;

View file

@ -1,12 +1,8 @@
import { LogtoClientError, LogtoError, useLogto } from '@logto/react';
import { conditional } from '@silverhand/essentials';
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import AppError from '@/components/AppError';
import AppLoading from '@/components/AppLoading';
import SessionExpired from '@/components/SessionExpired';
import { isCloud } from '@/consts/env';
import useConfigs from '@/hooks/use-configs';
import useScroll from '@/hooks/use-scroll';
@ -21,7 +17,6 @@ import * as styles from './index.module.scss';
import { type AppContentOutletContext } from './types';
function AppContent() {
const { error } = useLogto();
const { isLoading: isPreferencesLoading } = useUserPreferences();
const { isLoading: isConfigsLoading } = useConfigs();
@ -32,7 +27,6 @@ function AppContent() {
const { firstItem } = useSidebarMenuItems();
const scrollableContent = useRef<HTMLDivElement>(null);
const { scrollTop } = useScroll(scrollableContent.current);
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
useEffect(() => {
// Navigate to the first menu item after configs are loaded.
@ -45,18 +39,6 @@ function AppContent() {
return <AppLoading />;
}
if (error) {
if (error instanceof LogtoClientError) {
return <SessionExpired error={error} />;
}
if (error instanceof LogtoError && error.code === 'crypto_subtle_unavailable') {
return <AppError errorMessage={t('errors.insecure_contexts')} callStack={error.stack} />;
}
return <AppError errorMessage={error.message} callStack={error.stack} />;
}
return (
<>
<div className={styles.app}>

View file

@ -1,13 +1,18 @@
import { LogtoClientError, LogtoError, OidcError } from '@logto/react';
import { conditional } from '@silverhand/essentials';
import { ResponseError } from '@withtyped/client';
import { type TFunction } from 'i18next';
import { HTTPError } from 'ky';
import type { ReactNode } from 'react';
import { Component } from 'react';
import { withTranslation } from 'react-i18next';
import AppError from '@/components/AppError';
import SessionExpired from '@/components/SessionExpired';
type Props = {
children: ReactNode;
t: TFunction<'translation', 'admin_console'>;
};
type State = {
@ -48,12 +53,42 @@ class ErrorBoundary extends Component<Props, State> {
}
render() {
const { children } = this.props;
const { children, t } = this.props;
const { error } = this.state;
if (error) {
if (error instanceof HTTPError) {
return error.response.status === 401 ? <SessionExpired error={error} /> : children;
// Different strategies for handling errors in callback pages since the callback errors
// are likely unexpected and unrecoverable.
const { pathname } = window.location;
if (['/callback', '-callback'].some((path) => pathname.endsWith(path))) {
if (error instanceof LogtoError && error.data instanceof OidcError) {
return (
<AppError
errorCode={error.data.error}
errorMessage={error.data.errorDescription}
callStack={error.stack}
/>
);
}
return (
<AppError errorCode={error.name} errorMessage={error.message} callStack={error.stack} />
);
}
// Insecure contexts error is not recoverable
if (error instanceof LogtoError && error.code === 'crypto_subtle_unavailable') {
return <AppError errorMessage={t('errors.insecure_contexts')} callStack={error.stack} />;
}
// Treat other Logto errors and 401 responses as session expired
if (
error instanceof LogtoError ||
error instanceof LogtoClientError ||
(error instanceof HTTPError && error.response.status === 401) ||
(error instanceof ResponseError && error.status === 401)
) {
return <SessionExpired error={error} />;
}
const callStack = conditional(
@ -69,4 +104,4 @@ class ErrorBoundary extends Component<Props, State> {
}
}
export default ErrorBoundary;
export default withTranslation('translation', { keyPrefix: 'admin_console' })(ErrorBoundary);

View file

@ -0,0 +1,18 @@
import { useLogto } from '@logto/react';
import { useEffect } from 'react';
/**
* This component keep children as is, but throw error if there is any error from Logto
* (`useLogto()`). The error should be handled by the upper `<ErrorBoundary />`.
*/
export default function LogtoErrorBoundary({ children }: { children: JSX.Element }) {
const { error } = useLogto();
useEffect(() => {
if (error) {
throw error;
}
}, [error]);
return children;
}

View file

@ -1,28 +1,17 @@
import { LogtoError, OidcError, useHandleSignInCallback } from '@logto/react';
import { useHandleSignInCallback } from '@logto/react';
import { conditionalString } from '@silverhand/essentials';
import { useNavigate } from 'react-router-dom';
import AppError from '@/components/AppError';
import AppLoading from '@/components/AppLoading';
import { isCloud } from '@/consts/env';
import { getUserTenantId } from '@/consts/tenants';
function Callback() {
const navigate = useNavigate();
const { error } = useHandleSignInCallback(() => {
navigate('/');
useHandleSignInCallback(() => {
navigate('/' + conditionalString(isCloud && getUserTenantId()), { replace: true });
});
if (error) {
const errorCode =
error instanceof LogtoError && error.data instanceof OidcError
? error.data.error
: error.name;
const errorMessage =
error instanceof LogtoError && error.data instanceof OidcError
? error.data.errorDescription
: error.message;
return <AppError errorCode={errorCode} errorMessage={errorMessage} callStack={error.stack} />;
}
return <AppLoading />;
}

View file

@ -27,8 +27,7 @@ const tenantProfileToForm = (tenant?: TenantInfo): TenantSettingsForm => {
function TenantBasicSettings() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const api = useCloudApi();
const { currentTenant, currentTenantId, tenants, updateTenant, removeTenant } =
useContext(TenantsContext);
const { currentTenant, currentTenantId, updateTenant, removeTenant } = useContext(TenantsContext);
const [error, setError] = useState<Error>();
const [isDeletionModalOpen, setIsDeletionModalOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);

View file

@ -1,12 +1,10 @@
import { LogtoClientError, useLogto } from '@logto/react';
import { useLogto } from '@logto/react';
import classNames from 'classnames';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import Logo from '@/assets/images/logo.svg';
import AppError from '@/components/AppError';
import SessionExpired from '@/components/SessionExpired';
import { getCallbackUrl } from '@/consts';
import Button from '@/ds-components/Button';
import useTheme from '@/hooks/use-theme';
@ -16,7 +14,7 @@ import * as styles from './index.module.scss';
function Welcome() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const navigate = useNavigate();
const { isAuthenticated, error, signIn } = useLogto();
const { isAuthenticated, signIn } = useLogto();
const theme = useTheme();
useEffect(() => {
@ -26,14 +24,6 @@ function Welcome() {
}
}, [isAuthenticated, navigate]);
if (error) {
if (error instanceof LogtoClientError) {
return <SessionExpired error={error} />;
}
return <AppError errorMessage={error.message} callStack={error.stack} />;
}
return (
<div className={classNames(styles.container, styles[theme])}>
<div className={styles.header}>

View file

@ -77,7 +77,11 @@ export default function koaSecurityHeaders<StateT, ContextT, ResponseBodyT>(
directives: {
'upgrade-insecure-requests': null,
imgSrc: ["'self'", 'data:', 'https:'],
scriptSrc: ["'self'"],
// Non-production environment allow "unsafe-eval" and "unsafe-inline" for debugging purpose
scriptSrc: [
"'self'",
...conditionalArray(!isProduction && ["'unsafe-eval'", "'unsafe-inline'"]),
],
connectSrc: ["'self'", tenantEndpointOrigin, ...developmentOrigins, ...appInsightsOrigins],
// WARNING: high risk Need to allow self hosted terms of use page loaded in an iframe
frameSrc: ["'self'", 'https:'],
@ -97,7 +101,11 @@ export default function koaSecurityHeaders<StateT, ContextT, ResponseBodyT>(
directives: {
'upgrade-insecure-requests': null,
imgSrc: ["'self'", 'data:', 'https:'],
scriptSrc: ["'self'"],
// Non-production environment allow "unsafe-eval" and "unsafe-inline" for debugging purpose
scriptSrc: [
"'self'",
...conditionalArray(!isProduction && ["'unsafe-eval'", "'unsafe-inline'"]),
],
connectSrc: ["'self'", ...adminOrigins, ...coreOrigins, ...developmentOrigins],
// Allow Main Flow origin loaded in preview iframe
frameSrc: ["'self'", ...adminOrigins, ...coreOrigins],