Merge pull request #852 from spotify/rugvip/oauth
packages/core: lift out and adapt internal oauth request API
This commit is contained in:
@@ -21,7 +21,7 @@
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-use": "^13.24.0",
|
||||
"react-use": "^14.2.0",
|
||||
"zen-observable": "^0.8.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-use": "^13.24.0"
|
||||
"react-use": "^14.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^4.2.4",
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-use": "^13.24.0",
|
||||
"react-use": "^14.2.0",
|
||||
"react-router-dom": "^5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-use": "^13.24.0"
|
||||
"react-use": "^14.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^{{version}}",
|
||||
|
||||
@@ -41,7 +41,9 @@
|
||||
"react-router": "^5.2.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-sparklines": "^1.7.0",
|
||||
"react-syntax-highlighter": "^12.2.1"
|
||||
"react-syntax-highlighter": "^12.2.1",
|
||||
"react-use": "^14.2.0",
|
||||
"zen-observable": "^0.8.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.5",
|
||||
@@ -55,7 +57,8 @@
|
||||
"@types/jest": "^25.2.1",
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/react-helmet": "^5.0.15",
|
||||
"@types/react-sparklines": "^1.7.0"
|
||||
"@types/react-sparklines": "^1.7.0",
|
||||
"@types/zen-observable": "^0.8.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
|
||||
@@ -23,3 +23,4 @@
|
||||
export * from './alert';
|
||||
export * from './error';
|
||||
export * from './featureFlags';
|
||||
export * from './oauthrequest';
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { IconComponent } from '../../../icons';
|
||||
import { Observable } from '../../types';
|
||||
import { ApiRef } from '../ApiRef';
|
||||
|
||||
export type OAuthScopes = {
|
||||
extend(scopes: OAuthScopeLike): OAuthScopes;
|
||||
hasScopes(scopes: OAuthScopeLike): boolean;
|
||||
toSet(): Set<string>;
|
||||
toString(): string;
|
||||
};
|
||||
|
||||
export type OAuthScopeLike =
|
||||
| string /** Space separated scope strings */
|
||||
| string[] /** Array of individual scope strings */
|
||||
| OAuthScopes;
|
||||
|
||||
/**
|
||||
* Options used to open a login popup.
|
||||
*/
|
||||
export type LoginPopupOptions = {
|
||||
/**
|
||||
* The URL that the auth popup should point to
|
||||
*/
|
||||
url: string;
|
||||
|
||||
/**
|
||||
* The name of the popup, as in second argument to window.open
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* The origin of the final popup page that will post a message to this window.
|
||||
*/
|
||||
origin: string;
|
||||
|
||||
/**
|
||||
* The width of the popup in pixels, defaults to 500
|
||||
*/
|
||||
width?: number;
|
||||
|
||||
/**
|
||||
* The height of the popup in pixels, defaults to 700
|
||||
*/
|
||||
height?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Information about the auth provider that we're requesting a login towards.
|
||||
*
|
||||
* This should be shown to the user so that they can be informed about what login is being requested
|
||||
* before a popup is shown.
|
||||
*/
|
||||
export type AuthProvider = {
|
||||
/**
|
||||
* Title for the auth provider, for example "GitHub"
|
||||
*/
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* Icon for the auth provider.
|
||||
*/
|
||||
icon: IconComponent;
|
||||
};
|
||||
|
||||
/**
|
||||
* Describes how to handle auth requests. Both how to show them to the user, and what to do when
|
||||
* the user accesses the auth request.
|
||||
*/
|
||||
export type AuthRequesterOptions<AuthResponse> = {
|
||||
/**
|
||||
* Information about the auth provider, which will be forwarded to auth requests.
|
||||
*/
|
||||
provider: AuthProvider;
|
||||
|
||||
/**
|
||||
* Implementation of the auth flow, which will be called synchronously when
|
||||
* trigger() is called on an auth requests.
|
||||
*/
|
||||
onAuthRequest(scope: OAuthScopes): Promise<AuthResponse>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Function used to trigger new auth requests for a set of scopes.
|
||||
*
|
||||
* The returned promise will resolve to the same value returned by the onAuthRequest in the
|
||||
* AuthRequesterOptions. Or rejected, if the request is rejected.
|
||||
*
|
||||
* This function can be called multiple times before the promise resolves. All calls
|
||||
* will be merged into one request, and the scopes forwarded to the onAuthRequest will be the
|
||||
* union of all requested scopes.
|
||||
*/
|
||||
export type AuthRequester<AuthResponse> = (
|
||||
scope: OAuthScopes,
|
||||
) => Promise<AuthResponse>;
|
||||
|
||||
/**
|
||||
* An pending auth request for a single auth provider. The request will remain in this pending
|
||||
* state until either reject() or trigger() is called.
|
||||
*
|
||||
* Any new requests for the same provider are merged into the existing pending request, meaning
|
||||
* there will only ever be a single pending request for a given provider.
|
||||
*/
|
||||
export type PendingAuthRequest = {
|
||||
/**
|
||||
* Information about the auth provider, as given in the AuthRequesterOptions
|
||||
*/
|
||||
provider: AuthProvider;
|
||||
|
||||
/**
|
||||
* Rejects the request, causing all pending AuthRequester calls to fail with "RejectedError".
|
||||
*/
|
||||
reject: () => void;
|
||||
|
||||
/**
|
||||
* Trigger the auth request to continue the auth flow, by for example showing a popup.
|
||||
*
|
||||
* Synchronously calls onAuthRequest with all scope currently in the request.
|
||||
*/
|
||||
trigger(): Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Provides helpers for implemented OAuth login flows within Backstage.
|
||||
*/
|
||||
export type OAuthRequestApi = {
|
||||
/**
|
||||
* Show a popup pointing to a URL that starts an OAuth flow.
|
||||
*
|
||||
* The redirect handler of the flow should use postMessage to communicate back
|
||||
* to the app window.
|
||||
*
|
||||
* The returned promise resolves to the contents of the message that was posted from the auth popup.
|
||||
*/
|
||||
showLoginPopup(options: LoginPopupOptions): Promise<any>;
|
||||
|
||||
/**
|
||||
* A utility for showing login popups or similar things, and merging together multiple requests for
|
||||
* different scopes into one request that inclues all scopes.
|
||||
*
|
||||
* The passed in options provide information about the login provider, and how to handle auth requests.
|
||||
*
|
||||
* The returned AuthRequester function is used to request login with new scopes. These requests
|
||||
* are merged together and forwarded to the auth handler, as soon as a consumer of auth requests
|
||||
* triggers an auth flow.
|
||||
*
|
||||
* See AuthRequesterOptions, AuthRequester, and handleAuthRequests for more info.
|
||||
*/
|
||||
createAuthRequester<AuthResponse>(
|
||||
options: AuthRequesterOptions<AuthResponse>,
|
||||
): AuthRequester<AuthResponse>;
|
||||
|
||||
/**
|
||||
* Observers panding auth requests. The returned observable will emit all
|
||||
* current active auth request, at most one for each created auth requester.
|
||||
*
|
||||
* Each request has its own info about the login provider, forwarded from the auth requester options.
|
||||
*
|
||||
* Depending on user interaction, the request should either be rejected, or used to trigger the auth handler.
|
||||
* If the request is rejected, all pending AuthRequester calls will fail with a "RejectedError".
|
||||
* If a auth is triggered, and the auth handler resolves successfully, then all currently pending
|
||||
* AuthRequester calls will resolve to the value returned by the onAuthRequest call.
|
||||
*/
|
||||
authRequest$(): Observable<PendingAuthRequest[]>;
|
||||
};
|
||||
|
||||
export const oauthRequestApiRef = new ApiRef<OAuthRequestApi>({
|
||||
id: 'core.oauthrequest',
|
||||
description: 'An API for implementing unified OAuth flows in Backstage',
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { OAuthScopes, OAuthScopeLike } from '../../definitions/oauthrequest';
|
||||
|
||||
/**
|
||||
* The BasicOAuthScopes class is an implementation of OAuthScopes that
|
||||
* works for any simple comma- or space-separated format of scope.
|
||||
*/
|
||||
export class BasicOAuthScopes implements OAuthScopes {
|
||||
static from(scopes: OAuthScopeLike, normalizer?: (scope: string) => string) {
|
||||
const normalized = BasicOAuthScopes.asStrings(scopes, normalizer);
|
||||
return new BasicOAuthScopes(new Set(normalized), normalizer);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly scopes: Set<string>,
|
||||
private readonly normalizer?: (scope: string) => string,
|
||||
) {}
|
||||
|
||||
extend(requestedScopes: OAuthScopeLike): BasicOAuthScopes {
|
||||
const newScopes = new Set(this.scopes);
|
||||
BasicOAuthScopes.asStrings(requestedScopes, this.normalizer).forEach((s) =>
|
||||
newScopes.add(s),
|
||||
);
|
||||
return new BasicOAuthScopes(newScopes, this.normalizer);
|
||||
}
|
||||
|
||||
hasScopes(scopes: OAuthScopeLike): boolean {
|
||||
return BasicOAuthScopes.asStrings(scopes, this.normalizer).every((s) =>
|
||||
this.scopes.has(s),
|
||||
);
|
||||
}
|
||||
|
||||
toSet(): Set<string> {
|
||||
return this.scopes;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return Array.from(this.scopes).join(' ');
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return Array.from(this.scopes);
|
||||
}
|
||||
|
||||
static asStrings(
|
||||
input: OAuthScopeLike,
|
||||
normalizer?: (scope: string) => string,
|
||||
): string[] {
|
||||
let scopeArray: string[];
|
||||
if (typeof input === 'string') {
|
||||
scopeArray = input.split(/[,\s]/).filter(Boolean);
|
||||
} else if (Array.isArray(input)) {
|
||||
scopeArray = input;
|
||||
} else {
|
||||
scopeArray = Array.from(input.toSet());
|
||||
}
|
||||
if (normalizer) {
|
||||
scopeArray = scopeArray.map((x) => normalizer(x));
|
||||
}
|
||||
return scopeArray;
|
||||
}
|
||||
}
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { wait } from '@testing-library/react';
|
||||
import { OAuthPendingRequests } from './OAuthPendingRequests';
|
||||
import { BasicOAuthScopes } from './BasicOAuthScopes';
|
||||
|
||||
describe('OAuthPendingRequests', () => {
|
||||
it('notifies new observers about current state', async () => {
|
||||
const target = new OAuthPendingRequests<string>();
|
||||
const next = jest.fn();
|
||||
const error = jest.fn();
|
||||
|
||||
const input = BasicOAuthScopes.from('a b');
|
||||
target.pending().subscribe({ next, error });
|
||||
target.request(input);
|
||||
|
||||
await wait(() => expect(next).toBeCalledTimes(2));
|
||||
expect(next.mock.calls[0][0].scopes).toBeUndefined();
|
||||
expect(next.mock.calls[1][0].scopes.toString()).toBe(input.toString());
|
||||
expect(error.mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('resolves requests and notifies observers', async () => {
|
||||
const target = new OAuthPendingRequests<string>();
|
||||
const next = jest.fn();
|
||||
const error = jest.fn();
|
||||
|
||||
const request1 = target.request(BasicOAuthScopes.from('a'));
|
||||
const request2 = target.request(BasicOAuthScopes.from('a'));
|
||||
target.pending().subscribe({ next, error });
|
||||
target.resolve(BasicOAuthScopes.from('a'), 'session1');
|
||||
target.resolve(BasicOAuthScopes.from('a'), 'session2');
|
||||
|
||||
await expect(request1).resolves.toBe('session1');
|
||||
await expect(request2).resolves.toBe('session1');
|
||||
expect(next).toBeCalledTimes(3); // once on subscription, twice on resolve
|
||||
expect(error).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('can resolve through the observable', async () => {
|
||||
const target = new OAuthPendingRequests<string>();
|
||||
const next = jest.fn((pendingRequest) => pendingRequest.resolve('done'));
|
||||
const error = jest.fn();
|
||||
|
||||
const request1 = target.request(BasicOAuthScopes.from('a'));
|
||||
target.pending().subscribe({ next, error });
|
||||
|
||||
await expect(request1).resolves.toBe('done');
|
||||
expect(next).toBeCalledTimes(2); // once with data on subscription, once empty after resolution
|
||||
expect(error).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('rejects requests and notifies observers only once', async () => {
|
||||
const target = new OAuthPendingRequests<string>();
|
||||
const next = jest.fn();
|
||||
const error = jest.fn();
|
||||
const rejection = new Error('eek');
|
||||
|
||||
const request1 = target.request(BasicOAuthScopes.from('a'));
|
||||
const request2 = target.request(BasicOAuthScopes.from('a'));
|
||||
target.pending().subscribe({ next, error });
|
||||
target.reject(rejection);
|
||||
target.resolve(BasicOAuthScopes.from('a'), 'session');
|
||||
|
||||
await expect(request1).rejects.toBe(rejection);
|
||||
await expect(request2).rejects.toBe(rejection);
|
||||
expect(next).toBeCalledTimes(3); // once on subscription, once or reject, once on resolve
|
||||
expect(error).toBeCalledTimes(0);
|
||||
});
|
||||
|
||||
it('can reject through the observable', async () => {
|
||||
const target = new OAuthPendingRequests<string>();
|
||||
const rejection = new Error('nope');
|
||||
const next = jest.fn((pendingRequest) => pendingRequest.reject(rejection));
|
||||
const error = jest.fn();
|
||||
|
||||
const request1 = target.request(BasicOAuthScopes.from('a'));
|
||||
target.pending().subscribe({ next, error });
|
||||
|
||||
await expect(request1).rejects.toBe(rejection);
|
||||
expect(next).toBeCalledTimes(2);
|
||||
});
|
||||
});
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import Observable from 'zen-observable';
|
||||
import { OAuthScopes } from '../../definitions/oauthrequest';
|
||||
|
||||
type RequestQueueEntry<ResultType> = {
|
||||
scopes: OAuthScopes;
|
||||
resolve: (value?: ResultType | PromiseLike<ResultType> | undefined) => void;
|
||||
reject: (reason: Error) => void;
|
||||
};
|
||||
|
||||
export type PendingRequest<ResultType> = {
|
||||
scopes: OAuthScopes | undefined;
|
||||
resolve: (value: ResultType) => void;
|
||||
reject: (reason: Error) => void;
|
||||
};
|
||||
|
||||
export type OAuthPendingRequestsApi<ResultType> = {
|
||||
request(scopes: OAuthScopes): Promise<ResultType>;
|
||||
resolve(scopes: OAuthScopes, result: ResultType): void;
|
||||
reject(error: Error): void;
|
||||
pending(): Observable<PendingRequest<ResultType>>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The OAuthPendingRequests class is a utility for managing and observing
|
||||
* a stream of requests for oauth scopes for a single provider, and resolving
|
||||
* them correctly once requests are fulfilled.
|
||||
*/
|
||||
export class OAuthPendingRequests<ResultType>
|
||||
implements OAuthPendingRequestsApi<ResultType> {
|
||||
private requests: RequestQueueEntry<ResultType>[] = [];
|
||||
private listeners: ZenObservable.SubscriptionObserver<
|
||||
PendingRequest<ResultType>
|
||||
>[] = [];
|
||||
|
||||
request(scopes: OAuthScopes): Promise<ResultType> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.requests.push({ scopes, resolve, reject });
|
||||
|
||||
const pending = this.getCurrentPending();
|
||||
this.listeners.forEach((listener) => listener.next(pending));
|
||||
});
|
||||
}
|
||||
|
||||
resolve(scopes: OAuthScopes, result: ResultType): void {
|
||||
this.requests = this.requests.filter((request) => {
|
||||
if (scopes.hasScopes(request.scopes)) {
|
||||
request.resolve(result);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const pending = this.getCurrentPending();
|
||||
this.listeners.forEach((listener) => listener.next(pending));
|
||||
}
|
||||
|
||||
reject(error: Error) {
|
||||
this.requests.forEach((request) => request.reject(error));
|
||||
this.requests = [];
|
||||
|
||||
const pending = this.getCurrentPending();
|
||||
this.listeners.forEach((listener) => listener.next(pending));
|
||||
}
|
||||
|
||||
pending(): Observable<PendingRequest<ResultType>> {
|
||||
return new Observable((subscriber) => {
|
||||
this.listeners.push(subscriber);
|
||||
subscriber.next(this.getCurrentPending());
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter((l) => l !== subscriber);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getCurrentPending(): PendingRequest<ResultType> {
|
||||
const currentScopes =
|
||||
this.requests.length === 0
|
||||
? undefined
|
||||
: this.requests
|
||||
.slice(1)
|
||||
.reduce(
|
||||
(acc, current) => acc.extend(current.scopes),
|
||||
this.requests[0].scopes,
|
||||
);
|
||||
|
||||
return {
|
||||
scopes: currentScopes,
|
||||
resolve: (value: ResultType) => {
|
||||
if (currentScopes) {
|
||||
this.resolve(currentScopes, value);
|
||||
}
|
||||
},
|
||||
reject: (reason: Error) => {
|
||||
if (currentScopes) {
|
||||
this.reject(reason);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { OAuthRequestManager } from './OAuthRequestManager';
|
||||
|
||||
describe('OAuthApi login popup', () => {
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it('should show an auth popup', async () => {
|
||||
const oauth = new OAuthRequestManager();
|
||||
|
||||
const popupMock = { closed: false };
|
||||
const openSpy = jest
|
||||
.spyOn(window, 'open')
|
||||
.mockReturnValue(popupMock as Window);
|
||||
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
|
||||
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
|
||||
|
||||
const payloadPromise = oauth.showLoginPopup({
|
||||
url:
|
||||
'my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb',
|
||||
name: 'test-popup',
|
||||
origin: 'my-origin',
|
||||
});
|
||||
|
||||
expect(openSpy).toBeCalledTimes(1);
|
||||
expect(openSpy.mock.calls[0][0]).toBe(
|
||||
'my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb',
|
||||
);
|
||||
expect(openSpy.mock.calls[0][1]).toBe('test-popup');
|
||||
expect(addEventListenerSpy).toBeCalledTimes(1);
|
||||
expect(removeEventListenerSpy).toBeCalledTimes(0);
|
||||
|
||||
const listener = addEventListenerSpy.mock.calls[0][1] as EventListener;
|
||||
|
||||
await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe(
|
||||
'waiting',
|
||||
);
|
||||
|
||||
listener({} as MessageEvent);
|
||||
|
||||
await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe(
|
||||
'waiting',
|
||||
);
|
||||
|
||||
// None of these should be accepted
|
||||
listener({ source: popupMock } as MessageEvent);
|
||||
listener({ origin: 'my-origin' } as MessageEvent);
|
||||
listener({ data: { type: 'oauth-result' } } as MessageEvent);
|
||||
listener({
|
||||
source: popupMock,
|
||||
origin: 'my-origin',
|
||||
data: {},
|
||||
} as MessageEvent);
|
||||
listener({
|
||||
source: popupMock,
|
||||
origin: 'my-origin',
|
||||
data: { type: 'not-oauth-result', payload: {} },
|
||||
} as MessageEvent);
|
||||
|
||||
await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe(
|
||||
'waiting',
|
||||
);
|
||||
|
||||
const myPayload = {};
|
||||
|
||||
// This should be accepted as a valid sessions response
|
||||
listener({
|
||||
source: popupMock,
|
||||
origin: 'my-origin',
|
||||
data: {
|
||||
type: 'oauth-result',
|
||||
payload: myPayload,
|
||||
},
|
||||
} as MessageEvent);
|
||||
|
||||
await expect(payloadPromise).resolves.toBe(myPayload);
|
||||
|
||||
expect(openSpy).toBeCalledTimes(1);
|
||||
expect(addEventListenerSpy).toBeCalledTimes(1);
|
||||
expect(removeEventListenerSpy).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should fail if popup returns error', async () => {
|
||||
const oauth = new OAuthRequestManager();
|
||||
|
||||
const popupMock = { closed: false };
|
||||
const openSpy = jest
|
||||
.spyOn(window, 'open')
|
||||
.mockReturnValue(popupMock as Window);
|
||||
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
|
||||
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
|
||||
|
||||
const payloadPromise = oauth.showLoginPopup({
|
||||
url: 'url',
|
||||
name: 'name',
|
||||
origin: 'my-origin',
|
||||
});
|
||||
|
||||
expect(openSpy).toBeCalledTimes(1);
|
||||
expect(addEventListenerSpy).toBeCalledTimes(1);
|
||||
expect(removeEventListenerSpy).toBeCalledTimes(0);
|
||||
|
||||
const listener = addEventListenerSpy.mock.calls[0][1] as EventListener;
|
||||
|
||||
listener({
|
||||
source: popupMock,
|
||||
origin: 'my-origin',
|
||||
data: {
|
||||
type: 'oauth-result',
|
||||
payload: {
|
||||
error: {
|
||||
message: 'NOPE',
|
||||
name: 'NopeError',
|
||||
},
|
||||
},
|
||||
},
|
||||
} as MessageEvent);
|
||||
|
||||
await expect(payloadPromise).rejects.toThrow({
|
||||
name: 'NopeError',
|
||||
message: 'NOPE',
|
||||
});
|
||||
|
||||
expect(openSpy).toBeCalledTimes(1);
|
||||
expect(addEventListenerSpy).toBeCalledTimes(1);
|
||||
expect(removeEventListenerSpy).toBeCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should fail if popup is closed', async () => {
|
||||
const oauth = new OAuthRequestManager();
|
||||
|
||||
const openSpy = jest
|
||||
.spyOn(window, 'open')
|
||||
.mockReturnValue({ closed: false } as Window);
|
||||
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
|
||||
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
|
||||
const popupMock = { closed: false };
|
||||
|
||||
openSpy.mockReturnValue(popupMock as Window);
|
||||
|
||||
const payloadPromise = oauth.showLoginPopup({
|
||||
url: 'url',
|
||||
name: 'name',
|
||||
origin: 'origin',
|
||||
});
|
||||
|
||||
expect(openSpy).toBeCalledTimes(1);
|
||||
expect(addEventListenerSpy).toBeCalledTimes(1);
|
||||
expect(removeEventListenerSpy).toBeCalledTimes(0);
|
||||
|
||||
setTimeout(() => {
|
||||
popupMock.closed = true;
|
||||
}, 150);
|
||||
await expect(payloadPromise).rejects.toThrow(
|
||||
'Login failed, popup was closed',
|
||||
);
|
||||
|
||||
expect(openSpy).toBeCalledTimes(1);
|
||||
expect(addEventListenerSpy).toBeCalledTimes(1);
|
||||
expect(removeEventListenerSpy).toBeCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
OAuthRequestApi,
|
||||
LoginPopupOptions,
|
||||
PendingAuthRequest,
|
||||
AuthRequester,
|
||||
AuthRequesterOptions,
|
||||
} from '../../definitions/oauthrequest';
|
||||
import Observable from 'zen-observable';
|
||||
import { OAuthPendingRequests, PendingRequest } from './OAuthPendingRequests';
|
||||
|
||||
/**
|
||||
* The OAuthRequestManager is an implementation of the OAuthRequestApi.
|
||||
*
|
||||
* The purpose of this class and the API is to read a stream of incoming requests
|
||||
* of OAuth access tokens from different providers with varying scope, and funnel
|
||||
* them all together into a single request for each OAuth provider.
|
||||
*/
|
||||
export class OAuthRequestManager implements OAuthRequestApi {
|
||||
private readonly request$: Observable<PendingAuthRequest[]>;
|
||||
private readonly observers = new Set<
|
||||
ZenObservable.SubscriptionObserver<PendingAuthRequest[]>
|
||||
>();
|
||||
private currentRequests: PendingAuthRequest[] = [];
|
||||
private handlerCount = 0;
|
||||
|
||||
constructor() {
|
||||
this.request$ = new Observable<PendingAuthRequest[]>((observer) => {
|
||||
observer.next(this.currentRequests);
|
||||
|
||||
this.observers.add(observer);
|
||||
return () => {
|
||||
this.observers.delete(observer);
|
||||
};
|
||||
}).map((requests) => requests.filter(Boolean)); // Convert from sparse array to array of present items only
|
||||
}
|
||||
|
||||
createAuthRequester<T>(options: AuthRequesterOptions<T>): AuthRequester<T> {
|
||||
const handler = new OAuthPendingRequests<T>();
|
||||
|
||||
const index = this.handlerCount;
|
||||
this.handlerCount++;
|
||||
|
||||
handler.pending().subscribe({
|
||||
next: (scopeRequest) => {
|
||||
const newRequests = this.currentRequests.slice();
|
||||
const request = this.makeAuthRequest(scopeRequest, options);
|
||||
if (!request) {
|
||||
delete newRequests[index];
|
||||
} else {
|
||||
newRequests[index] = request;
|
||||
}
|
||||
this.currentRequests = newRequests;
|
||||
this.observers.forEach((observer) => observer.next(newRequests));
|
||||
},
|
||||
});
|
||||
|
||||
return (scopes) => {
|
||||
return handler.request(scopes);
|
||||
};
|
||||
}
|
||||
|
||||
// Converts the pending request and popup options into a popup request that we can forward to subscribers.
|
||||
private makeAuthRequest(
|
||||
request: PendingRequest<any>,
|
||||
options: AuthRequesterOptions<any>,
|
||||
): PendingAuthRequest | undefined {
|
||||
const { scopes } = request;
|
||||
if (!scopes) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: options.provider,
|
||||
trigger: async () => {
|
||||
const result = await options.onAuthRequest(scopes);
|
||||
request.resolve(result);
|
||||
},
|
||||
reject: () => {
|
||||
const error = new Error('Login failed, rejected by user');
|
||||
error.name = 'RejectedError';
|
||||
request.reject(error);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
authRequest$(): Observable<PendingAuthRequest[]> {
|
||||
return this.request$;
|
||||
}
|
||||
|
||||
async showLoginPopup(options: LoginPopupOptions): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const width = options.width || 500;
|
||||
const height = options.height || 700;
|
||||
const left = window.screen.width / 2 - width / 2;
|
||||
const top = window.screen.height / 2 - height / 2;
|
||||
|
||||
const popup = window.open(
|
||||
options.url,
|
||||
options.name,
|
||||
`menubar=no,location=no,resizable=no,scrollbars=no,status=no,width=${width},height=${height},top=${top},left=${left}`,
|
||||
);
|
||||
|
||||
if (!popup || typeof popup.closed === 'undefined' || popup.closed) {
|
||||
reject(new Error('Failed to open auth popup.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const messageListener = (event: MessageEvent) => {
|
||||
if (event.source !== popup) {
|
||||
return;
|
||||
}
|
||||
if (event.origin !== options.origin) {
|
||||
return;
|
||||
}
|
||||
const { data } = event;
|
||||
if (data.type !== 'oauth-result') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.payload.error) {
|
||||
const error = new Error(data.payload.error.message);
|
||||
error.name = data.payload.error.name;
|
||||
// TODO: proper error type
|
||||
// error.extra = data.payload.error.extra;
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(data.payload);
|
||||
}
|
||||
done();
|
||||
};
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
if (popup.closed) {
|
||||
const error = new Error('Login failed, popup was closed');
|
||||
error.name = 'PopupClosedError';
|
||||
reject(error);
|
||||
done();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
function done() {
|
||||
window.removeEventListener('message', messageListener);
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
|
||||
window.addEventListener('message', messageListener);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { OAuthRequestManager } from './OAuthRequestManager';
|
||||
@@ -20,3 +20,4 @@
|
||||
|
||||
export * from './AlertApiForwarder';
|
||||
export * from './ErrorApiForwarder';
|
||||
export * from './OAuthRequestManager';
|
||||
|
||||
@@ -18,3 +18,4 @@ export * from './apis';
|
||||
export * from './app';
|
||||
export * from './navTargets';
|
||||
export * from './plugin';
|
||||
export * from './types';
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This file contains non-react related core types used throught Backstage.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Observer interface for consuming an Observer, see TC39.
|
||||
*/
|
||||
export type Observer<T> = {
|
||||
next?(value: T): void;
|
||||
error?(error: Error): void;
|
||||
complete?(): void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscription returned when subscribing to an Observable, see TC39.
|
||||
*/
|
||||
export type Subscription = {
|
||||
/**
|
||||
* Cancels the subscription
|
||||
*/
|
||||
unsubscribe(): void;
|
||||
|
||||
/**
|
||||
* Value indicating whether the subscription is closed.
|
||||
*/
|
||||
readonly closed: Boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Observable sequence of values and errors, see TC39.
|
||||
*
|
||||
* https://github.com/tc39/proposal-observable
|
||||
*
|
||||
* This is used as a common return type for observable values and can be created
|
||||
* using many different observable implementations, such as zen-observable or RxJS 5.
|
||||
*/
|
||||
export type Observable<T> = {
|
||||
/**
|
||||
* Subscribes to this observable to start receiving new values.
|
||||
*/
|
||||
subscribe(observer: Observer<T>): Subscription;
|
||||
subscribe(
|
||||
onNext: (value: T) => void,
|
||||
onError?: (error: Error) => void,
|
||||
onComplete?: () => void,
|
||||
): Subscription;
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
ListItemText,
|
||||
makeStyles,
|
||||
Typography,
|
||||
Theme,
|
||||
} from '@material-ui/core';
|
||||
import React, { FC, useState } from 'react';
|
||||
import { PendingAuthRequest } from '../../api';
|
||||
|
||||
const useItemStyles = makeStyles<Theme>((theme) => ({
|
||||
root: {
|
||||
paddingLeft: theme.spacing(3),
|
||||
},
|
||||
}));
|
||||
|
||||
type RowProps = {
|
||||
request: PendingAuthRequest;
|
||||
busy: boolean;
|
||||
setBusy: (busy: boolean) => void;
|
||||
};
|
||||
|
||||
const LoginRequestListItem: FC<RowProps> = ({ request, busy, setBusy }) => {
|
||||
const classes = useItemStyles();
|
||||
const [error, setError] = useState<Error>();
|
||||
|
||||
const handleContinue = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await request.trigger();
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const IconComponent = request.provider.icon;
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
button
|
||||
disabled={busy}
|
||||
onClick={handleContinue}
|
||||
classes={{ root: classes.root }}
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<IconComponent fontSize="large" />
|
||||
</ListItemAvatar>
|
||||
<ListItemText
|
||||
primary={request.provider.title}
|
||||
secondary={
|
||||
error && (
|
||||
<Typography color="error">
|
||||
{error.message || 'An unspecified error occurred'}
|
||||
</Typography>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginRequestListItem;
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
List,
|
||||
makeStyles,
|
||||
Theme,
|
||||
Button,
|
||||
} from '@material-ui/core';
|
||||
import React, { FC, useMemo, useState } from 'react';
|
||||
import { useObservable } from 'react-use';
|
||||
import LoginRequestListItem from './LoginRequestListItem';
|
||||
import { useApi, oauthRequestApiRef } from '../../api';
|
||||
|
||||
const useStyles = makeStyles<Theme>((theme) => ({
|
||||
dialog: {
|
||||
paddingTop: theme.spacing(1),
|
||||
},
|
||||
title: {
|
||||
minWidth: 0,
|
||||
},
|
||||
contentList: {
|
||||
padding: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
type OAuthRequestDialogProps = {};
|
||||
|
||||
export const OAuthRequestDialog: FC<OAuthRequestDialogProps> = () => {
|
||||
const classes = useStyles();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const oauthRequestApi = useApi(oauthRequestApiRef);
|
||||
const requests = useObservable(
|
||||
useMemo(() => oauthRequestApi.authRequest$(), [oauthRequestApi]),
|
||||
[],
|
||||
);
|
||||
|
||||
const handleRejectAll = () => {
|
||||
requests.forEach((request) => request.reject());
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={Boolean(requests.length)}
|
||||
fullWidth
|
||||
maxWidth="xs"
|
||||
classes={{ paper: classes.dialog }}
|
||||
>
|
||||
<DialogTitle classes={{ root: classes.title }}>
|
||||
Login Required
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent classes={{ root: classes.contentList }}>
|
||||
<List>
|
||||
{requests.map((request) => (
|
||||
<LoginRequestListItem
|
||||
key={request.provider.title}
|
||||
request={request}
|
||||
busy={busy}
|
||||
setBusy={setBusy}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={handleRejectAll}>Reject All</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { OAuthRequestDialog } from './OAuthRequestDialog';
|
||||
@@ -33,6 +33,7 @@ export { default as HorizontalProgress } from './components/ProgressBars/Horizon
|
||||
export { default as CopyTextButton } from './components/CopyTextButton';
|
||||
export { default as Progress } from './components/Progress';
|
||||
export * from './components/SimpleStepper';
|
||||
export { OAuthRequestDialog } from './components/OAuthRequestDialog';
|
||||
export { AlphaLabel, BetaLabel } from './components/Lifecycle';
|
||||
export { default as SupportButton } from './components/SupportButton';
|
||||
export { default as Table, SubvalueCell } from './components/Table';
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-use": "^13.24.0"
|
||||
"react-use": "^14.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.5",
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"classnames": "^2.2.6",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-use": "^13.24.0"
|
||||
"react-use": "^14.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.5",
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"graphql": "15.0.0",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-use": "^13.24.0"
|
||||
"react-use": "^14.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.5",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-use": "^13.24.0"
|
||||
"react-use": "^14.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.5",
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"react-dom": "^16.13.1",
|
||||
"react-markdown": "^4.3.1",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-use": "^13.24.0"
|
||||
"react-use": "^14.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.5",
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-hook-form": "^5.7.2",
|
||||
"react-use": "^13.24.0"
|
||||
"react-use": "^14.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.5",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-use": "^13.24.0"
|
||||
"react-use": "^14.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.5",
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-use": "^13.24.0"
|
||||
"react-use": "^14.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.5",
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-use": "^13.24.0"
|
||||
"react-use": "^14.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.5",
|
||||
|
||||
@@ -4201,10 +4201,10 @@
|
||||
dependencies:
|
||||
"@types/sizzle" "*"
|
||||
|
||||
"@types/js-cookie@2.2.5":
|
||||
version "2.2.5"
|
||||
resolved "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.5.tgz#38dfaacae8623b37cc0b0d27398e574e3fc28b1e"
|
||||
integrity sha512-cpmwBRcHJmmZx0OGU7aPVwGWGbs4iKwVYchk9iuMtxNCA2zorwdaTz4GkLgs2WGxiRZRFKnV1k6tRUHX7tBMxg==
|
||||
"@types/js-cookie@2.2.6":
|
||||
version "2.2.6"
|
||||
resolved "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.6.tgz#f1a1cb35aff47bc5cfb05cb0c441ca91e914c26f"
|
||||
integrity sha512-+oY0FDTO2GYKEV0YPvSshGq9t7YozVkgvXLty7zogQNuCxBhT9/3INX9Q7H1aRZ4SUDRXAKlJuA4EA5nTt7SNw==
|
||||
|
||||
"@types/json-schema@*", "@types/json-schema@^7.0.3":
|
||||
version "7.0.4"
|
||||
@@ -4971,10 +4971,10 @@
|
||||
"@webassemblyjs/wast-parser" "1.9.0"
|
||||
"@xtuc/long" "4.2.2"
|
||||
|
||||
"@xobotyi/scrollbar-width@1.9.4":
|
||||
version "1.9.4"
|
||||
resolved "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.4.tgz#a7dce20b7465bcad29cd6bbb557695e4ea7863cb"
|
||||
integrity sha512-o12FCQt/X5n3pgKEWGpt0f/7Eg4mfv3uRwPUrctiOT8ZuxbH3cNLGWfH/8y6KxVJg4L2885ucuXQ6XECZzUiJA==
|
||||
"@xobotyi/scrollbar-width@1.9.5":
|
||||
version "1.9.5"
|
||||
resolved "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz#80224a6919272f405b87913ca13b92929bdf3c4d"
|
||||
integrity sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==
|
||||
|
||||
"@xtuc/ieee754@^1.2.0":
|
||||
version "1.2.0"
|
||||
@@ -18180,13 +18180,13 @@ react-transition-group@^4.0.0, react-transition-group@^4.3.0:
|
||||
loose-envify "^1.4.0"
|
||||
prop-types "^15.6.2"
|
||||
|
||||
react-use@^13.24.0:
|
||||
version "13.27.0"
|
||||
resolved "https://registry.npmjs.org/react-use/-/react-use-13.27.0.tgz#53a619dc9213e2cbe65d6262e8b0e76641ade4aa"
|
||||
integrity sha512-2lyTyqJWyvnaP/woVtDcFS4B5pUYz0FQWI9pVHk/6TBWom2x3/ziJthkEn/LbCA9Twv39xSQU7Dn0zdIWfsNTQ==
|
||||
react-use@^14.2.0:
|
||||
version "14.2.0"
|
||||
resolved "https://registry.npmjs.org/react-use/-/react-use-14.2.0.tgz#abac033fae5e358599b7e38084ff11b02e5d4868"
|
||||
integrity sha512-vwC7jsBsiDENLrXGPqIH3W4mMS2j24h5jp4ol3jiiUQzZhCaG+ihumrShJxBI59hXso1pLHAePRQAg/fJjDcaQ==
|
||||
dependencies:
|
||||
"@types/js-cookie" "2.2.5"
|
||||
"@xobotyi/scrollbar-width" "1.9.4"
|
||||
"@types/js-cookie" "2.2.6"
|
||||
"@xobotyi/scrollbar-width" "1.9.5"
|
||||
copy-to-clipboard "^3.2.0"
|
||||
fast-deep-equal "^3.1.1"
|
||||
fast-shallow-equal "^1.0.0"
|
||||
|
||||
Reference in New Issue
Block a user