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.
+
+
+
+
+
+ );
+};
+
+// 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,
};
};