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:
parent
38b9ae417c
commit
04cf242e48
7 changed files with 90 additions and 102 deletions
|
@ -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;
|
||||
|
|
|
@ -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}</>;
|
||||
|
|
|
@ -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} />;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Reference in a new issue