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

refactor(core,console): refactor AC 403 flow (#3235)

This commit is contained in:
simeng-li 2023-03-01 18:24:39 +08:00 committed by GitHub
parent 2b8b3a91ec
commit 6246fc58ba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 182 additions and 51 deletions

View file

@ -0,0 +1,10 @@
---
"@logto/console": patch
"@logto/core": patch
---
## Refactor the Admin Console 403 flow
- Add 403 error handler for all AC API requests
- Show confirm modal to notify the user who is not authorized
- Click `confirm` button to sign out and redirect user to the sign-in page

View file

@ -16,6 +16,7 @@ import initI18n from '@/i18n/init';
import { adminTenantEndpoint, getUserTenantId } from './consts';
import { isCloud } from './consts/cloud';
import AppConfirmModalProvider from './contexts/AppConfirmModalProvider';
import AppEndpointsProvider from './contexts/AppEndpointsProvider';
import TenantsProvider, { TenantsContext } from './contexts/TenantsProvider';
import Main from './pages/Main';
@ -51,7 +52,9 @@ const Content = () => {
>
{!isCloud || isSettle ? (
<AppEndpointsProvider>
<Main />
<AppConfirmModalProvider>
<Main />
</AppConfirmModalProvider>
</AppEndpointsProvider>
) : (
<CloudApp />

View file

@ -20,8 +20,8 @@ export type ConfirmModalProps = {
isOpen: boolean;
isConfirmButtonDisabled?: boolean;
isLoading?: boolean;
onCancel: () => void;
onConfirm: () => void;
onCancel?: () => void;
onConfirm?: () => void;
};
const ConfirmModal = ({
@ -49,14 +49,16 @@ const ConfirmModal = ({
title={title}
footer={
<>
<Button title={cancelButtonText} onClick={onCancel} />
<Button
type={confirmButtonType}
title={confirmButtonText}
disabled={isConfirmButtonDisabled}
isLoading={isLoading}
onClick={onConfirm}
/>
{onCancel && <Button title={cancelButtonText} onClick={onCancel} />}
{onConfirm && (
<Button
type={confirmButtonType}
title={confirmButtonText}
disabled={isConfirmButtonDisabled}
isLoading={isLoading}
onClick={onConfirm}
/>
)}
</>
}
className={classNames(styles.content, className)}

View file

@ -0,0 +1,116 @@
import type { Nullable } from '@silverhand/essentials';
import { noop } from '@silverhand/essentials';
import { useState, useRef, useMemo, createContext, useCallback } from 'react';
import ConfirmModal from '@/components/ConfirmModal';
import type { ConfirmModalProps } from '@/components/ConfirmModal';
export type ModalContentRenderProps = {
confirm: (data?: unknown) => void;
cancel: (data?: unknown) => void;
};
type ConfirmModalType = 'alert' | 'confirm';
type ConfirmModalState = Omit<
ConfirmModalProps,
'onCancel' | 'onConfirm' | 'children' | 'isLoading'
> & {
ModalContent: string | ((props: ModalContentRenderProps) => Nullable<JSX.Element>);
type: ConfirmModalType;
};
type AppConfirmModalProps = Omit<ConfirmModalState, 'isOpen' | 'type'> & {
type?: ConfirmModalType;
};
type ConfirmModalContextType = {
show: (props: AppConfirmModalProps) => Promise<[boolean, unknown?]>;
confirm: (data?: unknown) => void;
cancel: (data?: unknown) => void;
};
export const AppConfirmModalContext = createContext<ConfirmModalContextType>({
show: async () => [true],
confirm: noop,
cancel: noop,
});
type Props = {
children?: React.ReactNode;
};
const defaultModalState: ConfirmModalState = {
isOpen: false,
type: 'confirm',
ModalContent: () => null,
};
const AppConfirmModalProvider = ({ children }: Props) => {
const [modalState, setModalState] = useState<ConfirmModalState>(defaultModalState);
const resolver = useRef<(value: [result: boolean, data?: unknown]) => void>();
const handleShow = useCallback(async ({ type = 'confirm', ...props }: AppConfirmModalProps) => {
resolver.current?.([false]);
setModalState({
isOpen: true,
type,
...props,
});
return new Promise<[result: boolean, data?: unknown]>((resolve) => {
// eslint-disable-next-line @silverhand/fp/no-mutation
resolver.current = resolve;
});
}, []);
const handleConfirm = useCallback((data?: unknown) => {
resolver.current?.([true, data]);
setModalState(defaultModalState);
}, []);
const handleCancel = useCallback((data?: unknown) => {
resolver.current?.([false, data]);
setModalState(defaultModalState);
}, []);
const contextValue = useMemo(
() => ({
show: handleShow,
confirm: handleConfirm,
cancel: handleCancel,
}),
[handleCancel, handleConfirm, handleShow]
);
const { ModalContent, type, ...restProps } = modalState;
return (
<AppConfirmModalContext.Provider value={contextValue}>
{children}
<ConfirmModal
{...restProps}
onConfirm={
type === 'confirm'
? () => {
handleConfirm();
}
: undefined
}
onCancel={() => {
handleCancel();
}}
>
{typeof ModalContent === 'string' ? (
ModalContent
) : (
<ModalContent confirm={handleConfirm} cancel={handleCancel} />
)}
</ConfirmModal>
</AppConfirmModalContext.Provider>
);
};
export default AppConfirmModalProvider;

View file

@ -5,9 +5,11 @@ import { useCallback, useContext, useMemo } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import { getManagementApi, getUserTenantId, requestTimeout } from '@/consts';
import { getBasename, getManagementApi, getUserTenantId, requestTimeout } from '@/consts';
import { AppEndpointsContext } from '@/contexts/AppEndpointsProvider';
import { useConfirmModal } from './use-confirm-modal';
export class RequestError extends Error {
status: number;
body?: RequestErrorBody;
@ -30,8 +32,9 @@ export const useStaticApi = ({
hideErrorToast,
resourceIndicator = getManagementApi(getUserTenantId()).indicator,
}: StaticApiProps) => {
const { isAuthenticated, getAccessToken } = useLogto();
const { isAuthenticated, getAccessToken, signOut } = useLogto();
const { t, i18n } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { show } = useConfirmModal();
const toastError = useCallback(
async (response: Response) => {
@ -39,12 +42,26 @@ export const useStaticApi = ({
try {
const data = await response.json<RequestErrorBody>();
// 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(new URL(getBasename(), window.location.origin).toString());
return;
}
toast.error([data.message, data.details].join('\n') || fallbackErrorMessage);
} catch {
toast.error(fallbackErrorMessage);
}
},
[t]
[show, signOut, t]
);
const api = useMemo(
@ -56,8 +73,8 @@ export const useStaticApi = ({
beforeError: hideErrorToast
? []
: [
(error) => {
void toastError(error.response);
async (error) => {
await toastError(error.response);
return error;
},

View file

@ -0,0 +1,7 @@
import { useContext } from 'react';
import { AppConfirmModalContext } from '@/contexts/AppConfirmModalProvider';
export type { ModalContentRenderProps } from '@/contexts/AppConfirmModalProvider';
export const useConfirmModal = () => useContext(AppConfirmModalContext);

View file

@ -2,7 +2,6 @@ import { useLogto } from '@logto/react';
import { t } from 'i18next';
import { useCallback } from 'react';
import { toast } from 'react-hot-toast';
import type { BareFetcher } from 'swr';
import useSWR from 'swr';
import { adminTenantEndpoint, meApi } from '@/consts';
@ -10,20 +9,15 @@ import { adminTenantEndpoint, meApi } from '@/consts';
import type { RequestError } from './use-api';
import { useStaticApi } from './use-api';
import useLogtoUserId from './use-logto-user-id';
import useSwrFetcher from './use-swr-fetcher';
const useMeCustomData = () => {
const { isAuthenticated, error: authError } = useLogto();
const userId = useLogtoUserId();
const shouldFetch = isAuthenticated && !authError && userId;
const api = useStaticApi({ prefixUrl: adminTenantEndpoint, resourceIndicator: meApi.indicator });
const fetcher = useCallback<BareFetcher>(
async (resource, init) => {
const response = await api.get(resource, init);
return response.json();
},
[api]
);
const fetcher = useSwrFetcher(api);
const { data, mutate, error } = useSWR<unknown, RequestError>(
shouldFetch && `me/custom-data`,

View file

@ -1,21 +1,22 @@
import type { RequestErrorBody } from '@logto/schemas';
import { HTTPError } from 'ky';
import type { KyInstance } from 'ky/distribution/types/ky';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import type { BareFetcher } from 'swr';
import useApi, { RequestError } from './use-api';
import { RequestError } from './use-api';
type WithTotalNumber<T> = Array<Awaited<T> | number>;
type useSwrFetcherHook = {
<T>(): BareFetcher<T>;
<T extends unknown[]>(): BareFetcher<WithTotalNumber<T>>;
<T>(api: KyInstance): BareFetcher<T>;
<T extends unknown[]>(api: KyInstance): BareFetcher<WithTotalNumber<T>>;
};
const useSwrFetcher: useSwrFetcherHook = <T>() => {
const api = useApi({ hideErrorToast: true });
const useSwrFetcher: useSwrFetcherHook = <T>(api: KyInstance) => {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const fetcher = useCallback<BareFetcher<T | WithTotalNumber<T>>>(
async (resource, init) => {
try {

View file

@ -1,10 +1,11 @@
import type { SWRConfiguration } from 'swr';
import { RequestError } from './use-api';
import useApi, { RequestError } from './use-api';
import useSwrFetcher from './use-swr-fetcher';
const useSwrOptions = (): SWRConfiguration => {
const fetcher = useSwrFetcher();
const api = useApi({ hideErrorToast: true });
const fetcher = useSwrFetcher(api);
return {
fetcher,

View file

@ -1,14 +1,7 @@
import {
adminConsoleApplicationId,
defaultTenantId,
getManagementApiResourceIndicator,
PredefinedScope,
} from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import type Router from 'koa-router';
import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import { assignInteractionResults, saveUserFirstConsentedAppId } from '#src/libraries/session.js';
import type TenantContext from '#src/tenants/TenantContext.js';
import assertThat from '#src/utils/assert-that.js';
@ -34,19 +27,6 @@ export default function consentRoutes<T>(
const { accountId } = session;
// Block non-admin user from consenting to admin console
if (String(client_id) === adminConsoleApplicationId) {
const scopes = await libraries.users.findUserScopesForResourceIndicator(
accountId,
getManagementApiResourceIndicator(defaultTenantId)
);
assertThat(
scopes.some(({ name }) => name === PredefinedScope.All),
new RequestError({ code: 'auth.forbidden', status: 401 })
);
}
const grant =
conditional(grantId && (await provider.Grant.find(grantId))) ??
new provider.Grant({ accountId, clientId: String(client_id) });