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:
parent
2b8b3a91ec
commit
6246fc58ba
10 changed files with 182 additions and 51 deletions
10
.changeset-staged/eight-rocks-wave.md
Normal file
10
.changeset-staged/eight-rocks-wave.md
Normal 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
|
|
@ -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 />
|
||||
|
|
|
@ -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)}
|
||||
|
|
116
packages/console/src/contexts/AppConfirmModalProvider/index.tsx
Normal file
116
packages/console/src/contexts/AppConfirmModalProvider/index.tsx
Normal 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;
|
|
@ -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;
|
||||
},
|
||||
|
|
7
packages/console/src/hooks/use-confirm-modal.ts
Normal file
7
packages/console/src/hooks/use-confirm-modal.ts
Normal 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);
|
|
@ -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`,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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) });
|
||||
|
|
Loading…
Add table
Reference in a new issue