0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00

fix(console): fix tenant switching bugs (#4128)

* fix(console): fix tenant switching bugs

* refactor(console): simplify logic per review
This commit is contained in:
Gao Sun 2023-07-06 22:08:01 +08:00 committed by GitHub
parent 38b9ae417c
commit 04cf242e48
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 90 additions and 102 deletions

View file

@ -1,38 +1,19 @@
import { useLogto } from '@logto/react';
import { useTranslation } from 'react-i18next';
import { useEffect } from 'react';
import { getCallbackUrl } from '@/consts';
import Button from '@/ds-components/Button';
import { getCallbackUrl, getUserTenantId } from '@/consts';
import AppError from '../AppError';
import AppLoading from '../AppLoading';
import * as styles from './index.module.scss';
type Props = {
error: Error;
};
function SessionExpired({ error }: Props) {
/** This component shows a loading indicator and tries to sign in again. */
function SessionExpired() {
const { signIn } = useLogto();
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<AppError
title={t('session_expired.title')}
errorMessage={t('session_expired.subtitle')}
callStack={error.stack}
>
<Button
className={styles.retryButton}
size="large"
type="outline"
title="session_expired.button"
onClick={() => {
void signIn(getCallbackUrl().href);
}}
/>
</AppError>
);
useEffect(() => {
void signIn(getCallbackUrl(getUserTenantId()).href);
}, [signIn]);
return <AppLoading />;
}
export default SessionExpired;

View file

@ -1,8 +1,8 @@
import type { ReactNode } from 'react';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import useUserPreferences from '@/hooks/use-user-preferences';
import initI18n from '@/i18n/init';
type Props = {
children: ReactNode;
@ -12,12 +12,11 @@ function AppBoundary({ children }: Props) {
const {
data: { language },
} = useUserPreferences();
const { i18n } = useTranslation();
useEffect(() => {
(async () => {
void initI18n(language);
})();
}, [language]);
void i18n.changeLanguage(language);
}, [i18n, language]);
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{children}</>;

View file

@ -57,50 +57,50 @@ class ErrorBoundary extends Component<Props, State> {
const { children, t } = this.props;
const { error } = this.state;
if (error) {
// Different strategies for handling errors in callback pages since the callback errors
// are likely unexpected and unrecoverable.
if (isInCallback()) {
if (error instanceof LogtoError && error.data instanceof OidcError) {
return (
<AppError
errorCode={error.data.error}
errorMessage={error.data.errorDescription}
callStack={error.stack}
/>
);
}
if (!error) {
return children;
}
// Different strategies for handling errors in callback pages since the callback errors
// are likely unexpected and unrecoverable.
if (isInCallback()) {
if (error instanceof LogtoError && error.data instanceof OidcError) {
return (
<AppError errorCode={error.name} errorMessage={error.message} callStack={error.stack} />
<AppError
errorCode={error.data.error}
errorMessage={error.data.errorDescription}
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(
typeof error === 'object' &&
typeof error.stack === 'string' &&
error.stack.split('\n').slice(1).join('\n')
return (
<AppError errorCode={error.name} errorMessage={error.message} callStack={error.stack} />
);
return <AppError errorMessage={error.message} callStack={callStack} />;
}
return children;
// 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 />;
}
const callStack = conditional(
typeof error === 'object' &&
typeof error.stack === 'string' &&
error.stack.split('\n').slice(1).join('\n')
);
return <AppError errorMessage={error.message} callStack={callStack} />;
}
}

View file

@ -5,7 +5,6 @@ import { useContext, useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import AppLoading from '@/components/AppLoading';
import { getCallbackUrl } from '@/consts';
// Used in the docs
// eslint-disable-next-line unused-imports/no-unused-imports
import type ProtectedRoutes from '@/containers/ProtectedRoutes';
@ -50,15 +49,15 @@ export default function TenantAccess() {
const { updateIfNeeded } = useUserDefaultTenantId();
useEffect(() => {
const validate = async ({ indicator, id: tenantId }: TenantInfo) => {
const validate = async ({ indicator }: TenantInfo) => {
// Test fetching an access token for the current Tenant ID.
// If failed, it means the user finishes the first auth, ands still needs to auth again to
// fetch the full-scoped (with all available tenants) token.
if (await trySafe(getAccessToken(indicator))) {
if (await trySafe(async () => getAccessToken(indicator))) {
setCurrentTenantStatus('validated');
} else {
void signIn(getCallbackUrl(tenantId).href);
}
// If failed, it will be treated as a session expired error, and will be handled by the
// upper `<ErrorBoundary />`.
};
if (isAuthenticated && currentTenantId && currentTenantStatus === 'pending') {

View file

@ -1,12 +1,12 @@
import { useLogto } from '@logto/react';
import type { RequestErrorBody } from '@logto/schemas';
import { getManagementApiResourceIndicator, type RequestErrorBody } from '@logto/schemas';
import { conditionalArray } from '@silverhand/essentials';
import ky from 'ky';
import { useCallback, useContext, useMemo } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { getBasename, getManagementApi, requestTimeout } from '@/consts';
import { getBasename, requestTimeout } from '@/consts';
import { AppDataContext } from '@/contexts/AppDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
@ -21,22 +21,13 @@ export class RequestError extends Error {
export type StaticApiProps = {
prefixUrl?: URL;
hideErrorToast?: boolean;
resourceIndicator?: string;
resourceIndicator: string;
};
export const useStaticApi = ({
prefixUrl,
hideErrorToast,
resourceIndicator: resourceInput,
}: StaticApiProps) => {
export const useStaticApi = ({ prefixUrl, hideErrorToast, resourceIndicator }: StaticApiProps) => {
const { isAuthenticated, getAccessToken, signOut } = useLogto();
const { currentTenantId } = useContext(TenantsContext);
const { t, i18n } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { show } = useConfirmModal();
const resourceIndicator = useMemo(
() => resourceInput ?? getManagementApi(currentTenantId).indicator,
[currentTenantId, resourceInput]
);
const toastError = useCallback(
async (response: Response) => {
@ -105,10 +96,15 @@ export const useStaticApi = ({
return api;
};
const useApi = (props: Omit<StaticApiProps, 'prefixUrl'> = {}) => {
const useApi = (props: Omit<StaticApiProps, 'prefixUrl' | 'resourceIndicator'> = {}) => {
const { userEndpoint } = useContext(AppDataContext);
const { currentTenantId } = useContext(TenantsContext);
return useStaticApi({ ...props, prefixUrl: userEndpoint });
return useStaticApi({
...props,
prefixUrl: userEndpoint,
resourceIndicator: getManagementApiResourceIndicator(currentTenantId),
});
};
export default useApi;

View file

@ -3,7 +3,7 @@ import { type ConnectorResponse } from '@logto/schemas';
import useApi, { type StaticApiProps } from './use-api';
import useConfigs from './use-configs';
const useConnectorApi = (props: Omit<StaticApiProps, 'prefixUrl'> = {}) => {
const useConnectorApi = (props: Omit<StaticApiProps, 'prefixUrl' | 'resourceIndicator'> = {}) => {
const api = useApi(props);
const { updateConfigs } = useConfigs();

View file

@ -1,5 +1,5 @@
import { trySafe } from '@silverhand/essentials';
import { useCallback, useContext, useMemo } from 'react';
import { useCallback, useContext, useMemo, useState } from 'react';
import { z } from 'zod';
import { TenantsContext } from '@/contexts/TenantsProvider';
@ -17,10 +17,15 @@ const key = 'defaultTenantId';
const useUserDefaultTenantId = () => {
const { data, update: updateMeCustomData } = useMeCustomData();
const { tenants, currentTenantId } = useContext(TenantsContext);
/** The current stored default tenant ID in the user's `customData`. */
const storedId = useMemo(
() => trySafe(() => z.object({ [key]: z.string() }).parse(data)[key]),
[data]
);
/** The last tenant ID that has been updated in the user's `customData`. */
const [updatedTenantId, setUpdatedTenantId] = useState(storedId);
const defaultTenantId = useMemo(() => {
const storedId = trySafe(() => z.object({ [key]: z.string() }).parse(data)[key]);
// Ensure the stored ID is still available to the user.
if (storedId && tenants.some(({ id }) => id === storedId)) {
return storedId;
@ -28,21 +33,29 @@ const useUserDefaultTenantId = () => {
// Fall back to the first tenant ID.
return tenants[0]?.id;
}, [data, tenants]);
}, [storedId, tenants]);
const updateIfNeeded = useCallback(async () => {
if (currentTenantId !== defaultTenantId) {
// Note storedId is not checked here because it's by design that the default tenant ID
// should be updated only when the user manually changes the current tenant. That is,
// if the user opens a new tab and go back to the original tab, the default tenant ID
// should still be the ID of the new tab.
if (currentTenantId !== updatedTenantId) {
setUpdatedTenantId(currentTenantId);
await updateMeCustomData({
[key]: currentTenantId,
});
}
}, [currentTenantId, defaultTenantId, updateMeCustomData]);
}, [currentTenantId, updateMeCustomData, updatedTenantId]);
return {
defaultTenantId,
/** Update the default tenant ID to the current tenant ID. */
updateIfNeeded,
};
return useMemo(
() => ({
defaultTenantId,
/** Update the default tenant ID to the current tenant ID. */
updateIfNeeded,
}),
[defaultTenantId, updateIfNeeded]
);
};
export default useUserDefaultTenantId;