diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx
index ed0518485a..ba7fb4a8a5 100644
--- a/packages/app/src/App.tsx
+++ b/packages/app/src/App.tsx
@@ -27,15 +27,18 @@ const app = createApp({
});
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/definitions/IdentityApi.ts b/packages/core-api/src/apis/definitions/IdentityApi.ts
index bbfab0ef35..5da9ed4814 100644
--- a/packages/core-api/src/apis/definitions/IdentityApi.ts
+++ b/packages/core-api/src/apis/definitions/IdentityApi.ts
@@ -38,9 +38,14 @@ 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 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 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/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/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 */}
+ } />
+
);
}