Merge remote-tracking branch 'origin/master' into ndudnik/filter-by-identity

This commit is contained in:
Nikita Nek Dudnik
2020-06-17 15:17:38 +02:00
28 changed files with 929 additions and 132 deletions
+12 -12
View File
@@ -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?
+18 -5
View File
@@ -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 => (
<SignInPage {...props} providers={['guest', 'google', 'custom']} />
),
},
});
const AppProvider = app.getProvider();
const AppComponent = app.getRootComponent();
const AppRouter = app.getRouter();
const AppRoutes = app.getRoutes();
const App: FC<{}> = () => (
<AppProvider>
<AlertDisplay />
<OAuthRequestDialog />
<Root>
<AppComponent />
</Root>
<AppRouter>
<Root>
<AppRoutes />
</Root>
</AppRouter>
</AppProvider>
);
@@ -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 (
<AppProvider>
<AppComponent />
<AppRouter>
<AppRoutes />
</AppRouter>
</AppProvider>
);
};
+7 -1
View File
@@ -39,13 +39,19 @@ ApiProvider.propTypes = {
children: PropTypes.node,
};
export function useApi<T>(apiRef: ApiRef<T>): 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<T>(apiRef: ApiRef<T>): T {
const apiHolder = useApiHolder();
const api = apiHolder.get(apiRef);
if (!api) {
throw new Error(`No implementation available for ${apiRef}`);
@@ -38,6 +38,11 @@ export type IdentityApi = {
getIdToken(): string | undefined;
// TODO: getProfile(): Promise<Profile> - We want this to be async when added, but needs more work.
/**
* Log out the current user
*/
logout(): Promise<void>;
};
export const identityApiRef = createApiRef<IdentityApi>({
+1 -1
View File
@@ -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';
+146 -44
View File
@@ -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 = <Progress />;
} else if (config.error) {
const { BootErrorPage } = components;
noConfigNode = <BootErrorPage step="load-config" error={config.error} />;
}
// Before the config is loaded we can't use a router, so exit early
if (noConfigNode) {
return {
node: (
<ApiProvider apis={ApiRegistry.from([[appThemeApiRef, appThemeApi]])}>
<AppThemeProvider>{noConfigNode}</AppThemeProvider>
</ApiProvider>
),
};
}
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<JSX.Element>();
const registeredFeatureFlags = new Array<FeatureFlagsRegistryItem>();
@@ -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 = <Progress />;
} else if (config.error) {
const { BootErrorPage } = this.components;
noConfigNode = (
<BootErrorPage step="load-config" error={config.error} />
);
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 (
<ApiProvider apis={ApiRegistry.from([[appThemeApiRef, appThemeApi]])}>
<AppThemeProvider>{noConfigNode}</AppThemeProvider>
</ApiProvider>
);
}
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 (
<ApiProvider apis={apis}>
<AppContextProvider app={this}>
<AppThemeProvider>{children}</AppThemeProvider>
</AppContextProvider>
</ApiProvider>
);
};
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<SignInPageProps>;
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 <Component onResult={onResult} />;
};
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 (
<RouterComponent>
<Routes>
<Route path={`${pathname}/*`} element={<>{children}</>} />
</Routes>
</RouterComponent>
);
}
return (
<ApiProvider apis={apis}>
<AppContextProvider app={this}>
<AppThemeProvider>
<Router>
<Routes>
<Route path={`${pathname}/*`} element={<>{children}</>} />
</Routes>
</Router>
</AppThemeProvider>
</AppContextProvider>
</ApiProvider>
<RouterComponent>
<SignInPageWrapper component={SignInPageComponent}>
<Routes>
<Route path={`${pathname}/*`} element={<>{children}</>} />
</Routes>
</SignInPageWrapper>
</RouterComponent>
);
};
return Provider;
return AppRouter;
}
verify() {
+70
View File
@@ -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<void>;
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<void> {
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;
}
}
+47 -8
View File
@@ -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<void>;
};
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<BootErrorPageProps>;
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<SignInPageProps>;
};
/**
@@ -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<{}>;
};
+1
View File
@@ -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",
+13 -3
View File
@@ -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<Number>(0);
const styles = useStyles();
const handleChange = (_: React.ChangeEvent<{}>, index: Number) => {
setSelectedTab(index);
if (onChange) onChange(index);
};
return (
<div className={styles.tabsWrapper}>
<Tabs
@@ -53,7 +62,8 @@ export const HeaderTabs: React.FC<{ tabs: Tab[] }> = ({ tabs }) => {
variant="scrollable"
scrollButtons="auto"
aria-label="scrollable auto tabs example"
value={0}
onChange={handleChange}
value={selectedTab}
>
{tabs.map((tab, index) => (
<Tab
@@ -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}
/>
<SidebarItem
icon={SignOutIcon}
text="Sign Out"
onClick={() => identityApi.logout()}
/>
</Collapse>
</>
);
@@ -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<Props> = ({ onResult, providers }) => {
const configApi = useApi(configApiRef);
const [loading, providerElements] = useSignInProviders(providers, onResult);
if (loading) {
return <Progress />;
}
return (
<Page>
<Header title={configApi.getString('app.title') ?? 'Backstage'} />
<Content>
<ContentHeader title="Select a sign-in method" />
<Grid container>{providerElements}</Grid>
</Content>
</Page>
);
};
@@ -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<SignInResult>({
mode: 'onChange',
});
return (
<Grid item>
<InfoCard title="Custom User">
<Typography variant="body1">
Enter your own User ID and credentials.
<br />
This selection will not be stored.
</Typography>
<form className={classes.form} onSubmit={handleSubmit(onResult)}>
<FormControl>
<TextField
name="userId"
label="User ID"
margin="normal"
error={Boolean(errors.userId)}
inputRef={register({ required: true })}
/>
{errors.userId && (
<FormHelperText error>{errors.userId.message}</FormHelperText>
)}
</FormControl>
<FormControl>
<TextField
name="idToken"
label="ID Token (optional)"
margin="normal"
autoComplete="off"
error={Boolean(errors.idToken)}
inputRef={register({
required: false,
validate: token =>
!token ||
ID_TOKEN_REGEX.test(token) ||
'Token is not a valid OpenID Connect JWT Token',
})}
/>
{errors.idToken && (
<FormHelperText error>{errors.idToken.message}</FormHelperText>
)}
</FormControl>
<Button
type="submit"
color="primary"
variant="outlined"
className={classes.button}
disabled={!formState?.dirty || !isEmpty(errors)}
>
Continue
</Button>
</form>
</InfoCard>
</Grid>
);
};
// Custom provider doesn't store credentials
const loader: ProviderLoader = async () => undefined;
export const customProvider: SignInProvider = { Component, loader };
@@ -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 (
<Grid item>
<InfoCard
title="Google"
actions={
<Button color="primary" variant="outlined" onClick={handleLogin}>
Sign In
</Button>
}
>
<Typography variant="body1">Sign In using Google</Typography>
</InfoCard>
</Grid>
);
};
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 };
@@ -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 }) => (
<Grid item>
<InfoCard
title="Guest"
actions={
<Button
color="primary"
variant="outlined"
onClick={() => onResult({ userId: 'guest' })}
>
Enter
</Button>
}
>
<Typography variant="body1">
Enter as a Guest User.
<br />
You will not have a verified identity,
<br />
meaning some features might be unavailable.
</Typography>
</InfoCard>
</Grid>
);
const loader: ProviderLoader = async () => {
return { userId: 'guest' };
};
export const guestProvider: SignInProvider = { Component, loader };
@@ -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';
@@ -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 <Component key={providerId} onResult={handleResult} />;
}),
[providers, handleWrappedResult],
);
return [loading, elements];
};
@@ -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<SignInPageProps>;
export type ProviderLoader = (
apis: ApiHolder,
) => Promise<SignInResult | undefined>;
export type SignInProvider = {
Component: ProviderComponent;
loader: ProviderLoader;
};
+1
View File
@@ -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';
+10 -5
View File
@@ -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 {
<AlertDisplay />
<OAuthRequestDialog />
{this.rootChildren}
<SidebarPage>
{sidebar}
<AppComponent />
</SidebarPage>
<AppRouter>
<SidebarPage>
{sidebar}
<AppRoutes />
</SidebarPage>
</AppRouter>
</AppProvider>
);
};
+6 -1
View File
@@ -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,
@@ -89,12 +89,15 @@ export function wrapInTestApp(
}
const AppProvider = app.getProvider();
const AppRouter = app.getRouter();
return (
<AppProvider>
{/* 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 */}
<Route path="*" element={<Wrapper />} />
<AppRouter>
{/* 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 */}
<Route path="*" element={<Wrapper />} />
</AppRouter>
</AppProvider>
);
}
@@ -37,7 +37,7 @@ export class EnvironmentHandler implements AuthProviderRouteHandlers {
async start(req: express.Request, res: express.Response): Promise<void> {
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<void> {
const provider = this.getProviderForEnv(req);
provider.frameHandler(req, res);
await provider.frameHandler(req, res);
}
async refresh(req: express.Request, res: express.Response): Promise<void> {
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<void> {
const provider = this.getProviderForEnv(req);
provider.logout(req, res);
await provider.logout(req, res);
}
}
@@ -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 (
<CatalogLayout>
<HeaderTabs tabs={tabs} />
<HeaderTabs
tabs={labeledEntityTypes}
onChange={(index: Number) => {
setSelectedTab(labeledEntityTypes[index as number].id);
}}
/>
<Content>
<DismissableBanner
variant="info"
@@ -173,18 +160,18 @@ export const CatalogPage: FC<{}> = () => {
<div>
<CatalogFilter
groups={filterGroups}
selectedFilter={selectedId ?? EntityFilterType.ALL}
selectedFilter={selectedFilter ?? EntityFilterType.ALL}
onFilterChange={setSelectedFilter}
entitiesByFilter={entitiesByFilter}
/>
</div>
<CatalogTable
titlePreamble={
getCatalogFilterItemByType(selectedId ?? EntityFilterType.ALL)
getCatalogFilterItemByType(selectedFilter ?? EntityFilterType.ALL)
?.label ?? ''
}
entities={data || []}
loading={!data && !error}
entities={filteredEntities || []}
loading={loading && !error}
error={error}
actions={actions}
/>
@@ -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",
+35 -1
View File
@@ -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<string, EntityFilter> = {
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];
+31 -8
View File
@@ -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<string>(
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,
};
};