diff --git a/packages/core-api/src/apis/definitions/IdentityApi.ts b/packages/core-api/src/apis/definitions/IdentityApi.ts index bbfab0ef35..4c9ccfdbf6 100644 --- a/packages/core-api/src/apis/definitions/IdentityApi.ts +++ b/packages/core-api/src/apis/definitions/IdentityApi.ts @@ -40,7 +40,7 @@ export type IdentityApi = { // TODO: getProfile(): Promise - We want this to be async when added, but needs more work. }; -export const identifyApiRef = createApiRef({ +export const identityApiRef = createApiRef({ id: 'core.identity', description: 'Provides access to the identity of the signed in user', }); diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index 8d625a3a0d..163a546943 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -13,16 +13,31 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { ComponentType, FC, useMemo } from 'react'; +import React, { + ComponentType, + FC, + useMemo, + useCallback, + useState, + ReactElement, +} from 'react'; import { Route, Routes, Navigate } from 'react-router-dom'; import { AppContextProvider } from './AppContext'; -import { BackstageApp, AppComponents, AppConfigLoader, Apis } from './types'; +import { + BackstageApp, + AppComponents, + AppConfigLoader, + Apis, + SignInResult, + SignInPageProps, +} from './types'; import { BackstagePlugin } from '../plugin'; import { FeatureFlagsRegistryItem } from './FeatureFlags'; import { featureFlagsApiRef, AppThemeApi, ConfigApi, + identityApiRef, } from '../apis/definitions'; import { AppThemeProvider } from './AppThemeProvider'; @@ -40,6 +55,7 @@ import { } from '../apis'; import { ApiAggregator } from '../apis/ApiAggregator'; import { useAsync } from 'react-use'; +import { AppIdentity } from './AppIdentity'; type FullAppOptions = { apis: Apis; @@ -93,6 +109,8 @@ export class PrivateAppImpl implements BackstageApp { private readonly themes: AppTheme[]; private readonly configLoader?: AppConfigLoader; + private readonly identityApi = new AppIdentity(); + private apisOrFactory: Apis; constructor(options: FullAppOptions) { @@ -205,6 +223,7 @@ export class PrivateAppImpl implements BackstageApp { const appApis = ApiRegistry.from([ [appThemeApiRef, appThemeApi], [configApiRef, configApi], + [identityApiRef, this.identityApi], ]); if (!this.apis) { @@ -229,7 +248,35 @@ export class PrivateAppImpl implements BackstageApp { } getRouter(): ComponentType<{}> { - const { Router: RouterComponent } = this.components; + const { + Router: RouterComponent, + SignInPage: SignInPageComponent, + } = this.components; + + // This wraps the sign-in page and waits for sign-in to be completed before rendering the app + const SignInPageWrapper: FC<{ + component: ComponentType; + children: ReactElement; + }> = ({ component: Component, children }) => { + const [done, setDone] = useState(false); + + const onResult = useCallback( + (result: SignInResult) => { + if (done) { + throw new Error('Identity result callback was called twice'); + } + setDone(true); + this.identityApi.setSignInResult(result); + }, + [done], + ); + + if (done) { + return children; + } + + return ; + }; const AppRouter: FC<{}> = ({ children }) => { const configApi = useApi(configApiRef); @@ -242,14 +289,34 @@ export class PrivateAppImpl implements BackstageApp { pathname = pathname.replace(/\/$/, ''); } + // If the app hasn't configured a sign-in page, we just continue as guest. + if (!SignInPageComponent) { + this.identityApi.setSignInResult({ + userId: 'guest', + idToken: undefined, + logout: async () => {}, + }); + + return ( + + + {children}} /> + + + ); + } + return ( - - {children}} /> - + + + {children}} /> + + ); }; + return AppRouter; } diff --git a/packages/core-api/src/app/AppIdentity.ts b/packages/core-api/src/app/AppIdentity.ts new file mode 100644 index 0000000000..a0f933710b --- /dev/null +++ b/packages/core-api/src/app/AppIdentity.ts @@ -0,0 +1,61 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { IdentityApi } from '../apis'; +import { SignInResult } from './types'; + +/** + * Implementation of the connection between the App-wide IdentityApi + * and sign-in page. + */ +export class AppIdentity implements IdentityApi { + private userId?: string; + private idToken?: string; + private logoutFunc?: () => Promise; + + getUserId(): string { + if (!this.userId) { + throw new Error( + 'Tried to access IdentityApi userId before app was loaded', + ); + } + return this.userId; + } + + getIdToken(): string { + if (!this.idToken) { + throw new Error( + 'Tried to access IdentityApi idToken before app was loaded', + ); + } + return this.idToken; + } + + async logout(): Promise { + if (!this.logoutFunc) { + throw new Error( + 'Tried to access IdentityApi logoutFunc before app was loaded', + ); + } + await this.logoutFunc; + } + + setSignInResult(result: SignInResult) { + this.userId = result.userId; + this.idToken = result.idToken; + this.logoutFunc = result.logout; + } +} diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index 97809ca899..099438a58c 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -25,11 +25,45 @@ export type BootErrorPageProps = { step: 'load-config'; error: Error; }; + +export type SignInResult = { + /** + * User ID that will be returned by the IdentityApi + */ + userId: string; + /** + * ID token that will be returned by the IdentityApi + */ + idToken: string | undefined; + /** + * Logout handler that will be called if the user requests a logout. + */ + logout: () => Promise; +}; + +export type SignInPageProps = { + /** + * Set the sign-in result for the app. This should only be called once. + */ + onResult(result: SignInResult): void; +}; + export type AppComponents = { NotFoundErrorPage: ComponentType<{}>; BootErrorPage: ComponentType; Progress: ComponentType<{}>; Router: ComponentType<{}>; + + /** + * An optional sign-in page that will be rendered instead of the AppRouter at startup. + * + * If a sign-in page is set, it will always be shown before the app, and it is up + * to the sign-in page to handle e.g. saving of login methods for subsequent visits. + * + * The sign-in page will be displayed until it has passed up a result to the parent, + * and which point the AppRouter and all of its children will be rendered instead. + */ + SignInPage?: ComponentType; }; /**