diff --git a/docs/FAQ.md b/docs/FAQ.md index 5fb131528a..95d705ebe7 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -15,12 +15,12 @@ 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). +[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 -under [Apache License, Version 2.0](http://www.apache.org/licenses/LICENSE-2.0). +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 +99,15 @@ 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? diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index ed0518485a..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,18 +29,26 @@ import { hot } from 'react-hot-loader/root'; const app = createApp({ apis, plugins: Object.values(plugins), + components: { + SignInPage: props => ( + + ), + }, }); 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/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/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/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'; diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index 1923adba0d..163a546943 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -13,13 +13,32 @@ * 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 } from '../apis/definitions'; +import { + featureFlagsApiRef, + AppThemeApi, + ConfigApi, + identityApiRef, +} from '../apis/definitions'; import { AppThemeProvider } from './AppThemeProvider'; import { IconComponent, SystemIcons, SystemIconKey } from '../icons'; @@ -32,9 +51,11 @@ import { appThemeApiRef, configApiRef, ConfigReader, + useApi, } from '../apis'; import { ApiAggregator } from '../apis/ApiAggregator'; import { useAsync } from 'react-use'; +import { AppIdentity } from './AppIdentity'; type FullAppOptions = { apis: Apis; @@ -45,6 +66,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; @@ -53,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) { @@ -79,7 +137,7 @@ export class PrivateAppImpl implements BackstageApp { return this.icons[key]; } - getRootComponent(): ComponentType<{}> { + getRoutes(): ComponentType<{}> { const routes = new Array(); const registeredFeatureFlags = new Array(); @@ -151,71 +209,115 @@ 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 configApi = 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], + [appThemeApiRef, appThemeApi], + [configApiRef, configApi], + [identityApiRef, this.identityApi], ]); 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, + 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); + 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('/')) { 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 Provider; + + return AppRouter; } verify() { diff --git a/packages/core-api/src/app/AppIdentity.ts b/packages/core-api/src/app/AppIdentity.ts new file mode 100644 index 0000000000..77de445a20 --- /dev/null +++ b/packages/core-api/src/app/AppIdentity.ts @@ -0,0 +1,70 @@ +/* + * 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 hasIdentity = false; + private userId?: string; + private idToken?: string; + private logoutFunc?: () => Promise; + + getUserId(): string { + if (!this.hasIdentity) { + throw new Error( + 'Tried to access IdentityApi userId before app was loaded', + ); + } + return this.userId!; + } + + getIdToken(): string | undefined { + if (!this.hasIdentity) { + throw new Error( + 'Tried to access IdentityApi idToken before app was loaded', + ); + } + return this.idToken; + } + + async logout(): Promise { + if (!this.hasIdentity) { + throw new Error( + 'Tried to access IdentityApi logoutFunc before app was loaded', + ); + } + 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; + } +} diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index e30c79dd73..a152ef9dfb 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; + /** + * 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; }; /** @@ -117,14 +151,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/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/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) => ( { @@ -48,6 +56,11 @@ export function SidebarUserSettings() { apiRef={githubAuthApiRef} icon={Star} /> + identityApi.logout()} + /> ); diff --git a/packages/core/src/layout/SignInPage/SignInPage.tsx b/packages/core/src/layout/SignInPage/SignInPage.tsx new file mode 100644 index 0000000000..91bc251f90 --- /dev/null +++ b/packages/core/src/layout/SignInPage/SignInPage.tsx @@ -0,0 +1,49 @@ +/* + * 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 } 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[]; +}; + +export const SignInPage: FC = ({ onResult, providers }) => { + const configApi = useApi(configApiRef); + + const [loading, providerElements] = useSignInProviders(providers, onResult); + + if (loading) { + return ; + } + + return ( + +
+ + + {providerElements} + + + ); +}; 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/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/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/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/SignInPage/providers.tsx b/packages/core/src/layout/SignInPage/providers.tsx new file mode 100644 index 0000000000..d60c8ff281 --- /dev/null +++ b/packages/core/src/layout/SignInPage/providers.tsx @@ -0,0 +1,134 @@ +/* + * 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 { googleProvider } from './googleProvider'; +import { customProvider } from './customProvider'; +import { + SignInPageProps, + SignInResult, + useApi, + useApiHolder, + errorApiRef, +} from '@backstage/core-api'; +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' | 'custom'; + +const signInProviders: { [id in SignInProviderId]: SignInProvider } = { + guest: guestProvider, + google: googleProvider, + custom: customProvider, +}; + +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; +}; 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'; 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/storybook/.storybook/apis.js b/packages/storybook/.storybook/apis.js index 9dc1e2bad2..83f6bbca23 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, @@ -21,7 +22,11 @@ const alertApi = builder.add(alertApiRef, new AlertApiForwarder()); builder.add(errorApiRef, new ErrorAlerter(alertApi, new ErrorApiForwarder())); -builder.add(identityApiRef, new MockIdentity()); +builder.add(identityApiRef, { + getUserId: () => 'guest', + getIdToken: () => undefined, + logout: async () => {}, +}); const oauthRequestApi = builder.add( oauthRequestApiRef, 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 */} + } /> + ); } 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); } } diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index 1722dbb9ac..08014fdc74 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -36,9 +36,10 @@ import { CatalogTable } from '../CatalogTable/CatalogTable'; import { useEntities } from '../../hooks/useEntities'; import { findLocationForEntityMeta } from '../../data/utils'; import { - filterGroups, getCatalogFilterItemByType, EntityFilterType, + filterGroups, + labeledEntityTypes, } from '../../data/filters'; const useStyles = makeStyles(theme => ({ @@ -57,14 +58,19 @@ const useStyles = makeStyles(theme => ({ export const CatalogPage: FC<{}> = () => { const { entitiesByFilter, - selectedFilter: selectedId, error, + loading, + selectedFilter, toggleStarredEntity, isStarredEntity, setSelectedFilter, + selectedTab, + setSelectedTab, } = useEntities(); - const data = entitiesByFilter[selectedId ?? EntityFilterType.ALL]; + const filteredEntities = + entitiesByFilter[selectedFilter ?? EntityFilterType.ALL]; + const styles = useStyles(); const actions = [ @@ -113,33 +119,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(labeledEntityTypes[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 6230b3c8fc..31897dc27f 100644 --- a/plugins/catalog/src/data/filters.ts +++ b/plugins/catalog/src/data/filters.ts @@ -72,6 +72,7 @@ type EntityFilter = (entity: Entity, options: EntityFilterOptions) => boolean; type EntityFilterOptions = Partial<{ isStarred: boolean; userId: string; + type: string; }>; type Owned = { @@ -84,7 +85,40 @@ export const entityFilters: Record = { return owner === userId; }, [EntityFilterType.ALL]: () => true, - [EntityFilterType.STARRED]: (_, { isStarred }) => isStarred ?? false, + [EntityFilterType.STARRED]: (_, { isStarred }) => !!isStarred, }; +export const entityTypeFilter = (e: Entity, type: string) => + (e.spec as any)?.type === type; + +type EntityType = 'service' | 'website' | 'lib' | 'documentation' | 'other'; + +type LabeledEntityType = { + id: EntityType; + label: string; +}; + +export const labeledEntityTypes: LabeledEntityType[] = [ + { + id: 'service', + label: 'Services', + }, + { + id: 'website', + label: 'Websites', + }, + { + id: 'lib', + label: 'Libraries', + }, + { + id: 'documentation', + label: 'Documentation', + }, + { + id: 'other', + label: 'Other', + }, +]; + export const defaultFilter: CatalogFilterItem = filterGroups[0].items[0]; diff --git a/plugins/catalog/src/hooks/useEntities.ts b/plugins/catalog/src/hooks/useEntities.ts index 1530be6f7c..d9065b645e 100644 --- a/plugins/catalog/src/hooks/useEntities.ts +++ b/plugins/catalog/src/hooks/useEntities.ts @@ -14,7 +14,12 @@ * limitations under the License. */ import { useState, useMemo } from 'react'; -import { EntityFilterType, entityFilters } from '../data/filters'; +import { + EntityFilterType, + entityFilters, + entityTypeFilter, + labeledEntityTypes, +} from '../data/filters'; import { useApi, identityApiRef } from '@backstage/core'; import { catalogApiRef } from '..'; import { useStarredEntities } from './useStarredEntites'; @@ -30,6 +35,7 @@ type UseEntities = { toggleStarredEntity: any; isStarredEntity: (e: Entity) => boolean; entitiesByFilter: EntitiesByFilter; + loading: boolean; }; export const useEntities = (): UseEntities => { @@ -46,6 +52,18 @@ export const useEntities = (): UseEntities => { const indentityApi = useApi(identityApiRef); const userId = indentityApi.getUserId(); + const [selectedTab, setSelectedTab] = useState( + labeledEntityTypes[0].id, + ); + + // 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 entitiesByFilter = useMemo(() => { const filterEntities = ( ents: Entity[] | undefined, @@ -53,12 +71,14 @@ export const useEntities = (): UseEntities => { isStarred: (e: Entity) => boolean, user: string, ) => { - return ents?.filter((e: Entity) => - entityFilters[filterId](e, { - isStarred: isStarred(e), - userId: user, - }), - ); + return ents + ?.filter((e: Entity) => + entityFilters[filterId](e, { + isStarred: isStarred(e), + userId: user, + }), + ) + .filter(e => entityTypeFilter(e, selectedTab)); }; const data = Object.keys(EntityFilterType).reduce( (res, key) => ({ @@ -73,7 +93,7 @@ export const useEntities = (): UseEntities => { {} as EntitiesByFilter, ); return data; - }, [entities, isStarredEntity, userId]); + }, [entities, isStarredEntity, userId, selectedTab]); return { selectedFilter, @@ -82,5 +102,8 @@ export const useEntities = (): UseEntities => { toggleStarredEntity, isStarredEntity, entitiesByFilter, + loading: entities === undefined, + selectedTab, + setSelectedTab, }; };