0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-10 21:58:23 -05:00
logto/packages/console/src/hooks/use-api.ts

170 lines
5.4 KiB
TypeScript

import {
buildOrganizationUrn,
getOrganizationIdFromUrn,
httpCodeToMessage,
organizationUrnPrefix,
} from '@logto/core-kit';
import { useLogto } from '@logto/react';
import {
getTenantOrganizationId,
type RequestErrorBody,
getManagementApiResourceIndicator,
defaultTenantId,
} from '@logto/schemas';
import { appendPath, conditionalArray } from '@silverhand/essentials';
import ky from 'ky';
import { type KyInstance } from 'node_modules/ky/distribution/types/ky';
import { useCallback, useContext, useMemo } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { requestTimeout } from '@/consts';
import { isCloud } from '@/consts/env';
import { AppDataContext } from '@/contexts/AppDataProvider';
import { TenantsContext } from '@/contexts/TenantsProvider';
import { useConfirmModal } from './use-confirm-modal';
import useRedirectUri from './use-redirect-uri';
export class RequestError extends Error {
constructor(
public readonly status: number,
public readonly body?: RequestErrorBody
) {
super('Request error occurred.');
}
}
export type StaticApiProps = {
prefixUrl?: URL;
hideErrorToast?: boolean;
resourceIndicator: string;
};
export const useStaticApi = ({
prefixUrl,
hideErrorToast,
resourceIndicator,
}: StaticApiProps): KyInstance => {
const { isAuthenticated, getAccessToken, getOrganizationToken, signOut } = useLogto();
const { t, i18n } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { show } = useConfirmModal();
const postSignOutRedirectUri = useRedirectUri('signOut');
const toastError = useCallback(
async (response: Response) => {
const fallbackErrorMessage = t('errors.unknown_server_error');
try {
// Clone the response to avoid "Response body is already used".
const data = await response.clone().json<RequestErrorBody>();
// This is what will happen when the user still has the legacy refresh token without
// organization scope. We should sign them out and redirect to the sign in page.
// TODO: This is a temporary solution to prevent the user from getting stuck in Console,
// which can be removed after all legacy refresh tokens are expired, i.e. after Jan 10th,
// 2024.
if (response.status === 403 && data.message === 'Insufficient permissions.') {
await signOut(postSignOutRedirectUri.href);
return;
}
// Inform and redirect un-authorized users to sign in page.
if (data.code === 'auth.forbidden') {
await show({
ModalContent: data.message,
type: 'alert',
cancelButtonText: 'general.got_it',
});
await signOut(postSignOutRedirectUri.href);
return;
}
toast.error([data.message, data.details].join('\n') || fallbackErrorMessage);
} catch {
toast.error(httpCodeToMessage[response.status] ?? fallbackErrorMessage);
}
},
[show, signOut, t, postSignOutRedirectUri]
);
const api = useMemo(
() =>
ky.create({
prefixUrl,
timeout: requestTimeout,
hooks: {
beforeError: conditionalArray(
!hideErrorToast &&
(async (error) => {
await toastError(error.response);
return error;
})
),
beforeRequest: [
async (request) => {
if (isAuthenticated) {
const accessToken = await (resourceIndicator.startsWith(organizationUrnPrefix)
? getOrganizationToken(getOrganizationIdFromUrn(resourceIndicator))
: getAccessToken(resourceIndicator));
request.headers.set('Authorization', `Bearer ${accessToken ?? ''}`);
request.headers.set('Accept-Language', i18n.language);
}
},
],
},
}),
[
prefixUrl,
hideErrorToast,
toastError,
isAuthenticated,
resourceIndicator,
getOrganizationToken,
getAccessToken,
i18n.language,
]
);
return api;
};
/** A hook to get a Ky instance with the current tenant's Management API prefix URL. */
const useApi = (props: Omit<StaticApiProps, 'prefixUrl' | 'resourceIndicator'> = {}) => {
const { tenantEndpoint } = useContext(AppDataContext);
const { currentTenantId } = useContext(TenantsContext);
/**
* The config object for the Ky instance.
*
* - In Cloud, it uses the Management API proxy endpoint with tenant organization tokens.
* - In OSS, it directly uses the tenant endpoint (Management API).
*
* Since we removes all user roles for the Management API except the one for the default tenant,
* the OSS version should be used for the default tenant only.
*/
const config = useMemo(
() =>
isCloud
? {
prefixUrl: appendPath(new URL(window.location.origin), 'm', currentTenantId),
resourceIndicator: buildOrganizationUrn(getTenantOrganizationId(currentTenantId)),
}
: {
prefixUrl: tenantEndpoint,
resourceIndicator: getManagementApiResourceIndicator(currentTenantId),
},
[currentTenantId, tenantEndpoint]
);
if (!isCloud && currentTenantId !== defaultTenantId) {
throw new Error('Only the default tenant is supported in OSS.');
}
return useStaticApi({
...props,
...config,
});
};
export default useApi;