From 150908ae5500992b441e3e6159f4c5e8a3cc0cfc Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 May 2020 00:12:23 +0200 Subject: [PATCH 1/8] packages/core: add common Observable type based on TC39 --- packages/core/src/api/index.ts | 1 + packages/core/src/api/types.ts | 63 ++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) create mode 100644 packages/core/src/api/types.ts diff --git a/packages/core/src/api/index.ts b/packages/core/src/api/index.ts index 0f111b26ba..2eabc38280 100644 --- a/packages/core/src/api/index.ts +++ b/packages/core/src/api/index.ts @@ -18,3 +18,4 @@ export * from './apis'; export * from './app'; export * from './navTargets'; export * from './plugin'; +export * from './types'; diff --git a/packages/core/src/api/types.ts b/packages/core/src/api/types.ts new file mode 100644 index 0000000000..28a6155017 --- /dev/null +++ b/packages/core/src/api/types.ts @@ -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 = { + 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 = { + /** + * Subscribes to this observable to start receiving new values. + */ + subscribe(observer: Observer): Subscription; + subscribe( + onNext: (value: T) => void, + onError?: (error: Error) => void, + onComplete?: () => void, + ): Subscription; +}; From 4cd23d0231dfabbc7c00360db14e12ddb14bdf6e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 May 2020 00:22:40 +0200 Subject: [PATCH 2/8] packages/core: added oauthRequest API definition --- .../core/src/api/apis/definitions/index.ts | 1 + .../src/api/apis/definitions/oauthrequest.ts | 173 ++++++++++++++++++ 2 files changed, 174 insertions(+) create mode 100644 packages/core/src/api/apis/definitions/oauthrequest.ts diff --git a/packages/core/src/api/apis/definitions/index.ts b/packages/core/src/api/apis/definitions/index.ts index ca1290403f..5d0b8095d8 100644 --- a/packages/core/src/api/apis/definitions/index.ts +++ b/packages/core/src/api/apis/definitions/index.ts @@ -23,3 +23,4 @@ export * from './alert'; export * from './error'; export * from './featureFlags'; +export * from './oauthrequest'; diff --git a/packages/core/src/api/apis/definitions/oauthrequest.ts b/packages/core/src/api/apis/definitions/oauthrequest.ts new file mode 100644 index 0000000000..b31b828183 --- /dev/null +++ b/packages/core/src/api/apis/definitions/oauthrequest.ts @@ -0,0 +1,173 @@ +/* + * 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; + 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 show to the user so that they can be informed about what login is being requested + * before a popup is shown. + */ +export type AuthProviderInfo = { + /** + * 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 = { + /** + * Information about the auth provider, which will be forwarded to auth requests. + */ + info: AuthProviderInfo; + + /** + * Implementation of the auth flow, which will be called synchronously when + * triggerAuth() is called on an auth requests. + */ + authHandler(scope: OAuthScopes): Promise; +}; + +/** + * Function used to trigger new auth requests for a set of scopes. + * + * The returned promise will resolve to the same value returned by the authHandler 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 authHandler will be the + * union of all requested scopes. + */ +export type AuthRequester = (scope: OAuthScopes) => Promise; + +/** + * An auth request for a single auth provider. + */ +export type AuthRequest = { + /** + * Information about the auth provider, as given in the AuthRequesterOptions + */ + info: AuthProviderInfo; + + /** + * Rejects the request, causing all pending AuthRequester calls to fail with "RejectedError". + */ + reject: () => void; + + /** + * Synchronously calls the auth handler with all scope currently in the request. + */ + triggerAuth(): Promise; +}; + +/** + * 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. + */ + showLoginPopup(options: LoginPopupOptions): Promise; + + /** + * 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 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(options: AuthRequesterOptions): AuthRequester; + + /** + * Subscribes as a handler of 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 authHandler call. + */ + handleAuthRequests(): Observable; +}; + +export const oauthRequestApiRef = new ApiRef({ + id: 'core.oauthrequest', + description: 'An API for implementing unified OAuth flows in Backstage', +}); From 348e35d60b8ce6ee07225957ea0a6c8ef910c16d Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 May 2020 00:35:06 +0200 Subject: [PATCH 3/8] packages/core: added zen-observable dependency --- packages/core/package.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index df69a9353b..eaf25e07fd 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -41,7 +41,8 @@ "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", + "zen-observable": "^0.8.15" }, "devDependencies": { "@backstage/cli": "^0.1.1-alpha.5", @@ -55,7 +56,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" From 08732d57d0548d25e4ea358ed4af266478d37ffe Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 May 2020 00:35:46 +0200 Subject: [PATCH 4/8] packages/core: add implementation of OAuthRequest API --- .../OAuthRequestManager/BasicOAuthScopes.ts | 73 +++++++ .../OAuthPendingRequests.test.ts | 97 ++++++++++ .../OAuthPendingRequests.ts | 111 +++++++++++ .../OAuthRequestManager.test.ts | 178 ++++++++++++++++++ .../OAuthRequestManager.ts | 158 ++++++++++++++++ .../OAuthRequestManager/index.ts | 17 ++ .../src/api/apis/implementations/index.ts | 1 + 7 files changed, 635 insertions(+) create mode 100644 packages/core/src/api/apis/implementations/OAuthRequestManager/BasicOAuthScopes.ts create mode 100644 packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.test.ts create mode 100644 packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts create mode 100644 packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.test.ts create mode 100644 packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts create mode 100644 packages/core/src/api/apis/implementations/OAuthRequestManager/index.ts diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/BasicOAuthScopes.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/BasicOAuthScopes.ts new file mode 100644 index 0000000000..053d017da8 --- /dev/null +++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/BasicOAuthScopes.ts @@ -0,0 +1,73 @@ +/* + * 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'; + +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, + 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 { + 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; + } +} diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.test.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.test.ts new file mode 100644 index 0000000000..be762ca936 --- /dev/null +++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.test.ts @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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); + }); +}); diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts new file mode 100644 index 0000000000..2398282842 --- /dev/null +++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts @@ -0,0 +1,111 @@ +/* + * 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 = { + scopes: OAuthScopes; + resolve: (value?: ResultType | PromiseLike | undefined) => void; + reject: (reason: Error) => void; +}; + +export type PendingRequest = { + scopes: OAuthScopes | undefined; + resolve: (value: ResultType) => void; + reject: (reason: Error) => void; +}; + +export type OAuthPendingRequestsApi = { + request(scopes: OAuthScopes): Promise; + resolve(scopes: OAuthScopes, result: ResultType): void; + reject(error: Error): void; + pending(): Observable>; +}; + +export class OAuthPendingRequests + implements OAuthPendingRequestsApi { + private requests: RequestQueueEntry[] = []; + private listeners: ZenObservable.SubscriptionObserver< + PendingRequest + >[] = []; + + request(scopes: OAuthScopes): Promise { + 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> { + return new Observable((subscriber) => { + this.listeners.push(subscriber); + subscriber.next(this.getCurrentPending()); + return () => { + this.listeners = this.listeners.filter((l) => l !== subscriber); + }; + }); + } + + private getCurrentPending(): PendingRequest { + 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); + } + }, + }; + } +} diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.test.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.test.ts new file mode 100644 index 0000000000..940c0230f9 --- /dev/null +++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts new file mode 100644 index 0000000000..239d4aebc6 --- /dev/null +++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts @@ -0,0 +1,158 @@ +/* + * 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, + AuthRequest, + AuthRequester, + AuthRequesterOptions, +} from '../../definitions/oauthrequest'; +import Observable from 'zen-observable'; +import { OAuthPendingRequests, PendingRequest } from './OAuthPendingRequests'; + +export class OAuthRequestManager implements OAuthRequestApi { + private readonly requests$: Observable; + private readonly observers = new Set< + ZenObservable.SubscriptionObserver + >(); + private currentRequests: AuthRequest[] = []; + private handlerCount = 0; + + constructor() { + this.requests$ = new Observable((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(options: AuthRequesterOptions): AuthRequester { + const handler = new OAuthPendingRequests(); + + 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, + options: AuthRequesterOptions, + ): AuthRequest | undefined { + const { scopes } = request; + if (!scopes) { + return undefined; + } + + return { + info: options.info, + triggerAuth: async () => { + const result = await options.authHandler(scopes); + request.resolve(result); + }, + reject: () => { + const error = new Error('Login failed, rejected by user'); + error.name = 'RejectedError'; + request.reject(error); + }, + }; + } + + handleAuthRequests(): Observable { + return this.requests$; + } + + async showLoginPopup(options: LoginPopupOptions): Promise { + 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); + }); + } +} diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/index.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/index.ts new file mode 100644 index 0000000000..0fe9bfae0f --- /dev/null +++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/index.ts @@ -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'; diff --git a/packages/core/src/api/apis/implementations/index.ts b/packages/core/src/api/apis/implementations/index.ts index b5ea6467cb..ebd89b54c6 100644 --- a/packages/core/src/api/apis/implementations/index.ts +++ b/packages/core/src/api/apis/implementations/index.ts @@ -20,3 +20,4 @@ export * from './AlertApiForwarder'; export * from './ErrorApiForwarder'; +export * from './OAuthRequestManager'; From 5553d2dc94ed8b22a26f13fef87747b695a3b0f9 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 May 2020 00:45:34 +0200 Subject: [PATCH 5/8] bump react-use to 14.2.0 + add to core --- packages/app/package.json | 2 +- .../default-app/packages/app/package.json.hbs | 2 +- .../plugins/welcome/package.json.hbs | 2 +- .../templates/default-plugin/package.json.hbs | 2 +- packages/core/package.json | 1 + plugins/catalog/package.json | 2 +- plugins/explore/package.json | 2 +- plugins/graphiql/package.json | 2 +- plugins/home-page/package.json | 2 +- plugins/lighthouse/package.json | 2 +- plugins/register-component/package.json | 2 +- plugins/scaffolder/package.json | 2 +- plugins/tech-radar/package.json | 2 +- plugins/welcome/package.json | 2 +- yarn.lock | 28 +++++++++---------- 15 files changed, 28 insertions(+), 27 deletions(-) diff --git a/packages/app/package.json b/packages/app/package.json index bce1f8c01c..e3b71820b6 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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": { diff --git a/packages/cli/templates/default-app/packages/app/package.json.hbs b/packages/cli/templates/default-app/packages/app/package.json.hbs index 5f4459f556..24ab67f830 100644 --- a/packages/cli/templates/default-app/packages/app/package.json.hbs +++ b/packages/cli/templates/default-app/packages/app/package.json.hbs @@ -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", diff --git a/packages/cli/templates/default-app/plugins/welcome/package.json.hbs b/packages/cli/templates/default-app/plugins/welcome/package.json.hbs index 194e81da25..75321fa60c 100644 --- a/packages/cli/templates/default-app/plugins/welcome/package.json.hbs +++ b/packages/cli/templates/default-app/plugins/welcome/package.json.hbs @@ -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": { diff --git a/packages/cli/templates/default-plugin/package.json.hbs b/packages/cli/templates/default-plugin/package.json.hbs index c4a72c6b06..bbb6592d62 100644 --- a/packages/cli/templates/default-plugin/package.json.hbs +++ b/packages/cli/templates/default-plugin/package.json.hbs @@ -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}}", diff --git a/packages/core/package.json b/packages/core/package.json index eaf25e07fd..f2a1adc57b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -42,6 +42,7 @@ "react-router-dom": "^5.2.0", "react-sparklines": "^1.7.0", "react-syntax-highlighter": "^12.2.1", + "react-use": "^14.2.0", "zen-observable": "^0.8.15" }, "devDependencies": { diff --git a/plugins/catalog/package.json b/plugins/catalog/package.json index 3c46b5034f..b34c85e497 100644 --- a/plugins/catalog/package.json +++ b/plugins/catalog/package.json @@ -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", diff --git a/plugins/explore/package.json b/plugins/explore/package.json index c330068940..0750b2bd09 100644 --- a/plugins/explore/package.json +++ b/plugins/explore/package.json @@ -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", diff --git a/plugins/graphiql/package.json b/plugins/graphiql/package.json index a81ce53e5a..f031ea643c 100644 --- a/plugins/graphiql/package.json +++ b/plugins/graphiql/package.json @@ -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", diff --git a/plugins/home-page/package.json b/plugins/home-page/package.json index 9a30bb394f..015ad1deab 100644 --- a/plugins/home-page/package.json +++ b/plugins/home-page/package.json @@ -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", diff --git a/plugins/lighthouse/package.json b/plugins/lighthouse/package.json index 200897b7c3..62487f3f1d 100644 --- a/plugins/lighthouse/package.json +++ b/plugins/lighthouse/package.json @@ -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", diff --git a/plugins/register-component/package.json b/plugins/register-component/package.json index 4fc5af10da..68822e983b 100644 --- a/plugins/register-component/package.json +++ b/plugins/register-component/package.json @@ -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", diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index ba686fc016..3dc7893ba9 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -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", diff --git a/plugins/tech-radar/package.json b/plugins/tech-radar/package.json index cbddd0c7db..5e6b90b221 100644 --- a/plugins/tech-radar/package.json +++ b/plugins/tech-radar/package.json @@ -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", diff --git a/plugins/welcome/package.json b/plugins/welcome/package.json index ea1c579af3..b9f9156b45 100644 --- a/plugins/welcome/package.json +++ b/plugins/welcome/package.json @@ -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", diff --git a/yarn.lock b/yarn.lock index a2c9f54c82..d09e224d25 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" From c689244a177c32a43feb56a4f3b19f1698b788d2 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 May 2020 00:48:34 +0200 Subject: [PATCH 6/8] packages/core: added OAuthRequestDialog --- .../LoginRequestListItem.tsx | 81 +++++++++++++++++ .../OAuthRequestDialog/OAuthRequestDialog.tsx | 88 +++++++++++++++++++ .../components/OAuthRequestDialog/index.ts | 17 ++++ packages/core/src/index.ts | 1 + 4 files changed, 187 insertions(+) create mode 100644 packages/core/src/components/OAuthRequestDialog/LoginRequestListItem.tsx create mode 100644 packages/core/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx create mode 100644 packages/core/src/components/OAuthRequestDialog/index.ts diff --git a/packages/core/src/components/OAuthRequestDialog/LoginRequestListItem.tsx b/packages/core/src/components/OAuthRequestDialog/LoginRequestListItem.tsx new file mode 100644 index 0000000000..56d69aa814 --- /dev/null +++ b/packages/core/src/components/OAuthRequestDialog/LoginRequestListItem.tsx @@ -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 { AuthRequest } from '../../api'; + +const useItemStyles = makeStyles((theme) => ({ + root: { + paddingLeft: theme.spacing(3), + }, +})); + +type RowProps = { + request: AuthRequest; + busy: boolean; + setBusy: (busy: boolean) => void; +}; + +const LoginRequestListItem: FC = ({ request, busy, setBusy }) => { + const classes = useItemStyles(); + const [error, setError] = useState(); + + const handleContinue = async () => { + setBusy(true); + try { + await request.triggerAuth(); + } catch (e) { + setError(e); + } finally { + setBusy(false); + } + }; + + const IconComponent = request.info.icon; + + return ( + + + + + + {error.message || 'An unspecified error occurred'} + + ) + } + /> + + ); +}; + +export default LoginRequestListItem; diff --git a/packages/core/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx b/packages/core/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx new file mode 100644 index 0000000000..2a105fe5e3 --- /dev/null +++ b/packages/core/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx @@ -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) => ({ + dialog: { + paddingTop: theme.spacing(1), + }, + title: { + minWidth: 0, + }, + contentList: { + padding: 0, + }, +})); + +type OAuthRequestDialogProps = {}; + +export const OAuthRequestDialog: FC = () => { + const classes = useStyles(); + const [busy, setBusy] = useState(false); + const oauthRequestApi = useApi(oauthRequestApiRef); + const requests = useObservable( + useMemo(() => oauthRequestApi.handleAuthRequests(), [oauthRequestApi]), + [], + ); + + const handleRejectAll = () => { + requests.forEach((request) => request.reject()); + }; + + return ( + + + Login Required + + + + + {requests.map((request) => ( + + ))} + + + + + + + + ); +}; diff --git a/packages/core/src/components/OAuthRequestDialog/index.ts b/packages/core/src/components/OAuthRequestDialog/index.ts new file mode 100644 index 0000000000..45f87ece1d --- /dev/null +++ b/packages/core/src/components/OAuthRequestDialog/index.ts @@ -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'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e817ada9c9..1bd35f67c3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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'; From 1c50ded618dddadc85b299652cc42ef7310b253f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 May 2020 01:17:32 +0200 Subject: [PATCH 7/8] packages/core: added some basic docs for OAuthRequestManager --- .../OAuthRequestManager/BasicOAuthScopes.ts | 4 ++++ .../OAuthRequestManager/OAuthPendingRequests.ts | 5 +++++ .../OAuthRequestManager/OAuthRequestManager.ts | 7 +++++++ 3 files changed, 16 insertions(+) diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/BasicOAuthScopes.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/BasicOAuthScopes.ts index 053d017da8..14aecd1607 100644 --- a/packages/core/src/api/apis/implementations/OAuthRequestManager/BasicOAuthScopes.ts +++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/BasicOAuthScopes.ts @@ -16,6 +16,10 @@ 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); diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts index 2398282842..bc1b33ca5e 100644 --- a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts +++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts @@ -36,6 +36,11 @@ export type OAuthPendingRequestsApi = { pending(): Observable>; }; +/** + * The OAuthPendingRequests class is a utility for managing and observing + * a stream of requests for oauth scopes, and resolving them correctly once + * requests are fulfilled. + */ export class OAuthPendingRequests implements OAuthPendingRequestsApi { private requests: RequestQueueEntry[] = []; diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts index 239d4aebc6..c8cfacd161 100644 --- a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts +++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts @@ -24,6 +24,13 @@ import { 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 requests for each OAuth provider. + */ export class OAuthRequestManager implements OAuthRequestApi { private readonly requests$: Observable; private readonly observers = new Set< From eec599738d436fa467251a388841be4c9a0d9041 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 14 May 2020 11:02:35 +0200 Subject: [PATCH 8/8] packages/core: naming and docs updates for OAuthRequest API --- .../src/api/apis/definitions/oauthrequest.ts | 50 ++++++++++++------- .../OAuthPendingRequests.ts | 4 +- .../OAuthRequestManager.ts | 24 ++++----- packages/core/src/api/types.ts | 2 +- .../LoginRequestListItem.tsx | 10 ++-- .../OAuthRequestDialog/OAuthRequestDialog.tsx | 4 +- 6 files changed, 53 insertions(+), 41 deletions(-) diff --git a/packages/core/src/api/apis/definitions/oauthrequest.ts b/packages/core/src/api/apis/definitions/oauthrequest.ts index b31b828183..09e5f77718 100644 --- a/packages/core/src/api/apis/definitions/oauthrequest.ts +++ b/packages/core/src/api/apis/definitions/oauthrequest.ts @@ -63,10 +63,10 @@ export type LoginPopupOptions = { /** * Information about the auth provider that we're requesting a login towards. * - * This should be show to the user so that they can be informed about what login is being requested + * 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 AuthProviderInfo = { +export type AuthProvider = { /** * Title for the auth provider, for example "GitHub" */ @@ -82,39 +82,45 @@ export type AuthProviderInfo = { * 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 = { +export type AuthRequesterOptions = { /** * Information about the auth provider, which will be forwarded to auth requests. */ - info: AuthProviderInfo; + provider: AuthProvider; /** * Implementation of the auth flow, which will be called synchronously when - * triggerAuth() is called on an auth requests. + * trigger() is called on an auth requests. */ - authHandler(scope: OAuthScopes): Promise; + onAuthRequest(scope: OAuthScopes): Promise; }; /** * Function used to trigger new auth requests for a set of scopes. * - * The returned promise will resolve to the same value returned by the authHandler in the + * 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 authHandler will be the + * will be merged into one request, and the scopes forwarded to the onAuthRequest will be the * union of all requested scopes. */ -export type AuthRequester = (scope: OAuthScopes) => Promise; +export type AuthRequester = ( + scope: OAuthScopes, +) => Promise; /** - * An auth request for a single auth provider. + * 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 AuthRequest = { +export type PendingAuthRequest = { /** * Information about the auth provider, as given in the AuthRequesterOptions */ - info: AuthProviderInfo; + provider: AuthProvider; /** * Rejects the request, causing all pending AuthRequester calls to fail with "RejectedError". @@ -122,9 +128,11 @@ export type AuthRequest = { reject: () => void; /** - * Synchronously calls the auth handler with all scope currently in the request. + * 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. */ - triggerAuth(): Promise; + trigger(): Promise; }; /** @@ -136,6 +144,8 @@ export type OAuthRequestApi = { * * 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; @@ -143,7 +153,7 @@ export type OAuthRequestApi = { * 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 handle auth requests. + * 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 @@ -151,10 +161,12 @@ export type OAuthRequestApi = { * * See AuthRequesterOptions, AuthRequester, and handleAuthRequests for more info. */ - createAuthRequester(options: AuthRequesterOptions): AuthRequester; + createAuthRequester( + options: AuthRequesterOptions, + ): AuthRequester; /** - * Subscribes as a handler of auth requests. The returned observable will emit all + * 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. @@ -162,9 +174,9 @@ export type OAuthRequestApi = { * 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 authHandler call. + * AuthRequester calls will resolve to the value returned by the onAuthRequest call. */ - handleAuthRequests(): Observable; + authRequest$(): Observable; }; export const oauthRequestApiRef = new ApiRef({ diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts index bc1b33ca5e..904acecf9f 100644 --- a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts +++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthPendingRequests.ts @@ -38,8 +38,8 @@ export type OAuthPendingRequestsApi = { /** * The OAuthPendingRequests class is a utility for managing and observing - * a stream of requests for oauth scopes, and resolving them correctly once - * requests are fulfilled. + * a stream of requests for oauth scopes for a single provider, and resolving + * them correctly once requests are fulfilled. */ export class OAuthPendingRequests implements OAuthPendingRequestsApi { diff --git a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts index c8cfacd161..dd31a4b33c 100644 --- a/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts +++ b/packages/core/src/api/apis/implementations/OAuthRequestManager/OAuthRequestManager.ts @@ -17,7 +17,7 @@ import { OAuthRequestApi, LoginPopupOptions, - AuthRequest, + PendingAuthRequest, AuthRequester, AuthRequesterOptions, } from '../../definitions/oauthrequest'; @@ -29,18 +29,18 @@ import { OAuthPendingRequests, PendingRequest } from './OAuthPendingRequests'; * * 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 requests for each OAuth provider. + * them all together into a single request for each OAuth provider. */ export class OAuthRequestManager implements OAuthRequestApi { - private readonly requests$: Observable; + private readonly request$: Observable; private readonly observers = new Set< - ZenObservable.SubscriptionObserver + ZenObservable.SubscriptionObserver >(); - private currentRequests: AuthRequest[] = []; + private currentRequests: PendingAuthRequest[] = []; private handlerCount = 0; constructor() { - this.requests$ = new Observable((observer) => { + this.request$ = new Observable((observer) => { observer.next(this.currentRequests); this.observers.add(observer); @@ -79,16 +79,16 @@ export class OAuthRequestManager implements OAuthRequestApi { private makeAuthRequest( request: PendingRequest, options: AuthRequesterOptions, - ): AuthRequest | undefined { + ): PendingAuthRequest | undefined { const { scopes } = request; if (!scopes) { return undefined; } return { - info: options.info, - triggerAuth: async () => { - const result = await options.authHandler(scopes); + provider: options.provider, + trigger: async () => { + const result = await options.onAuthRequest(scopes); request.resolve(result); }, reject: () => { @@ -99,8 +99,8 @@ export class OAuthRequestManager implements OAuthRequestApi { }; } - handleAuthRequests(): Observable { - return this.requests$; + authRequest$(): Observable { + return this.request$; } async showLoginPopup(options: LoginPopupOptions): Promise { diff --git a/packages/core/src/api/types.ts b/packages/core/src/api/types.ts index 28a6155017..09f0bc11ae 100644 --- a/packages/core/src/api/types.ts +++ b/packages/core/src/api/types.ts @@ -32,7 +32,7 @@ export type Observer = { */ export type Subscription = { /** - * CAncels the subscription + * Cancels the subscription */ unsubscribe(): void; diff --git a/packages/core/src/components/OAuthRequestDialog/LoginRequestListItem.tsx b/packages/core/src/components/OAuthRequestDialog/LoginRequestListItem.tsx index 56d69aa814..8e3d98c998 100644 --- a/packages/core/src/components/OAuthRequestDialog/LoginRequestListItem.tsx +++ b/packages/core/src/components/OAuthRequestDialog/LoginRequestListItem.tsx @@ -23,7 +23,7 @@ import { Theme, } from '@material-ui/core'; import React, { FC, useState } from 'react'; -import { AuthRequest } from '../../api'; +import { PendingAuthRequest } from '../../api'; const useItemStyles = makeStyles((theme) => ({ root: { @@ -32,7 +32,7 @@ const useItemStyles = makeStyles((theme) => ({ })); type RowProps = { - request: AuthRequest; + request: PendingAuthRequest; busy: boolean; setBusy: (busy: boolean) => void; }; @@ -44,7 +44,7 @@ const LoginRequestListItem: FC = ({ request, busy, setBusy }) => { const handleContinue = async () => { setBusy(true); try { - await request.triggerAuth(); + await request.trigger(); } catch (e) { setError(e); } finally { @@ -52,7 +52,7 @@ const LoginRequestListItem: FC = ({ request, busy, setBusy }) => { } }; - const IconComponent = request.info.icon; + const IconComponent = request.provider.icon; return ( = ({ request, busy, setBusy }) => { diff --git a/packages/core/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx b/packages/core/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx index 2a105fe5e3..617cce2aee 100644 --- a/packages/core/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx +++ b/packages/core/src/components/OAuthRequestDialog/OAuthRequestDialog.tsx @@ -48,7 +48,7 @@ export const OAuthRequestDialog: FC = () => { const [busy, setBusy] = useState(false); const oauthRequestApi = useApi(oauthRequestApiRef); const requests = useObservable( - useMemo(() => oauthRequestApi.handleAuthRequests(), [oauthRequestApi]), + useMemo(() => oauthRequestApi.authRequest$(), [oauthRequestApi]), [], ); @@ -71,7 +71,7 @@ export const OAuthRequestDialog: FC = () => { {requests.map((request) => (