mirror of
https://github.com/logto-io/logto.git
synced 2025-01-06 20:40:08 -05:00
Merge pull request #5110 from logto-io/gao-use-organization-token
refactor(console): use organization token for management api
This commit is contained in:
commit
3f8e42af81
4 changed files with 80 additions and 79 deletions
|
@ -1,8 +1,13 @@
|
||||||
import { AppInsightsBoundary } from '@logto/app-insights/react';
|
import { AppInsightsBoundary } from '@logto/app-insights/react';
|
||||||
import { UserScope } from '@logto/core-kit';
|
import { UserScope } from '@logto/core-kit';
|
||||||
import { LogtoProvider, useLogto } from '@logto/react';
|
import { LogtoProvider, useLogto } from '@logto/react';
|
||||||
import { adminConsoleApplicationId, PredefinedScope } from '@logto/schemas';
|
import {
|
||||||
import { conditionalArray, deduplicate } from '@silverhand/essentials';
|
adminConsoleApplicationId,
|
||||||
|
defaultTenantId,
|
||||||
|
PredefinedScope,
|
||||||
|
TenantScope,
|
||||||
|
} from '@logto/schemas';
|
||||||
|
import { conditionalArray } from '@silverhand/essentials';
|
||||||
import { useContext, useMemo } from 'react';
|
import { useContext, useMemo } from 'react';
|
||||||
import { Helmet } from 'react-helmet';
|
import { Helmet } from 'react-helmet';
|
||||||
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
|
||||||
|
@ -66,22 +71,16 @@ export default App;
|
||||||
* different components.
|
* different components.
|
||||||
*/
|
*/
|
||||||
function Providers() {
|
function Providers() {
|
||||||
const { tenants, currentTenantId } = useContext(TenantsContext);
|
const { currentTenantId } = useContext(TenantsContext);
|
||||||
|
|
||||||
|
// For Cloud, we use Management API proxy for accessing tenant data.
|
||||||
|
// For OSS, we directly call the tenant API with the default tenant API resource.
|
||||||
const resources = useMemo(
|
const resources = useMemo(
|
||||||
() =>
|
() =>
|
||||||
deduplicate(
|
isCloud
|
||||||
conditionalArray(
|
? [cloudApi.indicator, meApi.indicator]
|
||||||
// Explicitly add `currentTenantId` and deduplicate since the user may directly
|
: [getManagementApi(defaultTenantId).indicator, meApi.indicator],
|
||||||
// access a URL with Tenant ID, adding the ID from the URL here can possibly remove one
|
[]
|
||||||
// additional redirect.
|
|
||||||
currentTenantId && getManagementApi(currentTenantId).indicator,
|
|
||||||
...tenants.map(({ id }) => getManagementApi(id).indicator),
|
|
||||||
isCloud && cloudApi.indicator,
|
|
||||||
meApi.indicator
|
|
||||||
)
|
|
||||||
),
|
|
||||||
[currentTenantId, tenants]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const scopes = useMemo(
|
const scopes = useMemo(
|
||||||
|
@ -92,9 +91,12 @@ function Providers() {
|
||||||
UserScope.Organizations,
|
UserScope.Organizations,
|
||||||
PredefinedScope.All,
|
PredefinedScope.All,
|
||||||
...conditionalArray(
|
...conditionalArray(
|
||||||
isCloud && cloudApi.scopes.CreateTenant,
|
isCloud && [
|
||||||
isCloud && cloudApi.scopes.ManageTenant,
|
...Object.values(TenantScope),
|
||||||
isCloud && cloudApi.scopes.ManageTenantSelf
|
cloudApi.scopes.CreateTenant,
|
||||||
|
cloudApi.scopes.ManageTenant,
|
||||||
|
cloudApi.scopes.ManageTenantSelf,
|
||||||
|
]
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
[]
|
[]
|
||||||
|
|
|
@ -1,11 +1,8 @@
|
||||||
import { useLogto } from '@logto/react';
|
import { useLogto } from '@logto/react';
|
||||||
import { trySafe } from '@silverhand/essentials';
|
|
||||||
import { useContext, useEffect } from 'react';
|
import { useContext, useEffect } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { useSWRConfig } from 'swr';
|
import { useSWRConfig } from 'swr';
|
||||||
|
|
||||||
import { type TenantResponse } from '@/cloud/types/router';
|
|
||||||
import AppLoading from '@/components/AppLoading';
|
|
||||||
// Used in the docs
|
// Used in the docs
|
||||||
// eslint-disable-next-line unused-imports/no-unused-imports
|
// eslint-disable-next-line unused-imports/no-unused-imports
|
||||||
import type ProtectedRoutes from '@/containers/ProtectedRoutes';
|
import type ProtectedRoutes from '@/containers/ProtectedRoutes';
|
||||||
|
@ -43,9 +40,8 @@ import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||||
* @see ProtectedRoutes
|
* @see ProtectedRoutes
|
||||||
*/
|
*/
|
||||||
export default function TenantAccess() {
|
export default function TenantAccess() {
|
||||||
const { getAccessToken, signIn, isAuthenticated } = useLogto();
|
const { isAuthenticated } = useLogto();
|
||||||
const { currentTenant, currentTenantId, currentTenantStatus, setCurrentTenantStatus } =
|
const { currentTenant, currentTenantId } = useContext(TenantsContext);
|
||||||
useContext(TenantsContext);
|
|
||||||
const { mutate } = useSWRConfig();
|
const { mutate } = useSWRConfig();
|
||||||
|
|
||||||
// Clean the cache when the current tenant ID changes. This is required because the
|
// Clean the cache when the current tenant ID changes. This is required because the
|
||||||
|
@ -68,39 +64,17 @@ export default function TenantAccess() {
|
||||||
}, [mutate, currentTenantId]);
|
}, [mutate, currentTenantId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const validate = async ({ indicator }: TenantResponse) => {
|
if (
|
||||||
// Test fetching an access token for the current Tenant ID.
|
isAuthenticated &&
|
||||||
// If failed, it means the user finishes the first auth, ands still needs to auth again to
|
currentTenantId &&
|
||||||
// fetch the full-scoped (with all available tenants) token.
|
|
||||||
if (await trySafe(getAccessToken(indicator))) {
|
|
||||||
setCurrentTenantStatus('validated');
|
|
||||||
}
|
|
||||||
// If failed, it will be treated as a session expired error, and will be handled by the
|
|
||||||
// upper `<ErrorBoundary />`.
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isAuthenticated && currentTenantId && currentTenantStatus === 'pending') {
|
|
||||||
setCurrentTenantStatus('validating');
|
|
||||||
|
|
||||||
// The current tenant is unavailable to the user, maybe a deleted tenant or a tenant that
|
// The current tenant is unavailable to the user, maybe a deleted tenant or a tenant that
|
||||||
// the user has no access to. Fall back to the home page.
|
// the user has no access to. Fall back to the home page.
|
||||||
if (!currentTenant) {
|
!currentTenant
|
||||||
// eslint-disable-next-line @silverhand/fp/no-mutation
|
) {
|
||||||
window.location.href = '/';
|
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||||
return;
|
window.location.href = '/';
|
||||||
}
|
|
||||||
|
|
||||||
void validate(currentTenant);
|
|
||||||
}
|
}
|
||||||
}, [
|
}, [currentTenant, currentTenantId, isAuthenticated]);
|
||||||
currentTenant,
|
|
||||||
currentTenantId,
|
|
||||||
currentTenantStatus,
|
|
||||||
getAccessToken,
|
|
||||||
isAuthenticated,
|
|
||||||
setCurrentTenantStatus,
|
|
||||||
signIn,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return currentTenantStatus === 'validated' ? <Outlet /> : <AppLoading />;
|
return <Outlet />;
|
||||||
}
|
}
|
||||||
|
|
|
@ -58,13 +58,6 @@ type Tenants = {
|
||||||
currentTenantId: string;
|
currentTenantId: string;
|
||||||
currentTenant?: TenantResponse;
|
currentTenant?: TenantResponse;
|
||||||
isDevTenant: boolean;
|
isDevTenant: boolean;
|
||||||
/**
|
|
||||||
* Indicates if the Access Token has been validated for use. Will be reset to `pending` when the current tenant changes.
|
|
||||||
*
|
|
||||||
* @see {@link CurrentTenantStatus}
|
|
||||||
*/
|
|
||||||
currentTenantStatus: CurrentTenantStatus;
|
|
||||||
setCurrentTenantStatus: (status: CurrentTenantStatus) => void;
|
|
||||||
/** Navigate to the given tenant ID. */
|
/** Navigate to the given tenant ID. */
|
||||||
navigateTenant: (tenantId: string) => void;
|
navigateTenant: (tenantId: string) => void;
|
||||||
};
|
};
|
||||||
|
@ -106,8 +99,6 @@ export const TenantsContext = createContext<Tenants>({
|
||||||
updateTenant: noop,
|
updateTenant: noop,
|
||||||
currentTenantId: '',
|
currentTenantId: '',
|
||||||
isDevTenant: false,
|
isDevTenant: false,
|
||||||
currentTenantStatus: 'pending',
|
|
||||||
setCurrentTenantStatus: noop,
|
|
||||||
navigateTenant: noop,
|
navigateTenant: noop,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -142,13 +133,10 @@ function TenantsProvider({ children }: Props) {
|
||||||
|
|
||||||
return match.params.tenantId ?? '';
|
return match.params.tenantId ?? '';
|
||||||
}, [match]);
|
}, [match]);
|
||||||
const [currentTenantStatus, setCurrentTenantStatus] = useState<CurrentTenantStatus>('pending');
|
|
||||||
|
|
||||||
const navigateTenant = useCallback(
|
const navigateTenant = useCallback(
|
||||||
(tenantId: string) => {
|
(tenantId: string) => {
|
||||||
navigate(`/${tenantId}`);
|
navigate(`/${tenantId}`);
|
||||||
|
|
||||||
setCurrentTenantStatus('pending');
|
|
||||||
},
|
},
|
||||||
[navigate]
|
[navigate]
|
||||||
);
|
);
|
||||||
|
@ -163,7 +151,6 @@ function TenantsProvider({ children }: Props) {
|
||||||
tenants,
|
tenants,
|
||||||
resetTenants: (tenants: TenantResponse[]) => {
|
resetTenants: (tenants: TenantResponse[]) => {
|
||||||
setTenants(tenants);
|
setTenants(tenants);
|
||||||
setCurrentTenantStatus('pending');
|
|
||||||
setIsInitComplete(true);
|
setIsInitComplete(true);
|
||||||
},
|
},
|
||||||
prependTenant: (tenant: TenantResponse) => {
|
prependTenant: (tenant: TenantResponse) => {
|
||||||
|
@ -181,11 +168,9 @@ function TenantsProvider({ children }: Props) {
|
||||||
currentTenantId,
|
currentTenantId,
|
||||||
isDevTenant: currentTenant?.tag === TenantTag.Development,
|
isDevTenant: currentTenant?.tag === TenantTag.Development,
|
||||||
currentTenant,
|
currentTenant,
|
||||||
currentTenantStatus,
|
|
||||||
setCurrentTenantStatus,
|
|
||||||
navigateTenant,
|
navigateTenant,
|
||||||
}),
|
}),
|
||||||
[currentTenant, currentTenantId, currentTenantStatus, isInitComplete, navigateTenant, tenants]
|
[currentTenant, currentTenantId, isInitComplete, navigateTenant, tenants]
|
||||||
);
|
);
|
||||||
|
|
||||||
return <TenantsContext.Provider value={memorizedContext}>{children}</TenantsContext.Provider>;
|
return <TenantsContext.Provider value={memorizedContext}>{children}</TenantsContext.Provider>;
|
||||||
|
|
|
@ -1,7 +1,17 @@
|
||||||
import { httpCodeToMessage } from '@logto/core-kit';
|
import {
|
||||||
|
buildOrganizationUrn,
|
||||||
|
getOrganizationIdFromUrn,
|
||||||
|
httpCodeToMessage,
|
||||||
|
organizationUrnPrefix,
|
||||||
|
} from '@logto/core-kit';
|
||||||
import { useLogto } from '@logto/react';
|
import { useLogto } from '@logto/react';
|
||||||
import { getManagementApiResourceIndicator, type RequestErrorBody } from '@logto/schemas';
|
import {
|
||||||
import { conditionalArray } from '@silverhand/essentials';
|
getTenantOrganizationId,
|
||||||
|
type RequestErrorBody,
|
||||||
|
getManagementApiResourceIndicator,
|
||||||
|
defaultTenantId,
|
||||||
|
} from '@logto/schemas';
|
||||||
|
import { appendPath, conditionalArray } from '@silverhand/essentials';
|
||||||
import ky from 'ky';
|
import ky from 'ky';
|
||||||
import { type KyInstance } from 'node_modules/ky/distribution/types/ky';
|
import { type KyInstance } from 'node_modules/ky/distribution/types/ky';
|
||||||
import { useCallback, useContext, useMemo } from 'react';
|
import { useCallback, useContext, useMemo } from 'react';
|
||||||
|
@ -9,6 +19,7 @@ import { toast } from 'react-hot-toast';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { requestTimeout } from '@/consts';
|
import { requestTimeout } from '@/consts';
|
||||||
|
import { isCloud } from '@/consts/env';
|
||||||
import { AppDataContext } from '@/contexts/AppDataProvider';
|
import { AppDataContext } from '@/contexts/AppDataProvider';
|
||||||
import { TenantsContext } from '@/contexts/TenantsProvider';
|
import { TenantsContext } from '@/contexts/TenantsProvider';
|
||||||
|
|
||||||
|
@ -35,7 +46,7 @@ export const useStaticApi = ({
|
||||||
hideErrorToast,
|
hideErrorToast,
|
||||||
resourceIndicator,
|
resourceIndicator,
|
||||||
}: StaticApiProps): KyInstance => {
|
}: StaticApiProps): KyInstance => {
|
||||||
const { isAuthenticated, getAccessToken, signOut } = useLogto();
|
const { isAuthenticated, getAccessToken, getOrganizationToken, signOut } = useLogto();
|
||||||
const { t, i18n } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
const { t, i18n } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||||
const { show } = useConfirmModal();
|
const { show } = useConfirmModal();
|
||||||
const postSignOutRedirectUri = useRedirectUri('signOut');
|
const postSignOutRedirectUri = useRedirectUri('signOut');
|
||||||
|
@ -84,7 +95,9 @@ export const useStaticApi = ({
|
||||||
beforeRequest: [
|
beforeRequest: [
|
||||||
async (request) => {
|
async (request) => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
const accessToken = await getAccessToken(resourceIndicator);
|
const accessToken = await (resourceIndicator.startsWith(organizationUrnPrefix)
|
||||||
|
? getOrganizationToken(getOrganizationIdFromUrn(resourceIndicator))
|
||||||
|
: getAccessToken(resourceIndicator));
|
||||||
request.headers.set('Authorization', `Bearer ${accessToken ?? ''}`);
|
request.headers.set('Authorization', `Bearer ${accessToken ?? ''}`);
|
||||||
request.headers.set('Accept-Language', i18n.language);
|
request.headers.set('Accept-Language', i18n.language);
|
||||||
}
|
}
|
||||||
|
@ -97,8 +110,9 @@ export const useStaticApi = ({
|
||||||
hideErrorToast,
|
hideErrorToast,
|
||||||
toastError,
|
toastError,
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
getAccessToken,
|
|
||||||
resourceIndicator,
|
resourceIndicator,
|
||||||
|
getOrganizationToken,
|
||||||
|
getAccessToken,
|
||||||
i18n.language,
|
i18n.language,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
@ -106,14 +120,40 @@ export const useStaticApi = ({
|
||||||
return api;
|
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 useApi = (props: Omit<StaticApiProps, 'prefixUrl' | 'resourceIndicator'> = {}) => {
|
||||||
const { tenantEndpoint } = useContext(AppDataContext);
|
const { tenantEndpoint } = useContext(AppDataContext);
|
||||||
const { currentTenantId } = useContext(TenantsContext);
|
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({
|
return useStaticApi({
|
||||||
...props,
|
...props,
|
||||||
prefixUrl: tenantEndpoint,
|
...config,
|
||||||
resourceIndicator: getManagementApiResourceIndicator(currentTenantId),
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue