();
- const configApi = useApi(configApiRef);
- const basePath = getBasePath(configApi);
-
- if (!identityApi) {
- return ;
- }
-
- this.appIdentityProxy.setTarget(identityApi, {
- signOutTargetUrl: basePath || '/',
- });
- return children;
- };
-
- const AppRouter = ({ children }: PropsWithChildren<{}>) => {
- const configApi = useApi(configApiRef);
- const basePath = readBasePath(configApi);
- const mountPath = `${basePath}/*`;
- const { routeObjects } = useContext(InternalAppContext);
-
- // If the app hasn't configured a sign-in page, we just continue as guest.
- if (!SignInPageComponent) {
- this.appIdentityProxy.setTarget(
- {
- getUserId: () => 'guest',
- getIdToken: async () => undefined,
- getProfile: () => ({
- email: 'guest@example.com',
- displayName: 'Guest',
- }),
- getProfileInfo: async () => ({
- email: 'guest@example.com',
- displayName: 'Guest',
- }),
- getBackstageIdentity: async () => ({
- type: 'user',
- userEntityRef: 'user:default/guest',
- ownershipEntityRefs: ['user:default/guest'],
- }),
- getCredentials: async () => ({}),
- signOut: async () => {},
- },
- { signOutTargetUrl: basePath || '/' },
- );
-
- if (isReactRouterBeta()) {
- return (
-
-
-
- {children}>} />
-
-
- );
- }
-
- return (
-
-
- {children}
-
- );
- }
-
- if (isReactRouterBeta()) {
- return (
-
-
-
-
- {children}>} />
-
-
-
- );
- }
-
- return (
-
-
-
- <>{children}>
-
-
- );
- };
-
return AppRouter;
}
diff --git a/packages/core-app-api/src/app/AppRouter.test.tsx b/packages/core-app-api/src/app/AppRouter.test.tsx
new file mode 100644
index 0000000000..75f28a6bfe
--- /dev/null
+++ b/packages/core-app-api/src/app/AppRouter.test.tsx
@@ -0,0 +1,120 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 {
+ AppComponents,
+ configApiRef,
+ IdentityApi,
+ identityApiRef,
+ SignInPageProps,
+ useApi,
+} from '@backstage/core-plugin-api';
+import { InternalAppContext } from './InternalAppContext';
+import { MemoryRouter } from 'react-router-dom';
+import { AppIdentityProxy } from '../apis/implementations/IdentityApi/AppIdentityProxy';
+import { render, screen } from '@testing-library/react';
+import { AppRouter } from './AppRouter';
+import useAsync from 'react-use/lib/useAsync';
+import { AppContextProvider } from './AppContext';
+import { TestApiProvider } from '@backstage/test-utils';
+import { ConfigReader } from '@backstage/config';
+
+function UserRefDisplay() {
+ const identityApi = useApi(identityApiRef);
+ const { value } = useAsync(() => identityApi.getBackstageIdentity());
+ return ref: {value?.userEntityRef}
;
+}
+
+describe('AppRouter', () => {
+ const mockComponents = {
+ Router: MemoryRouter,
+ } as AppComponents;
+
+ it('should fall back to guest if there is no sign-in page', async () => {
+ const appIdentityProxy = new AppIdentityProxy();
+
+ render(
+
+
+ mockComponents } as any}
+ >
+
+
+
+
+
+ ,
+ ,
+ );
+
+ await expect(
+ screen.findByText('ref: user:default/guest'),
+ ).resolves.toBeInTheDocument();
+ });
+
+ it('should use the result from the sign-in page', async () => {
+ const appIdentityProxy = new AppIdentityProxy();
+
+ const SignInPage = (props: SignInPageProps) => {
+ props.onSignInSuccess({
+ getBackstageIdentity: async () => ({
+ type: 'user',
+ userEntityRef: 'user:default/test',
+ ownershipEntityRefs: ['user:default/test'],
+ }),
+ } as IdentityApi);
+ return null;
+ };
+
+ render(
+
+
+ ({ ...mockComponents, SignInPage }),
+ } as any
+ }
+ >
+
+
+
+
+
+ ,
+ );
+
+ await expect(
+ screen.findByText('ref: user:default/test'),
+ ).resolves.toBeInTheDocument();
+ });
+});
diff --git a/packages/core-app-api/src/app/AppRouter.tsx b/packages/core-app-api/src/app/AppRouter.tsx
new file mode 100644
index 0000000000..b799983a3b
--- /dev/null
+++ b/packages/core-app-api/src/app/AppRouter.tsx
@@ -0,0 +1,188 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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, { useContext, ReactNode, ComponentType, useState } from 'react';
+import {
+ ConfigApi,
+ configApiRef,
+ IdentityApi,
+ SignInPageProps,
+ useApi,
+ useApp,
+} from '@backstage/core-plugin-api';
+import { InternalAppContext } from './InternalAppContext';
+import { isReactRouterBeta } from './isReactRouterBeta';
+import { RouteTracker } from '../routing/RouteTracker';
+import { Route, Routes } from 'react-router-dom';
+import { AppIdentityProxy } from '../apis/implementations/IdentityApi/AppIdentityProxy';
+
+/**
+ * Get the app base path from the configured app baseUrl.
+ *
+ * The returned path does not have a trailing slash.
+ */
+export function getBasePath(configApi: ConfigApi) {
+ if (!isReactRouterBeta()) {
+ // When using rr v6 stable the base path is handled through the
+ // basename prop on the router component instead.
+ return '';
+ }
+
+ return readBasePath(configApi);
+}
+
+/**
+ * Read the configured base path.
+ *
+ * The returned path does not have a trailing slash.
+ */
+function readBasePath(configApi: ConfigApi) {
+ let { pathname } = new URL(
+ configApi.getOptionalString('app.baseUrl') ?? '/',
+ 'http://sample.dev', // baseUrl can be specified as just a path
+ );
+ pathname = pathname.replace(/\/*$/, '');
+ return pathname;
+}
+
+// This wraps the sign-in page and waits for sign-in to be completed before rendering the app
+function SignInPageWrapper({
+ component: Component,
+ appIdentityProxy,
+ children,
+}: {
+ component: ComponentType;
+ appIdentityProxy: AppIdentityProxy;
+ children: ReactNode;
+}) {
+ const [identityApi, setIdentityApi] = useState();
+ const configApi = useApi(configApiRef);
+ const basePath = getBasePath(configApi);
+
+ if (!identityApi) {
+ return ;
+ }
+
+ appIdentityProxy.setTarget(identityApi, {
+ signOutTargetUrl: basePath || '/',
+ });
+ return <>{children}>;
+}
+
+/**
+ * Props for the {@link AppRouter} component.
+ * @public
+ */
+export interface AppRouterProps {
+ children?: ReactNode;
+}
+
+/**
+ * App router and sign-in page wrapper.
+ *
+ * @public
+ * @remarks
+ *
+ * The AppRouter provides the routing context and renders the sign-in page.
+ * Until the user has successfully signed in, this component will render
+ * the sign-in page. Once the user has signed-in, it will instead render
+ * the app, while providing routing and route tracking for the app.
+ */
+export function AppRouter(props: AppRouterProps) {
+ const { Router: RouterComponent, SignInPage: SignInPageComponent } =
+ useApp().getComponents();
+
+ const configApi = useApi(configApiRef);
+ const basePath = readBasePath(configApi);
+ const mountPath = `${basePath}/*`;
+ const internalAppContext = useContext(InternalAppContext);
+ if (!internalAppContext) {
+ throw new Error('AppRouter must be rendered within the AppProvider');
+ }
+ const { routeObjects, appIdentityProxy } = internalAppContext;
+
+ // If the app hasn't configured a sign-in page, we just continue as guest.
+ if (!SignInPageComponent) {
+ appIdentityProxy.setTarget(
+ {
+ getUserId: () => 'guest',
+ getIdToken: async () => undefined,
+ getProfile: () => ({
+ email: 'guest@example.com',
+ displayName: 'Guest',
+ }),
+ getProfileInfo: async () => ({
+ email: 'guest@example.com',
+ displayName: 'Guest',
+ }),
+ getBackstageIdentity: async () => ({
+ type: 'user',
+ userEntityRef: 'user:default/guest',
+ ownershipEntityRefs: ['user:default/guest'],
+ }),
+ getCredentials: async () => ({}),
+ signOut: async () => {},
+ },
+ { signOutTargetUrl: basePath || '/' },
+ );
+
+ if (isReactRouterBeta()) {
+ return (
+
+
+
+ {props.children}>} />
+
+
+ );
+ }
+
+ return (
+
+
+ {props.children}
+
+ );
+ }
+
+ if (isReactRouterBeta()) {
+ return (
+
+
+
+
+ {props.children}>} />
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {props.children}
+
+
+ );
+}
diff --git a/packages/core-app-api/src/app/InternalAppContext.ts b/packages/core-app-api/src/app/InternalAppContext.ts
new file mode 100644
index 0000000000..81382acf62
--- /dev/null
+++ b/packages/core-app-api/src/app/InternalAppContext.ts
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2022 The Backstage Authors
+ *
+ * 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 { createContext } from 'react';
+import { AppIdentityProxy } from '../apis/implementations/IdentityApi/AppIdentityProxy';
+import { BackstageRouteObject } from '../routing/types';
+
+export const InternalAppContext = createContext<
+ | undefined
+ | {
+ routeObjects: BackstageRouteObject[];
+ appIdentityProxy: AppIdentityProxy;
+ }
+>(undefined);
diff --git a/packages/core-app-api/src/app/index.ts b/packages/core-app-api/src/app/index.ts
index 7843b36339..156c59d0c7 100644
--- a/packages/core-app-api/src/app/index.ts
+++ b/packages/core-app-api/src/app/index.ts
@@ -14,6 +14,8 @@
* limitations under the License.
*/
+export { AppRouter } from './AppRouter';
+export type { AppRouterProps } from './AppRouter';
export { createSpecializedApp } from './createSpecializedApp';
export { defaultConfigLoader } from './defaultConfigLoader';
export * from './types';
diff --git a/packages/core-app-api/src/app/types.ts b/packages/core-app-api/src/app/types.ts
index 70022534a1..82c05ce268 100644
--- a/packages/core-app-api/src/app/types.ts
+++ b/packages/core-app-api/src/app/types.ts
@@ -298,15 +298,44 @@ export type BackstageApp = {
*/
getSystemIcon(key: string): IconComponent | undefined;
+ /**
+ * Creates the root component that renders the entire app.
+ *
+ * @remarks
+ *
+ * This method must only be called once, and you have to provide it the entire
+ * app element tree. The element tree will be analyzed to discover plugins,
+ * routes, and other app features. The returned component will render all
+ * of the app elements wrapped within the app context provider.
+ *
+ * @example
+ * ```tsx
+ * export default app.createRoot(
+ * <>
+ *
+ *
+ *
+ * {routes}
+ *
+ * >,
+ * );
+ * ```
+ */
+ createRoot(element: JSX.Element): ComponentType<{}>;
+
/**
* Provider component that should wrap the Router created with getRouter()
* and any other components that need to be within the app context.
+ *
+ * @deprecated Use {@link BackstageApp.createRoot} instead.
*/
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.
+ *
+ * @deprecated Import and use the {@link AppRouter} component from `@backstage/core-app-api` instead
*/
getRouter(): ComponentType<{}>;
};
diff --git a/packages/create-app/templates/default-app/packages/app/src/App.tsx b/packages/create-app/templates/default-app/packages/app/src/App.tsx
index 46cb786399..95fc94703e 100644
--- a/packages/create-app/templates/default-app/packages/app/src/App.tsx
+++ b/packages/create-app/templates/default-app/packages/app/src/App.tsx
@@ -29,7 +29,7 @@ import { Root } from './components/Root';
import { AlertDisplay, OAuthRequestDialog } from '@backstage/core-components';
import { createApp } from '@backstage/app-defaults';
-import { FlatRoutes } from '@backstage/core-app-api';
+import { AppRouter, FlatRoutes } from '@backstage/core-app-api';
import { CatalogGraphPage } from '@backstage/plugin-catalog-graph';
import { RequirePermission } from '@backstage/plugin-permission-react';
import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common/alpha';
@@ -53,9 +53,6 @@ const app = createApp({
},
});
-const AppProvider = app.getProvider();
-const AppRouter = app.getRouter();
-
const routes = (
} />
@@ -97,14 +94,12 @@ const routes = (
);
-const App = () => (
-
+export default app.createRoot(
+ <>
{routes}
-
+ >,
);
-
-export default App;