mirror of
https://github.com/logto-io/logto.git
synced 2025-04-07 23:01:25 -05:00
refactor!: adjust packages to adapt admin tenant
This commit is contained in:
parent
2af6fd114a
commit
dd5b3037a8
30 changed files with 342 additions and 132 deletions
|
@ -10,6 +10,7 @@ import {
|
|||
adminTenantId,
|
||||
defaultManagementApi,
|
||||
createManagementApiInAdminTenant,
|
||||
createMeApiInAdminTenant,
|
||||
} from '@logto/schemas';
|
||||
import { Hooks, Tenants } from '@logto/schemas/models';
|
||||
import type { DatabaseTransactionConnection } from 'slonik';
|
||||
|
@ -120,6 +121,7 @@ export const seedTables = async (
|
|||
await createTenant(connection, adminTenantId);
|
||||
await seedOidcConfigs(connection, adminTenantId);
|
||||
await seedAdminData(connection, createManagementApiInAdminTenant(defaultTenantId));
|
||||
await seedAdminData(connection, createMeApiInAdminTenant());
|
||||
|
||||
await Promise.all([
|
||||
connection.query(insertInto(createDefaultAdminConsoleConfig(), 'logto_configs')),
|
||||
|
|
|
@ -13,7 +13,7 @@ import './scss/overlayscrollbars.scss';
|
|||
import '@fontsource/roboto-mono';
|
||||
import AppLoading from '@/components/AppLoading';
|
||||
import Toast from '@/components/Toast';
|
||||
import { managementApi } from '@/consts/management-api';
|
||||
import { managementApi, meApi } from '@/consts/management-api';
|
||||
import AppBoundary from '@/containers/AppBoundary';
|
||||
import AppLayout from '@/containers/AppLayout';
|
||||
import ErrorBoundary from '@/containers/ErrorBoundary';
|
||||
|
@ -47,7 +47,7 @@ import {
|
|||
UserDetailsTabs,
|
||||
} from './consts/page-tabs';
|
||||
import AppContent from './containers/AppContent';
|
||||
import AppEndpointProvider, { AppEndpointContext } from './containers/AppEndpointProvider';
|
||||
import AppEndpointsProvider, { AppEndpointsContext } from './containers/AppEndpointsProvider';
|
||||
import ApiResourcePermissions from './pages/ApiResourceDetails/ApiResourcePermissions';
|
||||
import ApiResourceSettings from './pages/ApiResourceDetails/ApiResourceSettings';
|
||||
import CloudPreview from './pages/CloudPreview';
|
||||
|
@ -63,9 +63,9 @@ void initI18n();
|
|||
|
||||
const Main = () => {
|
||||
const swrOptions = useSwrOptions();
|
||||
const { endpoint } = useContext(AppEndpointContext);
|
||||
const { app, console } = useContext(AppEndpointsContext);
|
||||
|
||||
if (!endpoint) {
|
||||
if (!app || !console) {
|
||||
return <AppLoading />;
|
||||
}
|
||||
|
||||
|
@ -159,18 +159,18 @@ const Main = () => {
|
|||
|
||||
const App = () => (
|
||||
<BrowserRouter basename={getBasename('console', '5002')}>
|
||||
<AppEndpointProvider>
|
||||
<AppEndpointsProvider>
|
||||
<LogtoProvider
|
||||
config={{
|
||||
endpoint: window.location.origin,
|
||||
appId: adminConsoleApplicationId,
|
||||
resources: [managementApi.indicator],
|
||||
resources: [managementApi.indicator, meApi.indicator],
|
||||
scopes: [UserScope.Identities, UserScope.CustomData, managementApi.scopeAll],
|
||||
}}
|
||||
>
|
||||
<Main />
|
||||
</LogtoProvider>
|
||||
</AppEndpointProvider>
|
||||
</AppEndpointsProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import type { ResourceResponse, Scope, ScopeResponse } from '@logto/schemas';
|
||||
import { managementApiScopeAll } from '@logto/schemas';
|
||||
import { PredefinedScope } from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import classNames from 'classnames';
|
||||
import type { ChangeEvent } from 'react';
|
||||
|
@ -79,7 +79,7 @@ const SourceScopesBox = ({ roleId, selectedScopes, onChange }: Props) => {
|
|||
}
|
||||
|
||||
const existingScopeIds = roleScopes?.map(({ id }) => id) ?? [];
|
||||
const excludeScopeIds = new Set([...existingScopeIds, managementApiScopeAll]);
|
||||
const excludeScopeIds = new Set([...existingScopeIds, PredefinedScope.All]);
|
||||
|
||||
return allResources
|
||||
.filter(({ scopes }) => scopes.some(({ id }) => !excludeScopeIds.has(id)))
|
||||
|
|
|
@ -1,10 +1,16 @@
|
|||
import {
|
||||
adminTenantId,
|
||||
defaultTenantId,
|
||||
getManagementApiResourceIndicator,
|
||||
managementApiScopeAll,
|
||||
PredefinedScope,
|
||||
} from '@logto/schemas';
|
||||
|
||||
export const managementApi = Object.freeze({
|
||||
indicator: getManagementApiResourceIndicator(defaultTenantId),
|
||||
scopeAll: managementApiScopeAll,
|
||||
scopeAll: PredefinedScope.All,
|
||||
});
|
||||
|
||||
export const meApi = Object.freeze({
|
||||
indicator: getManagementApiResourceIndicator(adminTenantId, 'me'),
|
||||
scopeAll: PredefinedScope.All,
|
||||
});
|
||||
|
|
|
@ -1,31 +0,0 @@
|
|||
import ky from 'ky';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, useEffect, createContext, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const AppEndpointContext = createContext<{ endpoint?: URL }>({});
|
||||
|
||||
const AppEndpointProvider = ({ children }: Props) => {
|
||||
const [endpoint, setEndpoint] = useState<URL>();
|
||||
const memorizedContext = useMemo(() => ({ endpoint }), [endpoint]);
|
||||
|
||||
useEffect(() => {
|
||||
const getEndpoint = async () => {
|
||||
const { app } = await ky
|
||||
.get(new URL('api/.well-known/endpoints', window.location.origin))
|
||||
.json<{ app: string }>();
|
||||
setEndpoint(new URL(app));
|
||||
};
|
||||
|
||||
void getEndpoint();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppEndpointContext.Provider value={memorizedContext}>{children}</AppEndpointContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppEndpointProvider;
|
|
@ -0,0 +1,38 @@
|
|||
import ky from 'ky';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, useEffect, createContext, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export type AppEndpoints = {
|
||||
app?: URL;
|
||||
console?: URL;
|
||||
};
|
||||
|
||||
export type AppEndpointKey = keyof AppEndpoints;
|
||||
|
||||
export const AppEndpointsContext = createContext<AppEndpoints>({});
|
||||
|
||||
const AppEndpointsProvider = ({ children }: Props) => {
|
||||
const [endpoints, setEndpoints] = useState<AppEndpoints>({});
|
||||
const memorizedContext = useMemo(() => endpoints, [endpoints]);
|
||||
|
||||
useEffect(() => {
|
||||
const getEndpoint = async () => {
|
||||
const { app, console } = await ky
|
||||
.get(new URL('api/.well-known/endpoints', window.location.origin))
|
||||
.json<{ app: string; console: string }>();
|
||||
setEndpoints({ app: new URL(app), console: new URL(console) });
|
||||
};
|
||||
|
||||
void getEndpoint();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AppEndpointsContext.Provider value={memorizedContext}>{children}</AppEndpointsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppEndpointsProvider;
|
|
@ -6,7 +6,8 @@ import { toast } from 'react-hot-toast';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { managementApi, requestTimeout } from '@/consts';
|
||||
import { AppEndpointContext } from '@/containers/AppEndpointProvider';
|
||||
import type { AppEndpointKey } from '@/containers/AppEndpointsProvider';
|
||||
import { AppEndpointsContext } from '@/containers/AppEndpointsProvider';
|
||||
|
||||
export class RequestError extends Error {
|
||||
status: number;
|
||||
|
@ -20,11 +21,17 @@ export class RequestError extends Error {
|
|||
}
|
||||
|
||||
type Props = {
|
||||
endpointKey?: AppEndpointKey;
|
||||
hideErrorToast?: boolean;
|
||||
resourceIndicator?: string;
|
||||
};
|
||||
|
||||
const useApi = ({ hideErrorToast }: Props = {}) => {
|
||||
const { endpoint } = useContext(AppEndpointContext);
|
||||
const useApi = ({
|
||||
hideErrorToast,
|
||||
endpointKey = 'app',
|
||||
resourceIndicator = managementApi.indicator,
|
||||
}: Props = {}) => {
|
||||
const endpoints = useContext(AppEndpointsContext);
|
||||
const { isAuthenticated, getAccessToken } = useLogto();
|
||||
const { t, i18n } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
|
||||
|
@ -45,7 +52,7 @@ const useApi = ({ hideErrorToast }: Props = {}) => {
|
|||
const api = useMemo(
|
||||
() =>
|
||||
ky.create({
|
||||
prefixUrl: endpoint,
|
||||
prefixUrl: endpoints[endpointKey],
|
||||
timeout: requestTimeout,
|
||||
hooks: {
|
||||
beforeError: hideErrorToast
|
||||
|
@ -60,7 +67,7 @@ const useApi = ({ hideErrorToast }: Props = {}) => {
|
|||
beforeRequest: [
|
||||
async (request) => {
|
||||
if (isAuthenticated) {
|
||||
const accessToken = await getAccessToken(managementApi.indicator);
|
||||
const accessToken = await getAccessToken(resourceIndicator);
|
||||
request.headers.set('Authorization', `Bearer ${accessToken ?? ''}`);
|
||||
request.headers.set('Accept-Language', i18n.language);
|
||||
}
|
||||
|
@ -68,7 +75,16 @@ const useApi = ({ hideErrorToast }: Props = {}) => {
|
|||
],
|
||||
},
|
||||
}),
|
||||
[endpoint, hideErrorToast, toastError, isAuthenticated, getAccessToken, i18n.language]
|
||||
[
|
||||
endpoints,
|
||||
endpointKey,
|
||||
hideErrorToast,
|
||||
toastError,
|
||||
isAuthenticated,
|
||||
getAccessToken,
|
||||
resourceIndicator,
|
||||
i18n.language,
|
||||
]
|
||||
);
|
||||
|
||||
return api;
|
||||
|
|
|
@ -6,17 +6,17 @@ import type { BareFetcher } from 'swr';
|
|||
|
||||
import useApi, { RequestError } from './use-api';
|
||||
|
||||
type withTotalNumber<T> = Array<Awaited<T> | number>;
|
||||
type WithTotalNumber<T> = Array<Awaited<T> | number>;
|
||||
|
||||
type useSwrFetcherHook = {
|
||||
<T>(): BareFetcher<T>;
|
||||
<T extends unknown[]>(): BareFetcher<withTotalNumber<T>>;
|
||||
<T extends unknown[]>(): BareFetcher<WithTotalNumber<T>>;
|
||||
};
|
||||
|
||||
const useSwrFetcher: useSwrFetcherHook = <T>() => {
|
||||
const api = useApi({ hideErrorToast: true });
|
||||
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
|
||||
const fetcher = useCallback<BareFetcher<T | withTotalNumber<T>>>(
|
||||
const fetcher = useCallback<BareFetcher<T | WithTotalNumber<T>>>(
|
||||
async (resource, init) => {
|
||||
try {
|
||||
const response = await api.get(resource, init);
|
||||
|
|
|
@ -5,10 +5,11 @@ import type { Nullable, Optional } from '@silverhand/essentials';
|
|||
import { t } from 'i18next';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import type { BareFetcher } from 'swr';
|
||||
import useSWR from 'swr';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { themeStorageKey } from '@/consts';
|
||||
import { meApi, themeStorageKey } from '@/consts';
|
||||
|
||||
import type { RequestError } from './use-api';
|
||||
import useApi from './use-api';
|
||||
|
@ -35,10 +36,19 @@ const useUserPreferences = () => {
|
|||
const { isAuthenticated, error: authError } = useLogto();
|
||||
const userId = useLogtoUserId();
|
||||
const shouldFetch = isAuthenticated && !authError && userId;
|
||||
const { data, mutate, error } = useSWR<unknown, RequestError>(
|
||||
shouldFetch && `api/users/${userId}/custom-data`
|
||||
const api = useApi({ endpointKey: 'console', resourceIndicator: meApi.indicator });
|
||||
const fetcher = useCallback<BareFetcher>(
|
||||
async (resource, init) => {
|
||||
const response = await api.get(resource, init);
|
||||
|
||||
return response.json();
|
||||
},
|
||||
[api]
|
||||
);
|
||||
const { data, mutate, error } = useSWR<unknown, RequestError>(
|
||||
shouldFetch && `me/custom-data`,
|
||||
fetcher
|
||||
);
|
||||
const api = useApi();
|
||||
|
||||
const parseData = useCallback((): UserPreferences => {
|
||||
try {
|
||||
|
@ -62,13 +72,11 @@ const useUserPreferences = () => {
|
|||
}
|
||||
|
||||
const updated = await api
|
||||
.patch(`api/users/${userId}/custom-data`, {
|
||||
.patch(`me/custom-data`, {
|
||||
json: {
|
||||
customData: {
|
||||
[key]: {
|
||||
...userPreferences,
|
||||
...data,
|
||||
},
|
||||
[key]: {
|
||||
...userPreferences,
|
||||
...data,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
@ -5,13 +5,14 @@ import { ConnectorType, AppearanceMode } from '@logto/schemas';
|
|||
import { conditional } from '@silverhand/essentials';
|
||||
import classNames from 'classnames';
|
||||
import { format } from 'date-fns';
|
||||
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
||||
import { useEffect, useMemo, useState, useRef, useCallback, useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import PhoneInfo from '@/assets/images/phone-info.svg';
|
||||
import Select from '@/components/Select';
|
||||
import TabNav, { TabNavItem } from '@/components/TabNav';
|
||||
import { AppEndpointsContext } from '@/containers/AppEndpointsProvider';
|
||||
import type { RequestError } from '@/hooks/use-api';
|
||||
import useUiLanguages from '@/hooks/use-ui-languages';
|
||||
|
||||
|
@ -29,9 +30,8 @@ const Preview = ({ signInExperience, className }: Props) => {
|
|||
const [platform, setPlatform] = useState<'desktopWeb' | 'mobile' | 'mobileWeb'>('desktopWeb');
|
||||
const { data: allConnectors } = useSWR<ConnectorResponse[], RequestError>('api/connectors');
|
||||
const previewRef = useRef<HTMLIFrameElement>(null);
|
||||
const { customPhrases } = useUiLanguages();
|
||||
|
||||
const { languages } = useUiLanguages();
|
||||
const { customPhrases, languages } = useUiLanguages();
|
||||
const { app: appEndpoint } = useContext(AppEndpointsContext);
|
||||
|
||||
const modeOptions = useMemo(() => {
|
||||
const light = { value: AppearanceMode.LightMode, title: t('sign_in_exp.preview.light') };
|
||||
|
@ -120,9 +120,9 @@ const Preview = ({ signInExperience, className }: Props) => {
|
|||
|
||||
previewRef.current?.contentWindow?.postMessage(
|
||||
{ sender: 'ac_preview', config },
|
||||
window.location.origin
|
||||
appEndpoint?.origin ?? ''
|
||||
);
|
||||
}, [config, customPhrases]);
|
||||
}, [appEndpoint?.origin, config, customPhrases]);
|
||||
|
||||
useEffect(() => {
|
||||
postPreviewMessage();
|
||||
|
@ -210,7 +210,7 @@ const Preview = ({ signInExperience, className }: Props) => {
|
|||
ref={previewRef}
|
||||
// Allow all sandbox rules
|
||||
sandbox={undefined}
|
||||
src="/sign-in?preview=true"
|
||||
src={new URL('/sign-in?preview=true', appEndpoint).toString()}
|
||||
tabIndex={-1}
|
||||
title={t('sign_in_exp.preview.title')}
|
||||
/>
|
||||
|
|
|
@ -37,12 +37,6 @@ export default class UrlSet {
|
|||
}
|
||||
|
||||
public get endpoint() {
|
||||
const value = this.#endpoint || this.localhostUrl;
|
||||
|
||||
if (this.isLocalhostDisabled && new URL(value).hostname === 'localhost') {
|
||||
throw new Error(localhostDisabledMessage);
|
||||
}
|
||||
|
||||
return value;
|
||||
return this.#endpoint || this.localhostUrl;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import { adminTenantId } from '@logto/schemas';
|
||||
import type { Optional } from '@silverhand/essentials';
|
||||
import { trySafe } from '@silverhand/essentials';
|
||||
import type { PostgreSql } from '@withtyped/postgres';
|
||||
import type { QueryClient } from '@withtyped/server';
|
||||
import type { DatabasePool } from 'slonik';
|
||||
|
@ -13,7 +15,8 @@ import createQueryClient from './create-query-client.js';
|
|||
import loadOidcValues from './oidc.js';
|
||||
import { throwNotLoadedError } from './throw-errors.js';
|
||||
|
||||
export enum MountedApps {
|
||||
/** Apps (also paths) for user tenants. */
|
||||
export enum UserApps {
|
||||
Api = 'api',
|
||||
Oidc = 'oidc',
|
||||
Console = 'console',
|
||||
|
@ -21,6 +24,26 @@ export enum MountedApps {
|
|||
Welcome = 'welcome',
|
||||
}
|
||||
|
||||
/** Apps (also paths) ONLY for the admin tenant. */
|
||||
export enum AdminApps {
|
||||
Me = 'me',
|
||||
}
|
||||
|
||||
const getTenantEndpoint = (id: string) => {
|
||||
const { urlSet, adminUrlSet, isDomainBasedMultiTenancy } = EnvSet.values;
|
||||
const adminUrl = trySafe(() => adminUrlSet.endpoint);
|
||||
|
||||
if (adminUrl && id === adminTenantId) {
|
||||
return adminUrl;
|
||||
}
|
||||
|
||||
if (!isDomainBasedMultiTenancy) {
|
||||
return urlSet.endpoint;
|
||||
}
|
||||
|
||||
return urlSet.endpoint.replace('*', id);
|
||||
};
|
||||
|
||||
export class EnvSet {
|
||||
static values = new GlobalValues();
|
||||
|
||||
|
@ -43,7 +66,7 @@ export class EnvSet {
|
|||
#queryClient: Optional<QueryClient<PostgreSql>>;
|
||||
#oidc: Optional<Awaited<ReturnType<typeof loadOidcValues>>>;
|
||||
|
||||
constructor(public readonly databaseUrl: string) {}
|
||||
constructor(public readonly tenantId: string, public readonly databaseUrl: string) {}
|
||||
|
||||
get pool() {
|
||||
if (!this.#pool) {
|
||||
|
@ -86,9 +109,7 @@ export class EnvSet {
|
|||
const { getOidcConfigs } = createLogtoConfigLibrary(createLogtoConfigQueries(pool));
|
||||
|
||||
const oidcConfigs = await getOidcConfigs();
|
||||
this.#oidc = await loadOidcValues(
|
||||
appendPath(EnvSet.values.endpoint, '/oidc').toString(),
|
||||
oidcConfigs
|
||||
);
|
||||
const endpoint = getTenantEndpoint(this.tenantId);
|
||||
this.#oidc = await loadOidcValues(appendPath(endpoint, '/oidc').toString(), oidcConfigs);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,6 +24,7 @@ const loadOidcValues = async (issuer: string, configs: LogtoOidcConfigType) => {
|
|||
return Object.freeze({
|
||||
cookieKeys,
|
||||
privateJwks,
|
||||
publicJwks,
|
||||
jwkSigningAlg,
|
||||
localJWKSet,
|
||||
issuer,
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
import type { IncomingHttpHeaders } from 'http';
|
||||
|
||||
import { defaultManagementApi } from '@logto/schemas';
|
||||
import { adminTenantId, defaultManagementApi } from '@logto/schemas';
|
||||
import type { Optional } from '@silverhand/essentials';
|
||||
import { jwtVerify } from 'jose';
|
||||
import type { JWK } from 'jose';
|
||||
import { createLocalJWKSet, jwtVerify } from 'jose';
|
||||
import type { MiddlewareType, Request } from 'koa';
|
||||
import type { IRouterParamContext } from 'koa-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { tenantPool } from '#src/tenants/index.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
export type Auth = {
|
||||
|
@ -23,7 +25,7 @@ export type WithAuthContext<ContextT extends IRouterParamContext = IRouterParamC
|
|||
|
||||
const bearerTokenIdentifier = 'Bearer';
|
||||
|
||||
const extractBearerTokenFromHeaders = ({ authorization }: IncomingHttpHeaders) => {
|
||||
export const extractBearerTokenFromHeaders = ({ authorization }: IncomingHttpHeaders) => {
|
||||
assertThat(
|
||||
authorization,
|
||||
new RequestError({ code: 'auth.authorization_header_missing', status: 401 })
|
||||
|
@ -48,7 +50,7 @@ type TokenInfo = {
|
|||
export const verifyBearerTokenFromRequest = async (
|
||||
envSet: EnvSet,
|
||||
request: Request,
|
||||
resourceIndicator: Optional<string>
|
||||
audience: Optional<string>
|
||||
): Promise<TokenInfo> => {
|
||||
const { isProduction, isIntegrationTest, developmentUserId } = EnvSet.values;
|
||||
const userId = request.headers['development-user-id']?.toString() ?? developmentUserId;
|
||||
|
@ -59,14 +61,35 @@ export const verifyBearerTokenFromRequest = async (
|
|||
return { sub: userId, clientId: undefined, scopes: [defaultManagementApi.scope.name] };
|
||||
}
|
||||
|
||||
const getKeysAndIssuer = async (): Promise<[JWK[], string[]]> => {
|
||||
const { publicJwks, issuer } = envSet.oidc;
|
||||
|
||||
if (envSet.tenantId === adminTenantId) {
|
||||
return [publicJwks, [issuer]];
|
||||
}
|
||||
|
||||
const {
|
||||
envSet: { oidc: adminOidc },
|
||||
} = await tenantPool.get(adminTenantId);
|
||||
|
||||
return [
|
||||
[...publicJwks, ...adminOidc.publicJwks],
|
||||
[issuer, adminOidc.issuer],
|
||||
];
|
||||
};
|
||||
|
||||
try {
|
||||
const { localJWKSet, issuer } = envSet.oidc;
|
||||
const [keys, issuer] = await getKeysAndIssuer();
|
||||
const {
|
||||
payload: { sub, client_id: clientId, scope = '' },
|
||||
} = await jwtVerify(extractBearerTokenFromHeaders(request.headers), localJWKSet, {
|
||||
issuer,
|
||||
audience: resourceIndicator,
|
||||
});
|
||||
} = await jwtVerify(
|
||||
extractBearerTokenFromHeaders(request.headers),
|
||||
createLocalJWKSet({ keys }),
|
||||
{
|
||||
issuer,
|
||||
audience,
|
||||
}
|
||||
);
|
||||
|
||||
assertThat(sub, new RequestError({ code: 'auth.jwt_sub_missing', status: 401 }));
|
||||
|
||||
|
@ -81,17 +104,19 @@ export const verifyBearerTokenFromRequest = async (
|
|||
};
|
||||
|
||||
export default function koaAuth<StateT, ContextT extends IRouterParamContext, ResponseBodyT>(
|
||||
envSet: EnvSet
|
||||
envSet: EnvSet,
|
||||
audience: string,
|
||||
expectScopes = [defaultManagementApi.scope.name]
|
||||
): MiddlewareType<StateT, WithAuthContext<ContextT>, ResponseBodyT> {
|
||||
return async (ctx, next) => {
|
||||
const { sub, clientId, scopes } = await verifyBearerTokenFromRequest(
|
||||
envSet,
|
||||
ctx.request,
|
||||
defaultManagementApi.resource.indicator
|
||||
audience
|
||||
);
|
||||
|
||||
assertThat(
|
||||
scopes.includes(defaultManagementApi.scope.name),
|
||||
expectScopes.every((scope) => scopes.includes(scope)),
|
||||
new RequestError({ code: 'auth.forbidden', status: 403 })
|
||||
);
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { pickDefault, createMockUtils } from '@logto/shared/esm';
|
||||
import Sinon from 'sinon';
|
||||
|
||||
import { EnvSet, MountedApps } from '#src/env-set/index.js';
|
||||
import { EnvSet, UserApps } from '#src/env-set/index.js';
|
||||
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
@ -10,6 +10,7 @@ const { mockEsmDefault } = createMockUtils(jest);
|
|||
|
||||
const mockProxyMiddleware = jest.fn();
|
||||
const mockStaticMiddleware = jest.fn();
|
||||
const mountedApps = Object.values(UserApps);
|
||||
|
||||
mockEsmDefault('fs/promises', () => ({
|
||||
readdir: jest.fn().mockResolvedValue(['sign-in']),
|
||||
|
@ -34,14 +35,14 @@ describe('koaSpaProxy middleware', () => {
|
|||
|
||||
const next = jest.fn();
|
||||
|
||||
for (const app of Object.values(MountedApps)) {
|
||||
for (const app of Object.values(UserApps)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-loop-func
|
||||
it(`${app} path should not call dev proxy`, async () => {
|
||||
const ctx = createContextWithRouteParameters({
|
||||
url: `/${app}/foo`,
|
||||
});
|
||||
|
||||
await koaSpaProxy()(ctx, next);
|
||||
await koaSpaProxy(mountedApps)(ctx, next);
|
||||
|
||||
expect(mockProxyMiddleware).not.toBeCalled();
|
||||
});
|
||||
|
@ -49,7 +50,7 @@ describe('koaSpaProxy middleware', () => {
|
|||
|
||||
it('dev env should call dev proxy for SPA paths', async () => {
|
||||
const ctx = createContextWithRouteParameters();
|
||||
await koaSpaProxy()(ctx, next);
|
||||
await koaSpaProxy(mountedApps)(ctx, next);
|
||||
expect(mockProxyMiddleware).toBeCalled();
|
||||
});
|
||||
|
||||
|
@ -63,7 +64,7 @@ describe('koaSpaProxy middleware', () => {
|
|||
url: '/foo',
|
||||
});
|
||||
|
||||
await koaSpaProxy()(ctx, next);
|
||||
await koaSpaProxy(mountedApps)(ctx, next);
|
||||
|
||||
expect(mockStaticMiddleware).toBeCalled();
|
||||
expect(ctx.request.path).toEqual('/');
|
||||
|
@ -80,7 +81,7 @@ describe('koaSpaProxy middleware', () => {
|
|||
url: '/sign-in',
|
||||
});
|
||||
|
||||
await koaSpaProxy()(ctx, next);
|
||||
await koaSpaProxy(mountedApps)(ctx, next);
|
||||
expect(mockStaticMiddleware).toBeCalled();
|
||||
stub.restore();
|
||||
});
|
||||
|
|
|
@ -5,10 +5,11 @@ import type { MiddlewareType } from 'koa';
|
|||
import proxy from 'koa-proxies';
|
||||
import type { IRouterParamContext } from 'koa-router';
|
||||
|
||||
import { EnvSet, MountedApps } from '#src/env-set/index.js';
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import serveStatic from '#src/middleware/koa-serve-static.js';
|
||||
|
||||
export default function koaSpaProxy<StateT, ContextT extends IRouterParamContext, ResponseBodyT>(
|
||||
mountedApps: string[],
|
||||
packagePath = 'ui',
|
||||
port = 5001,
|
||||
prefix = ''
|
||||
|
@ -40,7 +41,7 @@ export default function koaSpaProxy<StateT, ContextT extends IRouterParamContext
|
|||
// Route has been handled by one of mounted apps
|
||||
if (
|
||||
!prefix &&
|
||||
Object.values(MountedApps).some((app) => app !== prefix && requestPath.startsWith(`/${app}`))
|
||||
Object.values(mountedApps).some((app) => app !== prefix && requestPath.startsWith(`/${app}`))
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { createMockUtils } from '@logto/shared/esm';
|
||||
import Provider from 'oidc-provider';
|
||||
|
||||
import { MountedApps } from '#src/env-set/index.js';
|
||||
import { UserApps } from '#src/env-set/index.js';
|
||||
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
@ -34,7 +34,7 @@ describe('koaSpaSessionGuard', () => {
|
|||
|
||||
const next = jest.fn();
|
||||
|
||||
for (const app of Object.values(MountedApps)) {
|
||||
for (const app of Object.values(UserApps)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-loop-func
|
||||
it(`${app} path should not redirect`, async () => {
|
||||
const ctx = createContextWithRouteParameters({
|
||||
|
|
|
@ -7,7 +7,7 @@ import type { AdapterFactory, AllClientMetadata } from 'oidc-provider';
|
|||
import { errors } from 'oidc-provider';
|
||||
import snakecaseKeys from 'snakecase-keys';
|
||||
|
||||
import { EnvSet, MountedApps } from '#src/env-set/index.js';
|
||||
import { EnvSet, UserApps } from '#src/env-set/index.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
import { appendPath } from '#src/utils/url.js';
|
||||
|
||||
|
@ -30,7 +30,7 @@ const buildDemoAppUris = (
|
|||
oidcClientMetadata: OidcClientMetadata
|
||||
): Pick<OidcClientMetadata, 'redirectUris' | 'postLogoutRedirectUris'> => {
|
||||
const { urlSet } = EnvSet.values;
|
||||
const urls = urlSet.deduplicated().map((url) => appendPath(url, MountedApps.DemoApp).toString());
|
||||
const urls = urlSet.deduplicated().map((url) => appendPath(url, UserApps.DemoApp).toString());
|
||||
|
||||
const data = {
|
||||
redirectUris: deduplicate([...urls, ...oidcClientMetadata.redirectUris]),
|
||||
|
|
76
packages/core/src/routes-me/init.ts
Normal file
76
packages/core/src/routes-me/init.ts
Normal file
|
@ -0,0 +1,76 @@
|
|||
import {
|
||||
adminTenantId,
|
||||
arbitraryObjectGuard,
|
||||
getManagementApiResourceIndicator,
|
||||
PredefinedScope,
|
||||
} from '@logto/schemas';
|
||||
import Koa from 'koa';
|
||||
import Router from 'koa-router';
|
||||
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import type { WithAuthContext } from '#src/middleware/koa-auth.js';
|
||||
import koaAuth from '#src/middleware/koa-auth.js';
|
||||
import koaGuard from '#src/middleware/koa-guard.js';
|
||||
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
export default function initMeApis(tenant: TenantContext): Koa {
|
||||
if (tenant.id !== adminTenantId) {
|
||||
throw new Error('`/me` routes should only be initialized in the admin tenant.');
|
||||
}
|
||||
|
||||
const { findUserById, updateUserById } = tenant.queries.users;
|
||||
const meRouter = new Router<unknown, WithAuthContext>();
|
||||
|
||||
console.log('????', getManagementApiResourceIndicator(adminTenantId, 'me'));
|
||||
|
||||
meRouter.use(
|
||||
koaAuth(tenant.envSet, getManagementApiResourceIndicator(adminTenantId, 'me'), [
|
||||
PredefinedScope.All,
|
||||
]),
|
||||
async (ctx, next) => {
|
||||
assertThat(
|
||||
ctx.auth.type === 'user',
|
||||
new RequestError({ code: 'auth.forbidden', status: 403 })
|
||||
);
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
meRouter.get('/custom-data', async (ctx, next) => {
|
||||
const { id: userId } = ctx.auth;
|
||||
const user = await findUserById(userId);
|
||||
|
||||
ctx.body = user.customData;
|
||||
|
||||
return next();
|
||||
});
|
||||
|
||||
meRouter.patch(
|
||||
'/custom-data',
|
||||
koaGuard({
|
||||
body: arbitraryObjectGuard,
|
||||
response: arbitraryObjectGuard,
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const { id: userId } = ctx.auth;
|
||||
const { body: customData } = ctx.guard;
|
||||
|
||||
await findUserById(userId);
|
||||
|
||||
const user = await updateUserById(userId, {
|
||||
customData,
|
||||
});
|
||||
|
||||
ctx.body = user.customData;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
const meApp = new Koa();
|
||||
meApp.use(meRouter.routes()).use(meRouter.allowedMethods());
|
||||
|
||||
return meApp;
|
||||
}
|
|
@ -19,7 +19,6 @@ export default function adminUserRoutes<T extends AuthedRouter>(
|
|||
) {
|
||||
const {
|
||||
oidcModelInstances: { revokeInstanceByUserId },
|
||||
roles: { findRolesByRoleNames },
|
||||
users: {
|
||||
deleteUserById,
|
||||
deleteUserIdentity,
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import cors from '@koa/cors';
|
||||
import { getManagementApiResourceIndicator, PredefinedScope } from '@logto/schemas';
|
||||
import Koa from 'koa';
|
||||
import Router from 'koa-router';
|
||||
|
||||
|
@ -34,7 +35,9 @@ const createRouters = (tenant: TenantContext) => {
|
|||
interactionRoutes(interactionRouter, tenant);
|
||||
|
||||
const managementRouter: AuthedRouter = new Router();
|
||||
managementRouter.use(koaAuth(tenant.envSet));
|
||||
managementRouter.use(
|
||||
koaAuth(tenant.envSet, getManagementApiResourceIndicator(tenant.id), [PredefinedScope.All])
|
||||
);
|
||||
applicationRoutes(managementRouter, tenant);
|
||||
logtoConfigRoutes(managementRouter, tenant);
|
||||
connectorRoutes(managementRouter, tenant);
|
||||
|
@ -62,7 +65,7 @@ const createRouters = (tenant: TenantContext) => {
|
|||
return [interactionRouter, managementRouter, anonymousRouter];
|
||||
};
|
||||
|
||||
export default function initRouter(tenant: TenantContext): Koa {
|
||||
export default function initApis(tenant: TenantContext): Koa {
|
||||
const apisApp = new Koa();
|
||||
|
||||
apisApp.use(
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import type { User, Profile } from '@logto/schemas';
|
||||
import {
|
||||
UserRole,
|
||||
getManagementApiAdminName,
|
||||
defaultTenantId,
|
||||
adminTenantId,
|
||||
|
@ -173,7 +174,7 @@ export default async function submitInteraction(
|
|||
id,
|
||||
...upsertProfile,
|
||||
},
|
||||
createAdminUser ? [getManagementApiAdminName(defaultTenantId)] : []
|
||||
createAdminUser ? [getManagementApiAdminName(defaultTenantId), UserRole.User] : []
|
||||
);
|
||||
|
||||
await assignInteractionResults(ctx, provider, { login: { accountId: id } });
|
||||
|
|
|
@ -2,7 +2,7 @@ import {
|
|||
adminConsoleApplicationId,
|
||||
defaultTenantId,
|
||||
getManagementApiResourceIndicator,
|
||||
managementApiScopeAll,
|
||||
PredefinedScope,
|
||||
} from '@logto/schemas';
|
||||
import { conditional } from '@silverhand/essentials';
|
||||
import type Router from 'koa-router';
|
||||
|
@ -42,7 +42,7 @@ export default function consentRoutes<T>(
|
|||
);
|
||||
|
||||
assertThat(
|
||||
scopes.some(({ name }) => name === managementApiScopeAll),
|
||||
scopes.some(({ name }) => name === PredefinedScope.All),
|
||||
new RequestError({ code: 'auth.forbidden', status: 401 })
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { adminTenantId } from '@logto/schemas';
|
||||
import type { MiddlewareType } from 'koa';
|
||||
import Koa from 'koa';
|
||||
import compose from 'koa-compose';
|
||||
|
@ -5,7 +6,7 @@ import koaLogger from 'koa-logger';
|
|||
import mount from 'koa-mount';
|
||||
import type Provider from 'oidc-provider';
|
||||
|
||||
import { EnvSet, MountedApps } from '#src/env-set/index.js';
|
||||
import { AdminApps, EnvSet, UserApps } from '#src/env-set/index.js';
|
||||
import koaCheckDemoApp from '#src/middleware/koa-check-demo-app.js';
|
||||
import koaConnectorErrorHandler from '#src/middleware/koa-connector-error-handler.js';
|
||||
import koaConsoleRedirectProxy from '#src/middleware/koa-console-redirect-proxy.js';
|
||||
|
@ -18,7 +19,8 @@ import koaSpaSessionGuard from '#src/middleware/koa-spa-session-guard.js';
|
|||
import type { ModelRouters } from '#src/model-routers/index.js';
|
||||
import { createModelRouters } from '#src/model-routers/index.js';
|
||||
import initOidc from '#src/oidc/init.js';
|
||||
import initRouter from '#src/routes/init.js';
|
||||
import initMeApis from '#src/routes-me/init.js';
|
||||
import initApis from '#src/routes/init.js';
|
||||
|
||||
import Libraries from './Libraries.js';
|
||||
import Queries from './Queries.js';
|
||||
|
@ -28,7 +30,7 @@ import { getTenantDatabaseDsn } from './utils.js';
|
|||
export default class Tenant implements TenantContext {
|
||||
static async create(id: string): Promise<Tenant> {
|
||||
// Treat the default database URL as the management URL
|
||||
const envSet = new EnvSet(await getTenantDatabaseDsn(id));
|
||||
const envSet = new EnvSet(id, await getTenantDatabaseDsn(id));
|
||||
await envSet.load();
|
||||
|
||||
return new Tenant(envSet, id);
|
||||
|
@ -49,6 +51,11 @@ export default class Tenant implements TenantContext {
|
|||
const modelRouters = createModelRouters(envSet.queryClient);
|
||||
const queries = new Queries(envSet.pool);
|
||||
const libraries = new Libraries(queries, modelRouters);
|
||||
const isAdminTenant = id === adminTenantId;
|
||||
const mountedApps = [
|
||||
...Object.values(UserApps),
|
||||
...(isAdminTenant ? Object.values(AdminApps) : []),
|
||||
];
|
||||
|
||||
this.envSet = envSet;
|
||||
this.modelRouters = modelRouters;
|
||||
|
@ -68,29 +75,38 @@ export default class Tenant implements TenantContext {
|
|||
app.use(koaConnectorErrorHandler());
|
||||
app.use(koaI18next());
|
||||
|
||||
const tenantContext: TenantContext = { id, provider, queries, libraries, modelRouters, envSet };
|
||||
// Mount APIs
|
||||
const apisApp = initRouter({ provider, queries, libraries, modelRouters, envSet });
|
||||
app.use(mount('/api', apisApp));
|
||||
app.use(mount('/api', initApis(tenantContext)));
|
||||
|
||||
// Mount `/me` APIs for admin tenant
|
||||
if (id === adminTenantId) {
|
||||
console.log('111111111111122221');
|
||||
app.use(mount('/me', initMeApis(tenantContext)));
|
||||
}
|
||||
|
||||
// Mount Admin Console
|
||||
app.use(koaConsoleRedirectProxy(queries));
|
||||
app.use(
|
||||
mount('/' + MountedApps.Console, koaSpaProxy(MountedApps.Console, 5002, MountedApps.Console))
|
||||
mount(
|
||||
'/' + UserApps.Console,
|
||||
koaSpaProxy(mountedApps, UserApps.Console, 5002, UserApps.Console)
|
||||
)
|
||||
);
|
||||
|
||||
// Mount demo app
|
||||
app.use(
|
||||
mount(
|
||||
'/' + MountedApps.DemoApp,
|
||||
'/' + UserApps.DemoApp,
|
||||
compose([
|
||||
koaCheckDemoApp(this.queries),
|
||||
koaSpaProxy(MountedApps.DemoApp, 5003, MountedApps.DemoApp),
|
||||
koaSpaProxy(mountedApps, UserApps.DemoApp, 5003, UserApps.DemoApp),
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
// Mount UI
|
||||
app.use(compose([koaSpaSessionGuard(provider), koaSpaProxy()]));
|
||||
app.use(compose([koaSpaSessionGuard(provider), koaSpaProxy(mountedApps)]));
|
||||
|
||||
this.app = app;
|
||||
this.provider = provider;
|
||||
|
|
|
@ -7,6 +7,7 @@ import type Libraries from './Libraries.js';
|
|||
import type Queries from './Queries.js';
|
||||
|
||||
export default abstract class TenantContext {
|
||||
public abstract readonly id: string;
|
||||
public abstract readonly envSet: EnvSet;
|
||||
public abstract readonly provider: Provider;
|
||||
public abstract readonly queries: Queries;
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { EnvSet } from '#src/env-set/index.js';
|
||||
|
||||
export const mockEnvSet = new EnvSet(EnvSet.values.dbUrl);
|
||||
export const mockEnvSet = new EnvSet(EnvSet.values.endpoint, EnvSet.values.dbUrl);
|
||||
|
||||
await mockEnvSet.load();
|
||||
|
|
|
@ -45,6 +45,7 @@ export type DeepPartial<T> = T extends object
|
|||
export type Partial2<T> = { [key in keyof T]?: Partial<T[key]> };
|
||||
|
||||
export class MockTenant implements TenantContext {
|
||||
public id = 'mock_id';
|
||||
public envSet = mockEnvSet;
|
||||
public queries: Queries;
|
||||
public libraries: Libraries;
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { generateStandardId } from '@logto/core-kit';
|
||||
|
||||
import type { CreateResource, CreateRole, CreateScope } from '../db-entries/index.js';
|
||||
import { UserRole } from '../types/index.js';
|
||||
import { PredefinedScope, UserRole } from '../types/index.js';
|
||||
import { adminTenantId, defaultTenantId } from './tenant.js';
|
||||
|
||||
export type AdminData = {
|
||||
|
@ -10,8 +10,6 @@ export type AdminData = {
|
|||
role: CreateRole;
|
||||
};
|
||||
|
||||
export const managementApiScopeAll = 'all';
|
||||
|
||||
// Consider remove the dependency of IDs
|
||||
const defaultResourceId = 'management-api';
|
||||
const defaultScopeAllId = 'management-api-all';
|
||||
|
@ -28,14 +26,14 @@ export const defaultManagementApi = Object.freeze({
|
|||
*
|
||||
* Admin Console requires the access token of this resource to be functional.
|
||||
*/
|
||||
indicator: 'https://logto.app/api',
|
||||
indicator: `https://${defaultTenantId}.logto.app/api`,
|
||||
name: 'Logto Management API',
|
||||
},
|
||||
scope: {
|
||||
tenantId: defaultTenantId,
|
||||
/** @deprecated You should not rely on this constant. Change to something else. */
|
||||
id: defaultScopeAllId,
|
||||
name: managementApiScopeAll,
|
||||
name: PredefinedScope.All,
|
||||
description: 'Default scope for Management API, allows all permissions.',
|
||||
/** @deprecated You should not rely on this constant. Change to something else. */
|
||||
resourceId: defaultResourceId,
|
||||
|
@ -49,8 +47,8 @@ export const defaultManagementApi = Object.freeze({
|
|||
},
|
||||
}) satisfies AdminData;
|
||||
|
||||
export const getManagementApiResourceIndicator = (tenantId: string) =>
|
||||
`https://${tenantId}.logto.app/api`;
|
||||
export const getManagementApiResourceIndicator = (tenantId: string, path = 'api') =>
|
||||
`https://${tenantId}.logto.app/${path}`;
|
||||
|
||||
export const getManagementApiAdminName = (tenantId: string) => `${tenantId}:${UserRole.Admin}`;
|
||||
|
||||
|
@ -68,7 +66,7 @@ export const createManagementApiInAdminTenant = (tenantId: string): AdminData =>
|
|||
scope: {
|
||||
tenantId: adminTenantId,
|
||||
id: generateStandardId(),
|
||||
name: managementApiScopeAll,
|
||||
name: PredefinedScope.All,
|
||||
description: 'Default scope for Management API, allows all permissions.',
|
||||
resourceId,
|
||||
},
|
||||
|
@ -80,3 +78,29 @@ export const createManagementApiInAdminTenant = (tenantId: string): AdminData =>
|
|||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const createMeApiInAdminTenant = (): AdminData => {
|
||||
const resourceId = generateStandardId();
|
||||
|
||||
return Object.freeze({
|
||||
resource: {
|
||||
tenantId: adminTenantId,
|
||||
id: resourceId,
|
||||
indicator: getManagementApiResourceIndicator(adminTenantId, 'me'),
|
||||
name: `Logto Me API`,
|
||||
},
|
||||
scope: {
|
||||
tenantId: adminTenantId,
|
||||
id: generateStandardId(),
|
||||
name: PredefinedScope.All,
|
||||
description: 'Default scope for Me API, allows all permissions.',
|
||||
resourceId,
|
||||
},
|
||||
role: {
|
||||
tenantId: adminTenantId,
|
||||
id: generateStandardId(),
|
||||
name: UserRole.User,
|
||||
description: 'Default role for admin tenant.',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
@ -24,4 +24,10 @@ export type UserProfileResponse = UserInfo & { hasPasswordSet: boolean };
|
|||
|
||||
export enum UserRole {
|
||||
Admin = 'admin',
|
||||
/** Common user role in admin tenant. */
|
||||
User = 'user',
|
||||
}
|
||||
|
||||
export enum PredefinedScope {
|
||||
All = 'all',
|
||||
}
|
||||
|
|
|
@ -29,9 +29,10 @@ const usePreview = (context: Context): [boolean, PreviewConfig?] => {
|
|||
document.body.classList.add(conditionalString(styles.preview));
|
||||
|
||||
const previewMessageHandler = (event: MessageEvent) => {
|
||||
if (event.origin !== window.location.origin) {
|
||||
return;
|
||||
}
|
||||
// TODO: @simeng: we can check allowed origins via `/.well-known/endpoints`
|
||||
// if (event.origin !== window.location.origin) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
if (event.data.sender === 'ac_preview') {
|
||||
// #event.data should be guarded at the provider's side
|
||||
|
|
Loading…
Add table
Reference in a new issue