From b2c54fd4baed2aa0a1f0f16ec4ca704456c88ed8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Jun 2020 14:14:41 +0200 Subject: [PATCH 01/12] packages/core: add initial simple SignInPage with guest provider --- .../core/src/layout/SignInPage/SignInPage.tsx | 69 +++++++++++++++++++ packages/core/src/layout/SignInPage/index.ts | 17 +++++ packages/core/src/layout/index.ts | 1 + 3 files changed, 87 insertions(+) create mode 100644 packages/core/src/layout/SignInPage/SignInPage.tsx create mode 100644 packages/core/src/layout/SignInPage/index.ts diff --git a/packages/core/src/layout/SignInPage/SignInPage.tsx b/packages/core/src/layout/SignInPage/SignInPage.tsx new file mode 100644 index 0000000000..7e24607eee --- /dev/null +++ b/packages/core/src/layout/SignInPage/SignInPage.tsx @@ -0,0 +1,69 @@ +/* + * 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 React, { FC } from 'react'; +import { Page } from '../Page'; +import { Header } from '../Header'; +import { Content } from '../Content/Content'; +import { ContentHeader } from '../ContentHeader/ContentHeader'; +import { Grid, Typography, Button } from '@material-ui/core'; +import { InfoCard } from '../InfoCard/InfoCard'; +import { SignInPageProps } from '@backstage/core-api'; + +const GuestProvider: FC = ({ onResult }) => ( + + onResult({ userId: 'guest' })} + > + Enter + + } + > + + Enter as a Guest User. +
+ You will not have a verified identity, +
+ so some features might be unavailable. +
+
+
+); + +export type SignInProviders = 'guest'; + +export type Props = SignInPageProps & { + providers: SignInProviders[]; +}; + +export const SignInPage: FC = ({ onResult, providers }) => { + return ( + +
+ + + + {providers.includes('guest') && } + + + + ); +}; diff --git a/packages/core/src/layout/SignInPage/index.ts b/packages/core/src/layout/SignInPage/index.ts new file mode 100644 index 0000000000..49f55aefc5 --- /dev/null +++ b/packages/core/src/layout/SignInPage/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 { SignInPage } from './SignInPage'; diff --git a/packages/core/src/layout/index.ts b/packages/core/src/layout/index.ts index 9e1298f3d6..e2de159ec6 100644 --- a/packages/core/src/layout/index.ts +++ b/packages/core/src/layout/index.ts @@ -23,5 +23,6 @@ export * from './HomepageTimer'; export * from './InfoCard'; export * from './Page'; export * from './Sidebar'; +export * from './SignInPage'; export * from './TabbedCard'; export * from './HeaderTabs'; From e7165fd887ca014da620d622528bcdef2157e192 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Jun 2020 15:13:46 +0200 Subject: [PATCH 02/12] packages/core: add logout item to user settings in sidebar --- packages/core/src/layout/Sidebar/UserSettings.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/core/src/layout/Sidebar/UserSettings.tsx b/packages/core/src/layout/Sidebar/UserSettings.tsx index 8ca2580df9..36e8e1088b 100644 --- a/packages/core/src/layout/Sidebar/UserSettings.tsx +++ b/packages/core/src/layout/Sidebar/UserSettings.tsx @@ -17,17 +17,25 @@ import React, { useContext, useEffect } from 'react'; import Collapse from '@material-ui/core/Collapse'; import Star from '@material-ui/icons/Star'; +import SignOutIcon from '@material-ui/icons/MeetingRoom'; import { SidebarContext } from './config'; -import { googleAuthApiRef, githubAuthApiRef } from '@backstage/core-api'; +import { + googleAuthApiRef, + githubAuthApiRef, + identityApiRef, + useApi, +} from '@backstage/core-api'; import { OAuthProviderSettings, OIDCProviderSettings, UserProfile as SidebarUserProfile, } from './Settings'; +import { SidebarItem } from './Items'; export function SidebarUserSettings() { const { isOpen: sidebarOpen } = useContext(SidebarContext); const [open, setOpen] = React.useState(false); + const identityApi = useApi(identityApiRef); // Close the provider list when sidebar collapse useEffect(() => { @@ -48,6 +56,11 @@ export function SidebarUserSettings() { apiRef={githubAuthApiRef} icon={Star} /> + identityApi.logout()} + /> ); From d221bc0daf53818d3938f0864c957a22eec06a2e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Jun 2020 15:18:46 +0200 Subject: [PATCH 03/12] packages/core: add logout and provider storage to SignInPage --- .../core/src/layout/SignInPage/SignInPage.tsx | 94 +++++++++++++------ 1 file changed, 65 insertions(+), 29 deletions(-) diff --git a/packages/core/src/layout/SignInPage/SignInPage.tsx b/packages/core/src/layout/SignInPage/SignInPage.tsx index 7e24607eee..ff7bbd4ef3 100644 --- a/packages/core/src/layout/SignInPage/SignInPage.tsx +++ b/packages/core/src/layout/SignInPage/SignInPage.tsx @@ -14,54 +14,90 @@ * limitations under the License. */ -import React, { FC } from 'react'; +import React, { FC, useLayoutEffect } from 'react'; import { Page } from '../Page'; import { Header } from '../Header'; import { Content } from '../Content/Content'; import { ContentHeader } from '../ContentHeader/ContentHeader'; import { Grid, Typography, Button } from '@material-ui/core'; import { InfoCard } from '../InfoCard/InfoCard'; -import { SignInPageProps } from '@backstage/core-api'; +import { SignInPageProps, SignInResult } from '@backstage/core-api'; -const GuestProvider: FC = ({ onResult }) => ( - - onResult({ userId: 'guest' })} - > - Enter - - } - > - - Enter as a Guest User. -
- You will not have a verified identity, -
- so some features might be unavailable. -
-
-
-); +const PROVIDER_STORAGE_KEY = '@backstage/core:SignInPage:provider'; -export type SignInProviders = 'guest'; +type ProviderProps = SignInPageProps & { + selected: boolean; +}; + +const GuestProvider: FC = ({ selected, onResult }) => { + useLayoutEffect(() => { + if (selected) { + onResult({ userId: 'guest' }); + } + }, [selected, onResult]); + + return ( + + onResult({ userId: 'guest' })} + > + Enter + + } + > + + Enter as a Guest User. +
+ You will not have a verified identity, +
+ so some features might be unavailable. +
+
+
+ ); +}; + +export type SignInProvider = 'guest'; export type Props = SignInPageProps & { - providers: SignInProviders[]; + providers: SignInProvider[]; }; export const SignInPage: FC = ({ onResult, providers }) => { + // We can't use storageApi here, as it might have a dependency on the IdentityApi + const selectedProvider = localStorage.getItem(PROVIDER_STORAGE_KEY); + + const makeResultHandler = (provider: SignInProvider) => ( + result: SignInResult, + ) => { + localStorage.setItem(PROVIDER_STORAGE_KEY, provider); + + onResult({ + ...result, + logout: async () => { + localStorage.removeItem(PROVIDER_STORAGE_KEY); + await result.logout?.(); + }, + }); + }; + return (
- {providers.includes('guest') && } + {providers.includes('guest') && ( + + )} From 70042fb378a964bd43b94541c860d3f20404ef59 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Jun 2020 15:20:06 +0200 Subject: [PATCH 04/12] packages/core: use app title as SignInPage header --- packages/core/src/layout/SignInPage/SignInPage.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/core/src/layout/SignInPage/SignInPage.tsx b/packages/core/src/layout/SignInPage/SignInPage.tsx index ff7bbd4ef3..f8879aa55b 100644 --- a/packages/core/src/layout/SignInPage/SignInPage.tsx +++ b/packages/core/src/layout/SignInPage/SignInPage.tsx @@ -21,7 +21,12 @@ import { Content } from '../Content/Content'; import { ContentHeader } from '../ContentHeader/ContentHeader'; import { Grid, Typography, Button } from '@material-ui/core'; import { InfoCard } from '../InfoCard/InfoCard'; -import { SignInPageProps, SignInResult } from '@backstage/core-api'; +import { + SignInPageProps, + SignInResult, + useApi, + configApiRef, +} from '@backstage/core-api'; const PROVIDER_STORAGE_KEY = '@backstage/core:SignInPage:provider'; @@ -69,6 +74,8 @@ export type Props = SignInPageProps & { }; export const SignInPage: FC = ({ onResult, providers }) => { + const configApi = useApi(configApiRef); + // We can't use storageApi here, as it might have a dependency on the IdentityApi const selectedProvider = localStorage.getItem(PROVIDER_STORAGE_KEY); @@ -88,7 +95,7 @@ export const SignInPage: FC = ({ onResult, providers }) => { return ( -
+
From b365d0ee6b6ed00d1f6dc58ea1a118320d1219f1 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Jun 2020 16:45:05 +0200 Subject: [PATCH 05/12] packages/core-api: add useApiHolder hook for when you want defer api access outside of react tree --- packages/core-api/src/apis/ApiProvider.tsx | 8 +++++++- packages/core-api/src/apis/index.ts | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/core-api/src/apis/ApiProvider.tsx b/packages/core-api/src/apis/ApiProvider.tsx index e157782024..24610a1372 100644 --- a/packages/core-api/src/apis/ApiProvider.tsx +++ b/packages/core-api/src/apis/ApiProvider.tsx @@ -39,13 +39,19 @@ ApiProvider.propTypes = { children: PropTypes.node, }; -export function useApi(apiRef: ApiRef): T { +export function useApiHolder(): ApiHolder { const apiHolder = useContext(Context); if (!apiHolder) { throw new Error('No ApiProvider available in react context'); } + return apiHolder; +} + +export function useApi(apiRef: ApiRef): T { + const apiHolder = useApiHolder(); + const api = apiHolder.get(apiRef); if (!api) { throw new Error(`No implementation available for ${apiRef}`); diff --git a/packages/core-api/src/apis/index.ts b/packages/core-api/src/apis/index.ts index 332636580c..c3689b6703 100644 --- a/packages/core-api/src/apis/index.ts +++ b/packages/core-api/src/apis/index.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -export { ApiProvider, useApi } from './ApiProvider'; +export { ApiProvider, useApi, useApiHolder } from './ApiProvider'; export { ApiRegistry } from './ApiRegistry'; export { ApiTestRegistry } from './ApiTestRegistry'; export * from './ApiRef'; From 9b5fa0da86faa63672403f824485cc378ed9e2c0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Jun 2020 16:52:05 +0200 Subject: [PATCH 06/12] packages/core: pluggable sign-in providers and re-loading of session --- .../core/src/layout/SignInPage/SignInPage.tsx | 182 ++++++++++++------ 1 file changed, 126 insertions(+), 56 deletions(-) diff --git a/packages/core/src/layout/SignInPage/SignInPage.tsx b/packages/core/src/layout/SignInPage/SignInPage.tsx index f8879aa55b..2feb7be3bc 100644 --- a/packages/core/src/layout/SignInPage/SignInPage.tsx +++ b/packages/core/src/layout/SignInPage/SignInPage.tsx @@ -14,7 +14,13 @@ * limitations under the License. */ -import React, { FC, useLayoutEffect } from 'react'; +import React, { + FC, + useLayoutEffect, + useState, + ComponentType, + useMemo, +} from 'react'; import { Page } from '../Page'; import { Header } from '../Header'; import { Content } from '../Content/Content'; @@ -26,86 +32,150 @@ import { SignInResult, useApi, configApiRef, + useApiHolder, + ApiHolder, + errorApiRef, } from '@backstage/core-api'; const PROVIDER_STORAGE_KEY = '@backstage/core:SignInPage:provider'; -type ProviderProps = SignInPageProps & { - selected: boolean; +type ProviderComponent = ComponentType; + +type ProviderLoader = (apis: ApiHolder) => Promise; + +type SignInProvider = { + component: ProviderComponent; + loader: ProviderLoader; }; -const GuestProvider: FC = ({ selected, onResult }) => { - useLayoutEffect(() => { - if (selected) { - onResult({ userId: 'guest' }); - } - }, [selected, onResult]); +const GuestProvider: ProviderComponent = ({ onResult }) => ( + + onResult({ userId: 'guest' })} + > + Enter + + } + > + + Enter as a Guest User. +
+ You will not have a verified identity, +
+ meaning some features might be unavailable. +
+
+
+); - return ( - - onResult({ userId: 'guest' })} - > - Enter - - } - > - - Enter as a Guest User. -
- You will not have a verified identity, -
- so some features might be unavailable. -
-
-
- ); +const guestLoader: ProviderLoader = async () => { + return { userId: 'guest' }; }; -export type SignInProvider = 'guest'; +const guestProvider: SignInProvider = { + component: GuestProvider, + loader: guestLoader, +}; + +const signInProviders = { + guest: guestProvider, +}; + +export type SignInProviderId = keyof typeof signInProviders; export type Props = SignInPageProps & { - providers: SignInProvider[]; + providers: SignInProviderId[]; }; export const SignInPage: FC = ({ onResult, providers }) => { const configApi = useApi(configApiRef); + const errorApi = useApi(errorApiRef); + const apiHolder = useApiHolder(); // We can't use storageApi here, as it might have a dependency on the IdentityApi - const selectedProvider = localStorage.getItem(PROVIDER_STORAGE_KEY); + const selectedProvider = localStorage.getItem( + PROVIDER_STORAGE_KEY, + ) as SignInProviderId; - const makeResultHandler = (provider: SignInProvider) => ( - result: SignInResult, - ) => { - localStorage.setItem(PROVIDER_STORAGE_KEY, provider); + const [attempting, setAttempting] = useState(Boolean(selectedProvider)); - onResult({ - ...result, - logout: async () => { - localStorage.removeItem(PROVIDER_STORAGE_KEY); - await result.logout?.(); - }, - }); - }; + useLayoutEffect(() => { + if (!attempting || selectedProvider === null) { + return undefined; + } + + const provider = signInProviders[selectedProvider]; + if (!provider) { + setAttempting(false); + return undefined; + } + + let didCancel = false; + provider + .loader(apiHolder) + .then(result => { + if (didCancel) { + return; + } + setAttempting(false); + if (result) { + onResult({ + ...result, + logout: async () => { + localStorage.removeItem(PROVIDER_STORAGE_KEY); + await result.logout?.(); + }, + }); + } + }) + .catch(error => { + if (!didCancel) { + errorApi.post(error); + } + }); + + return () => { + didCancel = true; + }; + }, [attempting, errorApi, onResult, apiHolder, providers, selectedProvider]); + + const providerElements = useMemo( + () => + providers.map(providerId => { + const provider = signInProviders[providerId]; + if (!provider) { + throw new Error(`Unknown sign-in provider: ${providerId}`); + } + const { component: Component } = provider; + + const handleResult = (result: SignInResult) => { + localStorage.setItem(PROVIDER_STORAGE_KEY, providerId); + + onResult({ + ...result, + logout: async () => { + localStorage.removeItem(PROVIDER_STORAGE_KEY); + await result.logout?.(); + }, + }); + }; + + return ; + }), + [providers, onResult], + ); return (
- - {providers.includes('guest') && ( - - )} - + {providerElements} ); From 506f20974236ac15294f836c90a0332c245e8b54 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Jun 2020 17:15:31 +0200 Subject: [PATCH 07/12] packages/core: split up SignInPage and make it display progress --- .../core/src/layout/SignInPage/SignInPage.tsx | 151 ++---------------- .../src/layout/SignInPage/guestProvider.tsx | 51 ++++++ .../core/src/layout/SignInPage/providers.tsx | 128 +++++++++++++++ packages/core/src/layout/SignInPage/types.ts | 29 ++++ 4 files changed, 217 insertions(+), 142 deletions(-) create mode 100644 packages/core/src/layout/SignInPage/guestProvider.tsx create mode 100644 packages/core/src/layout/SignInPage/providers.tsx create mode 100644 packages/core/src/layout/SignInPage/types.ts diff --git a/packages/core/src/layout/SignInPage/SignInPage.tsx b/packages/core/src/layout/SignInPage/SignInPage.tsx index 2feb7be3bc..91bc251f90 100644 --- a/packages/core/src/layout/SignInPage/SignInPage.tsx +++ b/packages/core/src/layout/SignInPage/SignInPage.tsx @@ -14,79 +14,15 @@ * limitations under the License. */ -import React, { - FC, - useLayoutEffect, - useState, - ComponentType, - useMemo, -} from 'react'; +import React, { FC } from 'react'; import { Page } from '../Page'; import { Header } from '../Header'; import { Content } from '../Content/Content'; import { ContentHeader } from '../ContentHeader/ContentHeader'; -import { Grid, Typography, Button } from '@material-ui/core'; -import { InfoCard } from '../InfoCard/InfoCard'; -import { - SignInPageProps, - SignInResult, - useApi, - configApiRef, - useApiHolder, - ApiHolder, - errorApiRef, -} from '@backstage/core-api'; - -const PROVIDER_STORAGE_KEY = '@backstage/core:SignInPage:provider'; - -type ProviderComponent = ComponentType; - -type ProviderLoader = (apis: ApiHolder) => Promise; - -type SignInProvider = { - component: ProviderComponent; - loader: ProviderLoader; -}; - -const GuestProvider: ProviderComponent = ({ onResult }) => ( - - onResult({ userId: 'guest' })} - > - Enter - - } - > - - Enter as a Guest User. -
- You will not have a verified identity, -
- meaning some features might be unavailable. -
-
-
-); - -const guestLoader: ProviderLoader = async () => { - return { userId: 'guest' }; -}; - -const guestProvider: SignInProvider = { - component: GuestProvider, - loader: guestLoader, -}; - -const signInProviders = { - guest: guestProvider, -}; - -export type SignInProviderId = keyof typeof signInProviders; +import { Grid } from '@material-ui/core'; +import { SignInPageProps, useApi, configApiRef } from '@backstage/core-api'; +import { useSignInProviders, SignInProviderId } from './providers'; +import Progress from '../../components/Progress'; export type Props = SignInPageProps & { providers: SignInProviderId[]; @@ -94,81 +30,12 @@ export type Props = SignInPageProps & { export const SignInPage: FC = ({ onResult, providers }) => { const configApi = useApi(configApiRef); - const errorApi = useApi(errorApiRef); - const apiHolder = useApiHolder(); - // We can't use storageApi here, as it might have a dependency on the IdentityApi - const selectedProvider = localStorage.getItem( - PROVIDER_STORAGE_KEY, - ) as SignInProviderId; + const [loading, providerElements] = useSignInProviders(providers, onResult); - const [attempting, setAttempting] = useState(Boolean(selectedProvider)); - - useLayoutEffect(() => { - if (!attempting || selectedProvider === null) { - return undefined; - } - - const provider = signInProviders[selectedProvider]; - if (!provider) { - setAttempting(false); - return undefined; - } - - let didCancel = false; - provider - .loader(apiHolder) - .then(result => { - if (didCancel) { - return; - } - setAttempting(false); - if (result) { - onResult({ - ...result, - logout: async () => { - localStorage.removeItem(PROVIDER_STORAGE_KEY); - await result.logout?.(); - }, - }); - } - }) - .catch(error => { - if (!didCancel) { - errorApi.post(error); - } - }); - - return () => { - didCancel = true; - }; - }, [attempting, errorApi, onResult, apiHolder, providers, selectedProvider]); - - const providerElements = useMemo( - () => - providers.map(providerId => { - const provider = signInProviders[providerId]; - if (!provider) { - throw new Error(`Unknown sign-in provider: ${providerId}`); - } - const { component: Component } = provider; - - const handleResult = (result: SignInResult) => { - localStorage.setItem(PROVIDER_STORAGE_KEY, providerId); - - onResult({ - ...result, - logout: async () => { - localStorage.removeItem(PROVIDER_STORAGE_KEY); - await result.logout?.(); - }, - }); - }; - - return ; - }), - [providers, onResult], - ); + if (loading) { + return ; + } return ( diff --git a/packages/core/src/layout/SignInPage/guestProvider.tsx b/packages/core/src/layout/SignInPage/guestProvider.tsx new file mode 100644 index 0000000000..78d0191ba9 --- /dev/null +++ b/packages/core/src/layout/SignInPage/guestProvider.tsx @@ -0,0 +1,51 @@ +/* + * 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 React from 'react'; +import { Grid, Typography, Button } from '@material-ui/core'; +import { InfoCard } from '../InfoCard/InfoCard'; +import { ProviderComponent, ProviderLoader, SignInProvider } from './types'; + +const Component: ProviderComponent = ({ onResult }) => ( + + onResult({ userId: 'guest' })} + > + Enter + + } + > + + Enter as a Guest User. +
+ You will not have a verified identity, +
+ meaning some features might be unavailable. +
+
+
+); + +const loader: ProviderLoader = async () => { + return { userId: 'guest' }; +}; + +export const guestProvider: SignInProvider = { Component, loader }; diff --git a/packages/core/src/layout/SignInPage/providers.tsx b/packages/core/src/layout/SignInPage/providers.tsx new file mode 100644 index 0000000000..ae9c3cc010 --- /dev/null +++ b/packages/core/src/layout/SignInPage/providers.tsx @@ -0,0 +1,128 @@ +/* + * 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 React, { useLayoutEffect, useState, useMemo, useCallback } from 'react'; +import { guestProvider } from './guestProvider'; +import { + SignInPageProps, + SignInResult, + useApi, + useApiHolder, + errorApiRef, +} from '@backstage/core-api'; + +const PROVIDER_STORAGE_KEY = '@backstage/core:SignInPage:provider'; + +const signInProviders = { + guest: guestProvider, +}; + +export type SignInProviderId = keyof typeof signInProviders; + +export const useSignInProviders = ( + providers: SignInProviderId[], + onResult: SignInPageProps['onResult'], +) => { + const errorApi = useApi(errorApiRef); + const apiHolder = useApiHolder(); + const [loading, setLoading] = useState(true); + + // This decorates the result with logout logic from this hook + const handleWrappedResult = useCallback( + (result: SignInResult) => { + onResult({ + ...result, + logout: async () => { + localStorage.removeItem(PROVIDER_STORAGE_KEY); + await result.logout?.(); + }, + }); + }, + [onResult], + ); + + // In this effect we check if the user has already selected an existing login + // provider, and in that case try to load an existing session for the provider. + useLayoutEffect(() => { + if (!loading) { + return undefined; + } + + // We can't use storageApi here, as it might have a dependency on the IdentityApi + const selectedProvider = localStorage.getItem( + PROVIDER_STORAGE_KEY, + ) as SignInProviderId; + + // No provider selected, let the user pick one + if (selectedProvider === null) { + setLoading(false); + return undefined; + } + + const provider = signInProviders[selectedProvider]; + if (!provider) { + setLoading(false); + return undefined; + } + + let didCancel = false; + provider + .loader(apiHolder) + .then(result => { + if (didCancel) { + return; + } + if (result) { + handleWrappedResult(result); + } + setLoading(false); + }) + .catch(error => { + if (didCancel) { + return; + } + errorApi.post(error); + setLoading(false); + }); + + return () => { + didCancel = true; + }; + }, [loading, errorApi, onResult, apiHolder, providers, handleWrappedResult]); + + // This renders all available sign-in providers + const elements = useMemo( + () => + providers.map(providerId => { + const provider = signInProviders[providerId]; + if (!provider) { + throw new Error(`Unknown sign-in provider: ${providerId}`); + } + const { Component } = provider; + + const handleResult = (result: SignInResult) => { + localStorage.setItem(PROVIDER_STORAGE_KEY, providerId); + + handleWrappedResult(result); + }; + + return ; + }), + [providers, handleWrappedResult], + ); + + return [loading, elements]; +}; diff --git a/packages/core/src/layout/SignInPage/types.ts b/packages/core/src/layout/SignInPage/types.ts new file mode 100644 index 0000000000..e13cda5ddd --- /dev/null +++ b/packages/core/src/layout/SignInPage/types.ts @@ -0,0 +1,29 @@ +/* + * 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 { ComponentType } from 'react'; +import { SignInPageProps, SignInResult, ApiHolder } from '@backstage/core-api'; + +export type ProviderComponent = ComponentType; + +export type ProviderLoader = ( + apis: ApiHolder, +) => Promise; + +export type SignInProvider = { + Component: ProviderComponent; + loader: ProviderLoader; +}; From 730b73aff129ff8059917893deb1bf7737c242f0 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Jun 2020 17:18:40 +0200 Subject: [PATCH 08/12] packages/core: separate list of sign-in provider IDs to avoid exporting internal types --- packages/core/src/layout/SignInPage/providers.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/core/src/layout/SignInPage/providers.tsx b/packages/core/src/layout/SignInPage/providers.tsx index ae9c3cc010..10fb09d082 100644 --- a/packages/core/src/layout/SignInPage/providers.tsx +++ b/packages/core/src/layout/SignInPage/providers.tsx @@ -23,15 +23,17 @@ import { useApiHolder, errorApiRef, } from '@backstage/core-api'; +import { SignInProvider } from './types'; const PROVIDER_STORAGE_KEY = '@backstage/core:SignInPage:provider'; -const signInProviders = { +// Separate list here to avoid exporting internal types +export type SignInProviderId = 'guest'; + +const signInProviders: { [id in SignInProviderId]: SignInProvider } = { guest: guestProvider, }; -export type SignInProviderId = keyof typeof signInProviders; - export const useSignInProviders = ( providers: SignInProviderId[], onResult: SignInPageProps['onResult'], From f65f982b1ce760a9a180c4ab07055c45cb37b5c2 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Jun 2020 17:26:49 +0200 Subject: [PATCH 09/12] packages/core: added google sign-in provider --- .../src/layout/SignInPage/googleProvider.tsx | 86 +++++++++++++++++++ .../core/src/layout/SignInPage/providers.tsx | 4 +- 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/layout/SignInPage/googleProvider.tsx diff --git a/packages/core/src/layout/SignInPage/googleProvider.tsx b/packages/core/src/layout/SignInPage/googleProvider.tsx new file mode 100644 index 0000000000..2db7e2fd53 --- /dev/null +++ b/packages/core/src/layout/SignInPage/googleProvider.tsx @@ -0,0 +1,86 @@ +/* + * 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 React from 'react'; +import { Grid, Typography, Button } from '@material-ui/core'; +import { InfoCard } from '../InfoCard/InfoCard'; +import { ProviderComponent, ProviderLoader, SignInProvider } from './types'; +import { + useApi, + googleAuthApiRef, + errorApiRef, + ProfileInfo, +} from '@backstage/core-api'; + +function parseUserId(profile: ProfileInfo) { + return profile!.email.replace(/@.*/, ''); +} + +const Component: ProviderComponent = ({ onResult }) => { + const googleAuthApi = useApi(googleAuthApiRef); + const errorApi = useApi(errorApiRef); + + const handleLogin = async () => { + try { + const idToken = await googleAuthApi.getIdToken({ instantPopup: true }); + const profile = await googleAuthApi.getProfile(); + + onResult({ + userId: parseUserId(profile!), + idToken, + logout: async () => { + await googleAuthApi.logout(); + }, + }); + } catch (error) { + errorApi.post(error); + } + }; + + return ( + + + Sign In + + } + > + Sign In using Google + + + ); +}; + +const loader: ProviderLoader = async apis => { + const googleAuthApi = apis.get(googleAuthApiRef)!; + + const [idToken, profile] = await Promise.all([ + googleAuthApi.getIdToken({ optional: true }), + googleAuthApi.getProfile({ optional: true }), + ]); + + return { + userId: parseUserId(profile!), + idToken, + logout: async () => { + await googleAuthApi.logout(); + }, + }; +}; + +export const googleProvider: SignInProvider = { Component, loader }; diff --git a/packages/core/src/layout/SignInPage/providers.tsx b/packages/core/src/layout/SignInPage/providers.tsx index 10fb09d082..8e5cc7fc54 100644 --- a/packages/core/src/layout/SignInPage/providers.tsx +++ b/packages/core/src/layout/SignInPage/providers.tsx @@ -16,6 +16,7 @@ import React, { useLayoutEffect, useState, useMemo, useCallback } from 'react'; import { guestProvider } from './guestProvider'; +import { googleProvider } from './googleProvider'; import { SignInPageProps, SignInResult, @@ -28,10 +29,11 @@ import { SignInProvider } from './types'; const PROVIDER_STORAGE_KEY = '@backstage/core:SignInPage:provider'; // Separate list here to avoid exporting internal types -export type SignInProviderId = 'guest'; +export type SignInProviderId = 'guest' | 'google'; const signInProviders: { [id in SignInProviderId]: SignInProvider } = { guest: guestProvider, + google: googleProvider, }; export const useSignInProviders = ( From afc4ba85c8285f21b097ca6ee9488c69e2373f2a Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Jun 2020 17:42:09 +0200 Subject: [PATCH 10/12] packages/core: added custom sign-in provider --- packages/core/package.json | 1 + .../src/layout/SignInPage/customProvider.tsx | 111 ++++++++++++++++++ .../core/src/layout/SignInPage/providers.tsx | 4 +- 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/layout/SignInPage/customProvider.tsx diff --git a/packages/core/package.json b/packages/core/package.json index a412af3fa1..ccf2bce628 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -47,6 +47,7 @@ "react": "^16.12.0", "react-dom": "^16.12.0", "react-helmet": "6.0.0", + "react-hook-form": "^5.7.2", "react-router": "6.0.0-alpha.5", "react-router-dom": "6.0.0-alpha.5", "react-sparklines": "^1.7.0", diff --git a/packages/core/src/layout/SignInPage/customProvider.tsx b/packages/core/src/layout/SignInPage/customProvider.tsx new file mode 100644 index 0000000000..8141175d8f --- /dev/null +++ b/packages/core/src/layout/SignInPage/customProvider.tsx @@ -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 React from 'react'; +import { useForm } from 'react-hook-form'; +import { + Grid, + Typography, + Button, + FormControl, + TextField, + FormHelperText, + makeStyles, +} from '@material-ui/core'; +import isEmpty from 'lodash/isEmpty'; +import { InfoCard } from '../InfoCard/InfoCard'; +import { ProviderComponent, ProviderLoader, SignInProvider } from './types'; +import { SignInResult } from '@backstage/core-api'; + +const ID_TOKEN_REGEX = /^[a-z0-9+/]+\.[a-z0-9+/]+\.[a-z0-9+/]+$/i; + +const useFormStyles = makeStyles(theme => ({ + form: { + display: 'flex', + flexFlow: 'column nowrap', + }, + button: { + alignSelf: 'center', + marginTop: theme.spacing(2), + }, +})); + +const Component: ProviderComponent = ({ onResult }) => { + const classes = useFormStyles(); + const { register, handleSubmit, errors, formState } = useForm({ + mode: 'onChange', + }); + + return ( + + + + Enter your own User ID and credentials. +
+ This selection will not be stored. +
+ +
+ + + {errors.userId && ( + {errors.userId.message} + )} + + + + !token || + ID_TOKEN_REGEX.test(token) || + 'Token is not a valid OpenID Connect JWT Token', + })} + /> + {errors.idToken && ( + {errors.idToken.message} + )} + + +
+
+
+ ); +}; + +// Custom provider doesn't store credentials +const loader: ProviderLoader = async () => undefined; + +export const customProvider: SignInProvider = { Component, loader }; diff --git a/packages/core/src/layout/SignInPage/providers.tsx b/packages/core/src/layout/SignInPage/providers.tsx index 8e5cc7fc54..d60c8ff281 100644 --- a/packages/core/src/layout/SignInPage/providers.tsx +++ b/packages/core/src/layout/SignInPage/providers.tsx @@ -17,6 +17,7 @@ import React, { useLayoutEffect, useState, useMemo, useCallback } from 'react'; import { guestProvider } from './guestProvider'; import { googleProvider } from './googleProvider'; +import { customProvider } from './customProvider'; import { SignInPageProps, SignInResult, @@ -29,11 +30,12 @@ import { SignInProvider } from './types'; const PROVIDER_STORAGE_KEY = '@backstage/core:SignInPage:provider'; // Separate list here to avoid exporting internal types -export type SignInProviderId = 'guest' | 'google'; +export type SignInProviderId = 'guest' | 'google' | 'custom'; const signInProviders: { [id in SignInProviderId]: SignInProvider } = { guest: guestProvider, google: googleProvider, + custom: customProvider, }; export const useSignInProviders = ( From b4284e7b3e4fc1c7c19218cb60a61416ccd54614 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Jun 2020 17:42:51 +0200 Subject: [PATCH 11/12] packages/app: add sign-in page --- packages/app/src/App.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index ba7fb4a8a5..f4515cbf01 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -14,7 +14,12 @@ * limitations under the License. */ -import { createApp, AlertDisplay, OAuthRequestDialog } from '@backstage/core'; +import { + createApp, + AlertDisplay, + OAuthRequestDialog, + SignInPage, +} from '@backstage/core'; import React, { FC } from 'react'; import Root from './components/Root'; import * as plugins from './plugins'; @@ -24,6 +29,11 @@ import { hot } from 'react-hot-loader/root'; const app = createApp({ apis, plugins: Object.values(plugins), + components: { + SignInPage: props => ( + + ), + }, }); const AppProvider = app.getProvider(); From af25113b584e99e31762af3f7481650dd5f4f9ae Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Jun 2020 18:05:49 +0200 Subject: [PATCH 12/12] packages/storybook: added mock identity api --- packages/storybook/.storybook/apis.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/storybook/.storybook/apis.js b/packages/storybook/.storybook/apis.js index d3150400cf..0450f3969d 100644 --- a/packages/storybook/.storybook/apis.js +++ b/packages/storybook/.storybook/apis.js @@ -2,6 +2,7 @@ import { ApiRegistry, alertApiRef, errorApiRef, + identityApiRef, oauthRequestApiRef, OAuthRequestManager, googleAuthApiRef, @@ -19,6 +20,12 @@ const alertApi = builder.add(alertApiRef, new AlertApiForwarder()); builder.add(errorApiRef, new ErrorAlerter(alertApi, new ErrorApiForwarder())); +builder.add(identityApiRef, { + getUserId: () => 'guest', + getIdToken: () => undefined, + logout: async () => {}, +}); + const oauthRequestApi = builder.add( oauthRequestApiRef, new OAuthRequestManager(),