From b943eb065e9cd912cbb901950d0c30d262d080f8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Jun 2020 00:10:15 +0200 Subject: [PATCH 01/20] packages/core-api: move config loading logic in AppProvider into separate hook --- packages/core-api/src/app/App.tsx | 66 ++++++++++++++++++++----------- 1 file changed, 43 insertions(+), 23 deletions(-) diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index 1923adba0d..6da2b5f1b9 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -45,6 +45,41 @@ type FullAppOptions = { configLoader?: AppConfigLoader; }; +function useConfigLoader( + configLoader: AppConfigLoader | undefined, + components: AppComponents, + appThemeApi: AppThemeApi, +): { api: ConfigApi } | { node: JSX.Element } { + // Keeping this synchronous when a config loader isn't set simplifies tests a lot + const hasConfig = Boolean(configLoader); + const config = useAsync(configLoader || (() => Promise.resolve([]))); + + let noConfigNode = undefined; + + if (hasConfig && config.loading) { + const { Progress } = components; + noConfigNode = ; + } else if (config.error) { + const { BootErrorPage } = components; + noConfigNode = ; + } + + // Before the config is loaded we can't use a router, so exit early + if (noConfigNode) { + return { + node: ( + + {noConfigNode} + + ), + }; + } + + const configReader = ConfigReader.fromConfigs(config.value ?? []); + + return { api: configReader }; +} + export class PrivateAppImpl implements BackstageApp { private apis?: ApiHolder = undefined; private readonly icons: SystemIcons; @@ -151,32 +186,17 @@ export class PrivateAppImpl implements BackstageApp { [], ); - // Keeping this synchronous when a config loader isn't set simplifies tests a lot - const hasConfig = Boolean(this.configLoader); - const config = useAsync(this.configLoader || (() => Promise.resolve([]))); + const loadedConfig = useConfigLoader( + this.configLoader, + this.components, + appThemeApi, + ); - let noConfigNode = undefined; - - if (hasConfig && config.loading) { - const { Progress } = this.components; - noConfigNode = ; - } else if (config.error) { - const { BootErrorPage } = this.components; - noConfigNode = ( - - ); + if ('node' in loadedConfig) { + return loadedConfig.node; } + const configReader = loadedConfig.api; - // Before the config is loaded we can't use a router, so exit early - if (noConfigNode) { - return ( - - {noConfigNode} - - ); - } - - const configReader = ConfigReader.fromConfigs(config.value ?? []); const appApis = ApiRegistry.from([ [appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)], [configApiRef, configReader], From 9a3799515167dbdd3af389d60b1fc7fc4f7c25aa Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Jun 2020 00:46:39 +0200 Subject: [PATCH 02/20] packages/core-api: split AppComponent into AppRouter and AppRoutes --- packages/app/src/App.tsx | 11 ++-- .../default-app/packages/app/src/App.tsx | 7 ++- packages/core-api/src/app/App.tsx | 55 ++++++++++++------- packages/core-api/src/app/types.ts | 21 ++++--- packages/dev-utils/src/devApp/render.tsx | 15 +++-- .../test-utils/src/testUtils/appWrappers.tsx | 9 ++- 6 files changed, 76 insertions(+), 42 deletions(-) diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index ed0518485a..ba7fb4a8a5 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -27,15 +27,18 @@ const app = createApp({ }); const AppProvider = app.getProvider(); -const AppComponent = app.getRootComponent(); +const AppRouter = app.getRouter(); +const AppRoutes = app.getRoutes(); const App: FC<{}> = () => ( - - - + + + + + ); diff --git a/packages/cli/templates/default-app/packages/app/src/App.tsx b/packages/cli/templates/default-app/packages/app/src/App.tsx index 140d1c1660..1b58a3e5df 100644 --- a/packages/cli/templates/default-app/packages/app/src/App.tsx +++ b/packages/cli/templates/default-app/packages/app/src/App.tsx @@ -26,13 +26,16 @@ const app = createApp({ }); const AppProvider = app.getProvider(); -const AppComponent = app.getRootComponent(); +const AppRouter = app.getRouter(); +const AppRoutes = app.getRoutes(); const App: FC<{}> = () => { useStyles(); return ( - + + + ); }; diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index 6da2b5f1b9..8d625a3a0d 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -19,7 +19,11 @@ import { AppContextProvider } from './AppContext'; import { BackstageApp, AppComponents, AppConfigLoader, Apis } from './types'; import { BackstagePlugin } from '../plugin'; import { FeatureFlagsRegistryItem } from './FeatureFlags'; -import { featureFlagsApiRef } from '../apis/definitions'; +import { + featureFlagsApiRef, + AppThemeApi, + ConfigApi, +} from '../apis/definitions'; import { AppThemeProvider } from './AppThemeProvider'; import { IconComponent, SystemIcons, SystemIconKey } from '../icons'; @@ -32,6 +36,7 @@ import { appThemeApiRef, configApiRef, ConfigReader, + useApi, } from '../apis'; import { ApiAggregator } from '../apis/ApiAggregator'; import { useAsync } from 'react-use'; @@ -114,7 +119,7 @@ export class PrivateAppImpl implements BackstageApp { return this.icons[key]; } - getRootComponent(): ComponentType<{}> { + getRoutes(): ComponentType<{}> { const routes = new Array(); const registeredFeatureFlags = new Array(); @@ -195,26 +200,42 @@ export class PrivateAppImpl implements BackstageApp { if ('node' in loadedConfig) { return loadedConfig.node; } - const configReader = loadedConfig.api; + const configApi = loadedConfig.api; const appApis = ApiRegistry.from([ - [appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)], - [configApiRef, configReader], + [appThemeApiRef, appThemeApi], + [configApiRef, configApi], ]); if (!this.apis) { if ('get' in this.apisOrFactory) { this.apis = this.apisOrFactory; } else { - this.apis = this.apisOrFactory(configReader); + this.apis = this.apisOrFactory(configApi); } } const apis = new ApiAggregator(this.apis, appApis); - const { Router } = this.components; + return ( + + + {children} + + + ); + }; + return Provider; + } + + getRouter(): ComponentType<{}> { + const { Router: RouterComponent } = this.components; + + const AppRouter: FC<{}> = ({ children }) => { + const configApi = useApi(configApiRef); + let { pathname } = new URL( - configReader.getString('app.baseUrl') ?? '/', + configApi.getString('app.baseUrl') ?? '/', 'http://dummy.dev', // baseUrl can be specified as just a path ); if (pathname.endsWith('/')) { @@ -222,20 +243,14 @@ export class PrivateAppImpl implements BackstageApp { } return ( - - - - - - {children}} /> - - - - - + + + {children}} /> + + ); }; - return Provider; + return AppRouter; } verify() { diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index e30c79dd73..97809ca899 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -117,14 +117,19 @@ export type BackstageApp = { getSystemIcon(key: SystemIconKey): IconComponent; /** - * Creates a root component for this app, including the App chrome - * and routes to all plugins. - */ - getRootComponent(): ComponentType<{}>; - - /** - * Provider component that should wrap the App's RootComponent and - * any other components that need to be within the app context. + * Provider component that should wrap the Router created with getRouter() + * and any other components that need to be within the app context. */ getProvider(): ComponentType<{}>; + + /** + * Router component that should wrap the App Routes create with getRoutes() + * and any other components that should only be available while signed in. + */ + getRouter(): ComponentType<{}>; + + /** + * Routes component that contains all routes for plugin pages in the app. + */ + getRoutes(): ComponentType<{}>; }; diff --git a/packages/dev-utils/src/devApp/render.tsx b/packages/dev-utils/src/devApp/render.tsx index 8900ecd54b..0e28a5bf8b 100644 --- a/packages/dev-utils/src/devApp/render.tsx +++ b/packages/dev-utils/src/devApp/render.tsx @@ -82,8 +82,10 @@ class DevAppBuilder { apis: this.setupApiRegistry(this.factories), plugins: this.plugins, }); + const AppProvider = app.getProvider(); - const AppComponent = app.getRootComponent(); + const AppRouter = app.getRouter(); + const AppRoutes = app.getRoutes(); const sidebar = this.setupSidebar(this.plugins); @@ -93,10 +95,13 @@ class DevAppBuilder { {this.rootChildren} - - {sidebar} - - + + + + {sidebar} + + + ); }; diff --git a/packages/test-utils/src/testUtils/appWrappers.tsx b/packages/test-utils/src/testUtils/appWrappers.tsx index c474678fd1..15814d918d 100644 --- a/packages/test-utils/src/testUtils/appWrappers.tsx +++ b/packages/test-utils/src/testUtils/appWrappers.tsx @@ -89,12 +89,15 @@ export function wrapInTestApp( } const AppProvider = app.getProvider(); + const AppRouter = app.getRouter(); return ( - {/* The path of * here is needed to be set as a catch all, so it will render the wrapper element - * and work with nested routes if they exist too */} - } /> + + {/* The path of * here is needed to be set as a catch all, so it will render the wrapper element + * and work with nested routes if they exist too */} + } /> + ); } From 2ed4db0f00f263747db6a8c5cd7a1af390213efa Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Jun 2020 11:21:32 +0200 Subject: [PATCH 03/20] packages/core-api: add IdentityApi to app and hook up to optional sign-in page component --- .../src/apis/definitions/IdentityApi.ts | 2 +- packages/core-api/src/app/App.tsx | 79 +++++++++++++++++-- packages/core-api/src/app/AppIdentity.ts | 61 ++++++++++++++ packages/core-api/src/app/types.ts | 34 ++++++++ 4 files changed, 169 insertions(+), 7 deletions(-) create mode 100644 packages/core-api/src/app/AppIdentity.ts diff --git a/packages/core-api/src/apis/definitions/IdentityApi.ts b/packages/core-api/src/apis/definitions/IdentityApi.ts index bbfab0ef35..4c9ccfdbf6 100644 --- a/packages/core-api/src/apis/definitions/IdentityApi.ts +++ b/packages/core-api/src/apis/definitions/IdentityApi.ts @@ -40,7 +40,7 @@ export type IdentityApi = { // TODO: getProfile(): Promise - We want this to be async when added, but needs more work. }; -export const identifyApiRef = createApiRef({ +export const identityApiRef = createApiRef({ id: 'core.identity', description: 'Provides access to the identity of the signed in user', }); diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index 8d625a3a0d..163a546943 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -13,16 +13,31 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { ComponentType, FC, useMemo } from 'react'; +import React, { + ComponentType, + FC, + useMemo, + useCallback, + useState, + ReactElement, +} from 'react'; import { Route, Routes, Navigate } from 'react-router-dom'; import { AppContextProvider } from './AppContext'; -import { BackstageApp, AppComponents, AppConfigLoader, Apis } from './types'; +import { + BackstageApp, + AppComponents, + AppConfigLoader, + Apis, + SignInResult, + SignInPageProps, +} from './types'; import { BackstagePlugin } from '../plugin'; import { FeatureFlagsRegistryItem } from './FeatureFlags'; import { featureFlagsApiRef, AppThemeApi, ConfigApi, + identityApiRef, } from '../apis/definitions'; import { AppThemeProvider } from './AppThemeProvider'; @@ -40,6 +55,7 @@ import { } from '../apis'; import { ApiAggregator } from '../apis/ApiAggregator'; import { useAsync } from 'react-use'; +import { AppIdentity } from './AppIdentity'; type FullAppOptions = { apis: Apis; @@ -93,6 +109,8 @@ export class PrivateAppImpl implements BackstageApp { private readonly themes: AppTheme[]; private readonly configLoader?: AppConfigLoader; + private readonly identityApi = new AppIdentity(); + private apisOrFactory: Apis; constructor(options: FullAppOptions) { @@ -205,6 +223,7 @@ export class PrivateAppImpl implements BackstageApp { const appApis = ApiRegistry.from([ [appThemeApiRef, appThemeApi], [configApiRef, configApi], + [identityApiRef, this.identityApi], ]); if (!this.apis) { @@ -229,7 +248,35 @@ export class PrivateAppImpl implements BackstageApp { } getRouter(): ComponentType<{}> { - const { Router: RouterComponent } = this.components; + const { + Router: RouterComponent, + SignInPage: SignInPageComponent, + } = this.components; + + // This wraps the sign-in page and waits for sign-in to be completed before rendering the app + const SignInPageWrapper: FC<{ + component: ComponentType; + children: ReactElement; + }> = ({ component: Component, children }) => { + const [done, setDone] = useState(false); + + const onResult = useCallback( + (result: SignInResult) => { + if (done) { + throw new Error('Identity result callback was called twice'); + } + setDone(true); + this.identityApi.setSignInResult(result); + }, + [done], + ); + + if (done) { + return children; + } + + return ; + }; const AppRouter: FC<{}> = ({ children }) => { const configApi = useApi(configApiRef); @@ -242,14 +289,34 @@ export class PrivateAppImpl implements BackstageApp { pathname = pathname.replace(/\/$/, ''); } + // If the app hasn't configured a sign-in page, we just continue as guest. + if (!SignInPageComponent) { + this.identityApi.setSignInResult({ + userId: 'guest', + idToken: undefined, + logout: async () => {}, + }); + + return ( + + + {children}} /> + + + ); + } + return ( - - {children}} /> - + + + {children}} /> + + ); }; + return AppRouter; } diff --git a/packages/core-api/src/app/AppIdentity.ts b/packages/core-api/src/app/AppIdentity.ts new file mode 100644 index 0000000000..a0f933710b --- /dev/null +++ b/packages/core-api/src/app/AppIdentity.ts @@ -0,0 +1,61 @@ +/* + * 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 { IdentityApi } from '../apis'; +import { SignInResult } from './types'; + +/** + * Implementation of the connection between the App-wide IdentityApi + * and sign-in page. + */ +export class AppIdentity implements IdentityApi { + private userId?: string; + private idToken?: string; + private logoutFunc?: () => Promise; + + getUserId(): string { + if (!this.userId) { + throw new Error( + 'Tried to access IdentityApi userId before app was loaded', + ); + } + return this.userId; + } + + getIdToken(): string { + if (!this.idToken) { + throw new Error( + 'Tried to access IdentityApi idToken before app was loaded', + ); + } + return this.idToken; + } + + async logout(): Promise { + if (!this.logoutFunc) { + throw new Error( + 'Tried to access IdentityApi logoutFunc before app was loaded', + ); + } + await this.logoutFunc; + } + + setSignInResult(result: SignInResult) { + this.userId = result.userId; + this.idToken = result.idToken; + this.logoutFunc = result.logout; + } +} diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index 97809ca899..099438a58c 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -25,11 +25,45 @@ export type BootErrorPageProps = { step: 'load-config'; error: Error; }; + +export type SignInResult = { + /** + * User ID that will be returned by the IdentityApi + */ + userId: string; + /** + * ID token that will be returned by the IdentityApi + */ + idToken: string | undefined; + /** + * Logout handler that will be called if the user requests a logout. + */ + logout: () => Promise; +}; + +export type SignInPageProps = { + /** + * Set the sign-in result for the app. This should only be called once. + */ + onResult(result: SignInResult): void; +}; + export type AppComponents = { NotFoundErrorPage: ComponentType<{}>; BootErrorPage: ComponentType; Progress: ComponentType<{}>; Router: ComponentType<{}>; + + /** + * An optional sign-in page that will be rendered instead of the AppRouter at startup. + * + * If a sign-in page is set, it will always be shown before the app, and it is up + * to the sign-in page to handle e.g. saving of login methods for subsequent visits. + * + * The sign-in page will be displayed until it has passed up a result to the parent, + * and which point the AppRouter and all of its children will be rendered instead. + */ + SignInPage?: ComponentType; }; /** From 09c16fb5542b86593b5ee6e8c27c911e383de881 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Jun 2020 14:14:05 +0200 Subject: [PATCH 04/20] packages/core-api: make idToken and logout optional in SignInResult --- packages/core-api/src/app/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index 099438a58c..a152ef9dfb 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -34,11 +34,11 @@ export type SignInResult = { /** * ID token that will be returned by the IdentityApi */ - idToken: string | undefined; + idToken?: string; /** * Logout handler that will be called if the user requests a logout. */ - logout: () => Promise; + logout?: () => Promise; }; export type SignInPageProps = { From ee7f6f2b2e3afeecdf6f4a1d49ec9b99776b78aa Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Jun 2020 15:06:26 +0200 Subject: [PATCH 05/20] packages/core-api: add logout to IdentityApi and properly handle multiple sign-in attempts --- .../src/apis/definitions/IdentityApi.ts | 5 +++++ packages/core-api/src/app/AppIdentity.ts | 21 +++++++++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/core-api/src/apis/definitions/IdentityApi.ts b/packages/core-api/src/apis/definitions/IdentityApi.ts index 4c9ccfdbf6..5da9ed4814 100644 --- a/packages/core-api/src/apis/definitions/IdentityApi.ts +++ b/packages/core-api/src/apis/definitions/IdentityApi.ts @@ -38,6 +38,11 @@ export type IdentityApi = { getIdToken(): string | undefined; // TODO: getProfile(): Promise - We want this to be async when added, but needs more work. + + /** + * Log out the current user + */ + logout(): Promise; }; export const identityApiRef = createApiRef({ diff --git a/packages/core-api/src/app/AppIdentity.ts b/packages/core-api/src/app/AppIdentity.ts index a0f933710b..77de445a20 100644 --- a/packages/core-api/src/app/AppIdentity.ts +++ b/packages/core-api/src/app/AppIdentity.ts @@ -22,21 +22,22 @@ import { SignInResult } from './types'; * and sign-in page. */ export class AppIdentity implements IdentityApi { + private hasIdentity = false; private userId?: string; private idToken?: string; private logoutFunc?: () => Promise; getUserId(): string { - if (!this.userId) { + if (!this.hasIdentity) { throw new Error( 'Tried to access IdentityApi userId before app was loaded', ); } - return this.userId; + return this.userId!; } - getIdToken(): string { - if (!this.idToken) { + getIdToken(): string | undefined { + if (!this.hasIdentity) { throw new Error( 'Tried to access IdentityApi idToken before app was loaded', ); @@ -45,15 +46,23 @@ export class AppIdentity implements IdentityApi { } async logout(): Promise { - if (!this.logoutFunc) { + if (!this.hasIdentity) { throw new Error( 'Tried to access IdentityApi logoutFunc before app was loaded', ); } - await this.logoutFunc; + await this.logoutFunc?.(); + location.reload(); } setSignInResult(result: SignInResult) { + if (this.hasIdentity) { + return; + } + if (!result.userId) { + throw new Error('Invalid sign-in result, userId not set'); + } + this.hasIdentity = true; this.userId = result.userId; this.idToken = result.idToken; this.logoutFunc = result.logout; From b2c54fd4baed2aa0a1f0f16ec4ca704456c88ed8 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Jun 2020 14:14:41 +0200 Subject: [PATCH 06/20] 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 07/20] 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 08/20] 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 09/20] 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 10/20] 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 11/20] 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 ecbfa5f093d8e1b314006fcc2bf6156f48ee334f Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Jun 2020 23:56:44 +0200 Subject: [PATCH 12/20] packages/auth-backend: fix for errors not being forwarded properly in EnvironmentHandler --- plugins/auth-backend/src/lib/EnvironmentHandler.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/auth-backend/src/lib/EnvironmentHandler.ts b/plugins/auth-backend/src/lib/EnvironmentHandler.ts index 4fb80ca3e8..c4d8d2b6e7 100644 --- a/plugins/auth-backend/src/lib/EnvironmentHandler.ts +++ b/plugins/auth-backend/src/lib/EnvironmentHandler.ts @@ -37,7 +37,7 @@ export class EnvironmentHandler implements AuthProviderRouteHandlers { async start(req: express.Request, res: express.Response): Promise { const provider = this.getProviderForEnv(req); - provider.start(req, res); + await provider.start(req, res); } async frameHandler( @@ -45,18 +45,18 @@ export class EnvironmentHandler implements AuthProviderRouteHandlers { res: express.Response, ): Promise { const provider = this.getProviderForEnv(req); - provider.frameHandler(req, res); + await provider.frameHandler(req, res); } async refresh(req: express.Request, res: express.Response): Promise { const provider = this.getProviderForEnv(req); if (provider.refresh) { - provider.refresh(req, res); + await provider.refresh(req, res); } } async logout(req: express.Request, res: express.Response): Promise { const provider = this.getProviderForEnv(req); - provider.logout(req, res); + await provider.logout(req, res); } } From 506f20974236ac15294f836c90a0332c245e8b54 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Tue, 16 Jun 2020 17:15:31 +0200 Subject: [PATCH 13/20] 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 14/20] 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 15/20] 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 16/20] 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 17/20] 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 18/20] 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(), From b073b610f4df17d6a9d9d3e5cd2c241858703777 Mon Sep 17 00:00:00 2001 From: Nikki Beesetti <12538017+nikkibeesetti@users.noreply.github.com> Date: Wed, 17 Jun 2020 03:30:39 -0500 Subject: [PATCH 19/20] Updated FAQ.md with the correct links (#1308) * Updated FAQ.md with the correct links Two links regarding plugins were broken and now they have been fixed. * Updated FAQ.md with correct links --- docs/FAQ.md | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/docs/FAQ.md b/docs/FAQ.md index 5fb131528a..3bd526f94b 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -11,15 +11,11 @@ brand. ### Is Backstage a monitoring platform? -No, but it can be! Backstage is designed to be a developer portal for all your -infrastructure tooling, services, and documentation. So, it's not a monitoring -platform — but that doesn't mean you can't integrate a monitoring tool into -Backstage by writing -[a plugin](https://github.com/spotify/faq#what-is-a-plugin-in-backstage). +No, but it can be! Backstage is designed to be a developer portal for all your infrastructure tooling, services, and documentation. So, it's not a monitoring platform — but that doesn't mean you can't integrate a monitoring tool into Backstage by writing [a plugin](https://github.com/spotify/backstage/blob/master/docs/FAQ.md#what-is-a-plugin-in-backstage). ### How is Backstage licensed? -Backstage was released as free and open software by Spotify and is licensed +Backstage was released as open sourced software by Spotify and is licensed under [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0). ### Why did we open source Backstage? @@ -99,15 +95,7 @@ plugins. ​ ### What is a "plugin" in Backstage? -​ Plugins are what provide the feature functionality in Backstage. They are used -to integrate different systems into Backstage's frontend, so that the developer -gets a consistent UX, no matter what tool or service is being accessed on the -other side. ​ Each plugin is treated as a self-contained web app and can include -almost any type of content. Plugins all use a common set of platform APIs and -reusable UI components. Plugins can fetch data either from the backend or an API -exposed through the proxy. ​ Learn more about -[the different components](https://github.com/spotify/backstage#overview) that -make up Backstage. ​ +By far, our most-used plugin is our TechDocs plugin, which we use for creating technical documentation. Our philosophy at Spotify is to treat "docs like code", where you write documentation using the same workflow as you write your code. This makes it easier to create, find, and update documentation. We hope to release [the open source version](https://github.com/spotify/backstage/issues/687) in the future. (See also: "[Will Spotify's internal plugins be open sourced, too?](https://github.com/spotify/backstage/blob/master/docs/FAQ.md#what-is-a-plugin-in-backstage)" above) ### Do I have to write plugins in TypeScript? From 8658ea3f27fd90663933176f2a252c11620bcbbe Mon Sep 17 00:00:00 2001 From: Sebastian Qvarfordt Date: Wed, 17 Jun 2020 12:03:23 +0200 Subject: [PATCH 20/20] Working catalog type filter (#1267) * WIP Working type filter * Fixed type error * Some interactivity between type filters and subfilters * Revert "Some interactivity between type filters and subfilters" This reverts commit 91a99f6c5759c33f1d35100f365af397d4ee9810. * Rename services to entities in catalog filter menu --- packages/core/src/layout/HeaderTabs/index.tsx | 16 +++- .../components/CatalogPage/CatalogPage.tsx | 83 +++++++++++-------- .../utils/locales/goodEvening.locales.json | 2 +- plugins/catalog/src/data/filters.ts | 9 +- 4 files changed, 68 insertions(+), 42 deletions(-) diff --git a/packages/core/src/layout/HeaderTabs/index.tsx b/packages/core/src/layout/HeaderTabs/index.tsx index 340b39f862..2617a27aa9 100644 --- a/packages/core/src/layout/HeaderTabs/index.tsx +++ b/packages/core/src/layout/HeaderTabs/index.tsx @@ -17,7 +17,7 @@ // TODO(blam): Remove this implementation when the Tabs are ready // This is just a temporary solution to implementing tabs for now -import React from 'react'; +import React, { useState } from 'react'; import { makeStyles, Tabs, Tab } from '@material-ui/core'; const useStyles = makeStyles(theme => ({ @@ -42,9 +42,18 @@ export type Tab = { id: string; label: string; }; -export const HeaderTabs: React.FC<{ tabs: Tab[] }> = ({ tabs }) => { +export const HeaderTabs: React.FC<{ + tabs: Tab[]; + onChange?: (index: Number) => void; +}> = ({ tabs, onChange }) => { + const [selectedTab, setSelectedTab] = useState(0); const styles = useStyles(); + const handleChange = (_: React.ChangeEvent<{}>, index: Number) => { + setSelectedTab(index); + if (onChange) onChange(index); + }; + return (
= ({ tabs }) => { variant="scrollable" scrollButtons="auto" aria-label="scrollable auto tabs example" - value={0} + onChange={handleChange} + value={selectedTab} > {tabs.map((tab, index) => ( ({ contentWrapper: { display: 'grid', @@ -58,8 +87,8 @@ const useStyles = makeStyles(theme => ({ export const CatalogPage: FC<{}> = () => { const catalogApi = useApi(catalogApiRef); + const [selectedTab, setSelectedTab] = useState(tabs[0].id); const { toggleStarredEntity, isStarredEntity } = useStarredEntities(); - const [selectedFilter, setSelectedFilter] = useState( defaultFilter, ); @@ -69,16 +98,19 @@ export const CatalogPage: FC<{}> = () => { async () => catalogApi.getEntities(), ); - const data = - entities?.filter(e => - entityFilters[selectedFilter.id](e, { isStarred: isStarredEntity(e) }), - ) ?? []; - const onFilterSelected = useCallback( selected => setSelectedFilter(selected), [], ); + const filteredEntities = useMemo(() => { + const typeFilter = entityFilters[EntityFilterType.TYPE]; + const leftMenuFilter = entityFilters[selectedFilter.id]; + return entities + ?.filter(e => leftMenuFilter(e, { isStarred: isStarredEntity(e) })) + .filter(e => typeFilter(e, { type: selectedTab })); + }, [selectedFilter.id, selectedTab, isStarredEntity, entities?.filter]); + const styles = useStyles(); const actions = [ @@ -127,33 +159,14 @@ export const CatalogPage: FC<{}> = () => { }, ]; - // TODO: replace me with the proper tabs implemntation - const tabs = [ - { - id: 'services', - label: 'Services', - }, - { - id: 'websites', - label: 'Websites', - }, - { - id: 'libs', - label: 'Libraries', - }, - { - id: 'documentation', - label: 'Documentation', - }, - { - id: 'other', - label: 'Other', - }, - ]; - return ( - + { + setSelectedTab(tabs[index as number].id); + }} + /> = () => {
diff --git a/plugins/catalog/src/components/CatalogPage/utils/locales/goodEvening.locales.json b/plugins/catalog/src/components/CatalogPage/utils/locales/goodEvening.locales.json index a70aa122b6..a5506f123d 100644 --- a/plugins/catalog/src/components/CatalogPage/utils/locales/goodEvening.locales.json +++ b/plugins/catalog/src/components/CatalogPage/utils/locales/goodEvening.locales.json @@ -88,7 +88,7 @@ "Swedish": "God afton", "Tagalog": "Magandang gabi", "Tatar": "Xäyerle kiç", - "Telugu" : "శుభ సాయంత్రం", + "Telugu": "శుభ సాయంత్రం", "Thai": "Sawat-dii torn khum", "Turkish": "İyi akşamlar", "Ukrainian": "Dobry vechir", diff --git a/plugins/catalog/src/data/filters.ts b/plugins/catalog/src/data/filters.ts index 71efa923c6..bbe959caa3 100644 --- a/plugins/catalog/src/data/filters.ts +++ b/plugins/catalog/src/data/filters.ts @@ -28,6 +28,7 @@ export enum EntityFilterType { ALL = 'ALL', STARRED = 'STARRED', OWNED = 'OWNED', + TYPE = 'TYPE', } export const filterGroups: CatalogFilterGroup[] = [ @@ -54,7 +55,7 @@ export const filterGroups: CatalogFilterGroup[] = [ items: [ { id: EntityFilterType.ALL, - label: 'All Services', + label: 'All Entities', count: AllServicesCount, }, ], @@ -64,13 +65,15 @@ export const filterGroups: CatalogFilterGroup[] = [ type EntityFilter = (entity: Entity, options: EntityFilterOptions) => boolean; type EntityFilterOptions = { - isStarred: boolean; + isStarred?: boolean; + type?: string; }; export const entityFilters: Record = { [EntityFilterType.OWNED]: () => false, [EntityFilterType.ALL]: () => true, - [EntityFilterType.STARRED]: (_, { isStarred }) => isStarred, + [EntityFilterType.STARRED]: (_, { isStarred }) => !!isStarred, + [EntityFilterType.TYPE]: (e, { type }) => (e.spec as any)?.type === type, }; export const defaultFilter: CatalogFilterItem = filterGroups[0].items[0];