diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx
index ba7fb4a8a5..f4515cbf01 100644
--- a/packages/app/src/App.tsx
+++ b/packages/app/src/App.tsx
@@ -14,7 +14,12 @@
* limitations under the License.
*/
-import { createApp, AlertDisplay, OAuthRequestDialog } from '@backstage/core';
+import {
+ createApp,
+ AlertDisplay,
+ OAuthRequestDialog,
+ SignInPage,
+} from '@backstage/core';
import React, { FC } from 'react';
import Root from './components/Root';
import * as plugins from './plugins';
@@ -24,6 +29,11 @@ import { hot } from 'react-hot-loader/root';
const app = createApp({
apis,
plugins: Object.values(plugins),
+ components: {
+ SignInPage: props => (
+
+ ),
+ },
});
const AppProvider = app.getProvider();
diff --git a/packages/core-api/src/apis/ApiProvider.tsx b/packages/core-api/src/apis/ApiProvider.tsx
index e157782024..24610a1372 100644
--- a/packages/core-api/src/apis/ApiProvider.tsx
+++ b/packages/core-api/src/apis/ApiProvider.tsx
@@ -39,13 +39,19 @@ ApiProvider.propTypes = {
children: PropTypes.node,
};
-export function useApi(apiRef: ApiRef): T {
+export function useApiHolder(): ApiHolder {
const apiHolder = useContext(Context);
if (!apiHolder) {
throw new Error('No ApiProvider available in react context');
}
+ return apiHolder;
+}
+
+export function useApi(apiRef: ApiRef): T {
+ const apiHolder = useApiHolder();
+
const api = apiHolder.get(apiRef);
if (!api) {
throw new Error(`No implementation available for ${apiRef}`);
diff --git a/packages/core-api/src/apis/index.ts b/packages/core-api/src/apis/index.ts
index 332636580c..c3689b6703 100644
--- a/packages/core-api/src/apis/index.ts
+++ b/packages/core-api/src/apis/index.ts
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-export { ApiProvider, useApi } from './ApiProvider';
+export { ApiProvider, useApi, useApiHolder } from './ApiProvider';
export { ApiRegistry } from './ApiRegistry';
export { ApiTestRegistry } from './ApiTestRegistry';
export * from './ApiRef';
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/Sidebar/UserSettings.tsx b/packages/core/src/layout/Sidebar/UserSettings.tsx
index 8ca2580df9..36e8e1088b 100644
--- a/packages/core/src/layout/Sidebar/UserSettings.tsx
+++ b/packages/core/src/layout/Sidebar/UserSettings.tsx
@@ -17,17 +17,25 @@
import React, { useContext, useEffect } from 'react';
import Collapse from '@material-ui/core/Collapse';
import Star from '@material-ui/icons/Star';
+import SignOutIcon from '@material-ui/icons/MeetingRoom';
import { SidebarContext } from './config';
-import { googleAuthApiRef, githubAuthApiRef } from '@backstage/core-api';
+import {
+ googleAuthApiRef,
+ githubAuthApiRef,
+ identityApiRef,
+ useApi,
+} from '@backstage/core-api';
import {
OAuthProviderSettings,
OIDCProviderSettings,
UserProfile as SidebarUserProfile,
} from './Settings';
+import { SidebarItem } from './Items';
export function SidebarUserSettings() {
const { isOpen: sidebarOpen } = useContext(SidebarContext);
const [open, setOpen] = React.useState(false);
+ const identityApi = useApi(identityApiRef);
// Close the provider list when sidebar collapse
useEffect(() => {
@@ -48,6 +56,11 @@ export function SidebarUserSettings() {
apiRef={githubAuthApiRef}
icon={Star}
/>
+ identityApi.logout()}
+ />
>
);
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/storybook/.storybook/apis.js b/packages/storybook/.storybook/apis.js
index d3150400cf..0450f3969d 100644
--- a/packages/storybook/.storybook/apis.js
+++ b/packages/storybook/.storybook/apis.js
@@ -2,6 +2,7 @@ import {
ApiRegistry,
alertApiRef,
errorApiRef,
+ identityApiRef,
oauthRequestApiRef,
OAuthRequestManager,
googleAuthApiRef,
@@ -19,6 +20,12 @@ const alertApi = builder.add(alertApiRef, new AlertApiForwarder());
builder.add(errorApiRef, new ErrorAlerter(alertApi, new ErrorApiForwarder()));
+builder.add(identityApiRef, {
+ getUserId: () => 'guest',
+ getIdToken: () => undefined,
+ logout: async () => {},
+});
+
const oauthRequestApi = builder.add(
oauthRequestApiRef,
new OAuthRequestManager(),