mirror of
https://github.com/apache/superset.git
synced 2024-09-06 22:07:34 -04:00
feat: SupersetClient config to override 401 behavior (#19144)
* wip * feat: make 401 responses configurable in SupersetClient * sort * guest unauthorized handler * add toast container to embedded app * add option for toast presenter to go at the top * remove confusing comms logging * lint * Update superset-frontend/src/embedded/index.tsx * type correction
This commit is contained in:
parent
88029e21b6
commit
96a123f553
@ -33,6 +33,12 @@ import {
|
|||||||
} from './types';
|
} from './types';
|
||||||
import { DEFAULT_FETCH_RETRY_OPTIONS, DEFAULT_BASE_URL } from './constants';
|
import { DEFAULT_FETCH_RETRY_OPTIONS, DEFAULT_BASE_URL } from './constants';
|
||||||
|
|
||||||
|
const defaultUnauthorizedHandler = () => {
|
||||||
|
window.location.href = `/login?next=${
|
||||||
|
window.location.pathname + window.location.search
|
||||||
|
}`;
|
||||||
|
};
|
||||||
|
|
||||||
export default class SupersetClientClass {
|
export default class SupersetClientClass {
|
||||||
credentials: Credentials;
|
credentials: Credentials;
|
||||||
|
|
||||||
@ -58,6 +64,8 @@ export default class SupersetClientClass {
|
|||||||
|
|
||||||
timeout: ClientTimeout;
|
timeout: ClientTimeout;
|
||||||
|
|
||||||
|
handleUnauthorized: () => void;
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
baseUrl = DEFAULT_BASE_URL,
|
baseUrl = DEFAULT_BASE_URL,
|
||||||
host,
|
host,
|
||||||
@ -70,6 +78,7 @@ export default class SupersetClientClass {
|
|||||||
csrfToken = undefined,
|
csrfToken = undefined,
|
||||||
guestToken = undefined,
|
guestToken = undefined,
|
||||||
guestTokenHeaderName = 'X-GuestToken',
|
guestTokenHeaderName = 'X-GuestToken',
|
||||||
|
unauthorizedHandler = defaultUnauthorizedHandler,
|
||||||
}: ClientConfig = {}) {
|
}: ClientConfig = {}) {
|
||||||
const url = new URL(
|
const url = new URL(
|
||||||
host || protocol
|
host || protocol
|
||||||
@ -100,6 +109,7 @@ export default class SupersetClientClass {
|
|||||||
if (guestToken) {
|
if (guestToken) {
|
||||||
this.headers[guestTokenHeaderName] = guestToken;
|
this.headers[guestTokenHeaderName] = guestToken;
|
||||||
}
|
}
|
||||||
|
this.handleUnauthorized = unauthorizedHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
async init(force = false): CsrfPromise {
|
async init(force = false): CsrfPromise {
|
||||||
@ -151,6 +161,7 @@ export default class SupersetClientClass {
|
|||||||
headers,
|
headers,
|
||||||
timeout,
|
timeout,
|
||||||
fetchRetryOptions,
|
fetchRetryOptions,
|
||||||
|
ignoreUnauthorized,
|
||||||
...rest
|
...rest
|
||||||
}: RequestConfig & { parseMethod?: T }) {
|
}: RequestConfig & { parseMethod?: T }) {
|
||||||
await this.ensureAuth();
|
await this.ensureAuth();
|
||||||
@ -163,8 +174,8 @@ export default class SupersetClientClass {
|
|||||||
timeout: timeout ?? this.timeout,
|
timeout: timeout ?? this.timeout,
|
||||||
fetchRetryOptions: fetchRetryOptions ?? this.fetchRetryOptions,
|
fetchRetryOptions: fetchRetryOptions ?? this.fetchRetryOptions,
|
||||||
}).catch(res => {
|
}).catch(res => {
|
||||||
if (res?.status === 401) {
|
if (res?.status === 401 && !ignoreUnauthorized) {
|
||||||
this.redirectUnauthorized();
|
this.handleUnauthorized();
|
||||||
}
|
}
|
||||||
return Promise.reject(res);
|
return Promise.reject(res);
|
||||||
});
|
});
|
||||||
@ -230,10 +241,4 @@ export default class SupersetClientClass {
|
|||||||
endpoint[0] === '/' ? endpoint.slice(1) : endpoint
|
endpoint[0] === '/' ? endpoint.slice(1) : endpoint
|
||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
redirectUnauthorized() {
|
|
||||||
window.location.href = `/login?next=${
|
|
||||||
window.location.pathname + window.location.search
|
|
||||||
}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -81,6 +81,7 @@ export interface RequestBase {
|
|||||||
fetchRetryOptions?: FetchRetryOptions;
|
fetchRetryOptions?: FetchRetryOptions;
|
||||||
headers?: Headers;
|
headers?: Headers;
|
||||||
host?: Host;
|
host?: Host;
|
||||||
|
ignoreUnauthorized?: boolean;
|
||||||
mode?: Mode;
|
mode?: Mode;
|
||||||
method?: Method;
|
method?: Method;
|
||||||
jsonPayload?: Payload;
|
jsonPayload?: Payload;
|
||||||
@ -136,6 +137,7 @@ export interface ClientConfig {
|
|||||||
headers?: Headers;
|
headers?: Headers;
|
||||||
mode?: Mode;
|
mode?: Mode;
|
||||||
timeout?: ClientTimeout;
|
timeout?: ClientTimeout;
|
||||||
|
unauthorizedHandler?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SupersetClientInterface
|
export interface SupersetClientInterface
|
||||||
|
@ -499,26 +499,39 @@ describe('SupersetClientClass', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should redirect Unauthorized', async () => {
|
describe('when unauthorized', () => {
|
||||||
|
let originalLocation: any;
|
||||||
|
let authSpy: jest.SpyInstance;
|
||||||
const mockRequestUrl = 'https://host/get/url';
|
const mockRequestUrl = 'https://host/get/url';
|
||||||
const mockRequestPath = '/get/url';
|
const mockRequestPath = '/get/url';
|
||||||
const mockRequestSearch = '?param=1¶m=2';
|
const mockRequestSearch = '?param=1¶m=2';
|
||||||
const { location } = window;
|
const mockHref = `http://localhost${mockRequestPath + mockRequestSearch}`;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalLocation = window.location;
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
delete window.location;
|
delete window.location;
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
window.location = {
|
window.location = {
|
||||||
pathname: mockRequestPath,
|
pathname: mockRequestPath,
|
||||||
search: mockRequestSearch,
|
search: mockRequestSearch,
|
||||||
|
href: mockHref,
|
||||||
};
|
};
|
||||||
const authSpy = jest
|
authSpy = jest
|
||||||
.spyOn(SupersetClientClass.prototype, 'ensureAuth')
|
.spyOn(SupersetClientClass.prototype, 'ensureAuth')
|
||||||
.mockImplementation();
|
.mockImplementation();
|
||||||
const rejectValue = { status: 401 };
|
const rejectValue = { status: 401 };
|
||||||
fetchMock.get(mockRequestUrl, () => Promise.reject(rejectValue), {
|
fetchMock.get(mockRequestUrl, () => Promise.reject(rejectValue), {
|
||||||
overwriteRoutes: true,
|
overwriteRoutes: true,
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
authSpy.mockReset();
|
||||||
|
window.location = originalLocation;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should redirect', async () => {
|
||||||
const client = new SupersetClientClass({});
|
const client = new SupersetClientClass({});
|
||||||
|
|
||||||
let error;
|
let error;
|
||||||
@ -533,8 +546,45 @@ describe('SupersetClientClass', () => {
|
|||||||
);
|
);
|
||||||
expect(error.status).toBe(401);
|
expect(error.status).toBe(401);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
authSpy.mockReset();
|
it('does nothing if instructed to ignoreUnauthorized', async () => {
|
||||||
window.location = location;
|
const client = new SupersetClientClass({});
|
||||||
|
|
||||||
|
let error;
|
||||||
|
try {
|
||||||
|
await client.request({
|
||||||
|
url: mockRequestUrl,
|
||||||
|
method: 'GET',
|
||||||
|
ignoreUnauthorized: true,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
} finally {
|
||||||
|
// unchanged href, no redirect
|
||||||
|
expect(window.location.href).toBe(mockHref);
|
||||||
|
expect(error.status).toBe(401);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an unauthorizedHandler to override redirect behavior', async () => {
|
||||||
|
const unauthorizedHandler = jest.fn();
|
||||||
|
const client = new SupersetClientClass({ unauthorizedHandler });
|
||||||
|
|
||||||
|
let error;
|
||||||
|
try {
|
||||||
|
await client.request({
|
||||||
|
url: mockRequestUrl,
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
error = err;
|
||||||
|
} finally {
|
||||||
|
// unchanged href, no redirect
|
||||||
|
expect(window.location.href).toBe(mockHref);
|
||||||
|
expect(error.status).toBe(401);
|
||||||
|
expect(unauthorizedHandler).toHaveBeenCalledTimes(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -22,7 +22,9 @@ import ToastPresenter from './ToastPresenter';
|
|||||||
|
|
||||||
import { removeToast } from './actions';
|
import { removeToast } from './actions';
|
||||||
|
|
||||||
export default connect(
|
const ToastContainer = connect(
|
||||||
({ messageToasts: toasts }) => ({ toasts }),
|
({ messageToasts: toasts }: any) => ({ toasts }),
|
||||||
dispatch => bindActionCreators({ removeToast }, dispatch),
|
dispatch => bindActionCreators({ removeToast }, dispatch),
|
||||||
)(ToastPresenter);
|
)(ToastPresenter);
|
||||||
|
|
||||||
|
export default ToastContainer;
|
@ -21,10 +21,14 @@ import { styled } from '@superset-ui/core';
|
|||||||
import { ToastMeta } from 'src/components/MessageToasts/types';
|
import { ToastMeta } from 'src/components/MessageToasts/types';
|
||||||
import Toast from './Toast';
|
import Toast from './Toast';
|
||||||
|
|
||||||
const StyledToastPresenter = styled.div`
|
export interface VisualProps {
|
||||||
|
position: 'bottom' | 'top';
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledToastPresenter = styled.div<VisualProps>`
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0px;
|
${({ position }) => (position === 'bottom' ? 'bottom' : 'top')}: 0px;
|
||||||
right: 0px;
|
right: 0px;
|
||||||
margin-right: 50px;
|
margin-right: 50px;
|
||||||
margin-bottom: 50px;
|
margin-bottom: 50px;
|
||||||
@ -69,22 +73,25 @@ const StyledToastPresenter = styled.div`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
type ToastPresenterProps = {
|
type ToastPresenterProps = Partial<VisualProps> & {
|
||||||
toasts: Array<ToastMeta>;
|
toasts: Array<ToastMeta>;
|
||||||
removeToast: () => void;
|
removeToast: () => any;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ToastPresenter({
|
export default function ToastPresenter({
|
||||||
toasts,
|
toasts,
|
||||||
removeToast,
|
removeToast,
|
||||||
|
position = 'top',
|
||||||
}: ToastPresenterProps) {
|
}: ToastPresenterProps) {
|
||||||
return (
|
return (
|
||||||
toasts.length > 0 && (
|
<>
|
||||||
<StyledToastPresenter id="toast-presenter">
|
{toasts.length > 0 && (
|
||||||
|
<StyledToastPresenter id="toast-presenter" position={position}>
|
||||||
{toasts.map(toast => (
|
{toasts.map(toast => (
|
||||||
<Toast key={toast.id} toast={toast} onCloseToast={removeToast} />
|
<Toast key={toast.id} toast={toast} onCloseToast={removeToast} />
|
||||||
))}
|
))}
|
||||||
</StyledToastPresenter>
|
</StyledToastPresenter>
|
||||||
)
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -35,7 +35,8 @@ export interface ToastProps {
|
|||||||
addWarningToast: typeof addWarningToast;
|
addWarningToast: typeof addWarningToast;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toasters = {
|
/** just action creators, these do not dispatch */
|
||||||
|
export const toasters = {
|
||||||
addInfoToast,
|
addInfoToast,
|
||||||
addSuccessToast,
|
addSuccessToast,
|
||||||
addWarningToast,
|
addWarningToast,
|
||||||
|
@ -19,12 +19,16 @@
|
|||||||
import React, { lazy, Suspense } from 'react';
|
import React, { lazy, Suspense } from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
import ReactDOM from 'react-dom';
|
||||||
import { BrowserRouter as Router, Route } from 'react-router-dom';
|
import { BrowserRouter as Router, Route } from 'react-router-dom';
|
||||||
|
import { t } from '@superset-ui/core';
|
||||||
import { Switchboard } from '@superset-ui/switchboard';
|
import { Switchboard } from '@superset-ui/switchboard';
|
||||||
import { bootstrapData } from 'src/preamble';
|
import { bootstrapData } from 'src/preamble';
|
||||||
import setupClient from 'src/setup/setupClient';
|
import setupClient from 'src/setup/setupClient';
|
||||||
import { RootContextProviders } from 'src/views/RootContextProviders';
|
import { RootContextProviders } from 'src/views/RootContextProviders';
|
||||||
|
import { store } from 'src/views/store';
|
||||||
import ErrorBoundary from 'src/components/ErrorBoundary';
|
import ErrorBoundary from 'src/components/ErrorBoundary';
|
||||||
import Loading from 'src/components/Loading';
|
import Loading from 'src/components/Loading';
|
||||||
|
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
||||||
|
import ToastContainer from 'src/components/MessageToasts/ToastContainer';
|
||||||
|
|
||||||
const debugMode = process.env.WEBPACK_MODE === 'development';
|
const debugMode = process.env.WEBPACK_MODE === 'development';
|
||||||
|
|
||||||
@ -49,6 +53,7 @@ const EmbeddedApp = () => (
|
|||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<LazyDashboardPage />
|
<LazyDashboardPage />
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
|
<ToastContainer position="top" />
|
||||||
</RootContextProviders>
|
</RootContextProviders>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Route>
|
</Route>
|
||||||
@ -75,23 +80,43 @@ if (!window.parent) {
|
|||||||
// );
|
// );
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
let displayedUnauthorizedToast = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If there is a problem with the guest token, we will start getting
|
||||||
|
* 401 errors from the api and SupersetClient will call this function.
|
||||||
|
*/
|
||||||
|
function guestUnauthorizedHandler() {
|
||||||
|
if (displayedUnauthorizedToast) return; // no need to display this message every time we get another 401
|
||||||
|
displayedUnauthorizedToast = true;
|
||||||
|
// If a guest user were sent to a login screen on 401, they would have no valid login to use.
|
||||||
|
// For embedded it makes more sense to just display a message
|
||||||
|
// and let them continue accessing the page, to whatever extent they can.
|
||||||
|
store.dispatch(
|
||||||
|
addDangerToast(
|
||||||
|
t(
|
||||||
|
'This session has encountered an interruption, and some controls may not work as intended. If you are the developer of this app, please check that the guest token is being generated correctly.',
|
||||||
|
),
|
||||||
|
{
|
||||||
|
duration: -1, // stay open until manually closed
|
||||||
|
noDuplicate: true,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures SupersetClient with the correct settings for the embedded dashboard page.
|
||||||
|
*/
|
||||||
function setupGuestClient(guestToken: string) {
|
function setupGuestClient(guestToken: string) {
|
||||||
// need to reconfigure SupersetClient to use the guest token
|
|
||||||
setupClient({
|
setupClient({
|
||||||
guestToken,
|
guestToken,
|
||||||
guestTokenHeaderName: bootstrapData.config?.GUEST_TOKEN_HEADER_NAME,
|
guestTokenHeaderName: bootstrapData.config?.GUEST_TOKEN_HEADER_NAME,
|
||||||
|
unauthorizedHandler: guestUnauthorizedHandler,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateMessageEvent(event: MessageEvent) {
|
function validateMessageEvent(event: MessageEvent) {
|
||||||
if (
|
|
||||||
event.data?.type === 'webpackClose' ||
|
|
||||||
event.data?.source === '@devtools-page'
|
|
||||||
) {
|
|
||||||
// sometimes devtools use the messaging api and we want to ignore those
|
|
||||||
throw new Error("Sir, this is a Wendy's");
|
|
||||||
}
|
|
||||||
|
|
||||||
// if (!ALLOW_ORIGINS.includes(event.origin)) {
|
// if (!ALLOW_ORIGINS.includes(event.origin)) {
|
||||||
// throw new Error('Message origin is not in the allowed list');
|
// throw new Error('Message origin is not in the allowed list');
|
||||||
// }
|
// }
|
||||||
@ -105,7 +130,7 @@ window.addEventListener('message', function embeddedPageInitializer(event) {
|
|||||||
try {
|
try {
|
||||||
validateMessageEvent(event);
|
validateMessageEvent(event);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log('ignoring message', err, event);
|
log('ignoring message unrelated to embedded comms', err, event);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user