diff --git a/.changeset/fifty-seas-grab.md b/.changeset/fifty-seas-grab.md
new file mode 100644
index 0000000000..587ac5606d
--- /dev/null
+++ b/.changeset/fifty-seas-grab.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-app-api': minor
+---
+
+Updated core extension structure to make space for the sign-in page by adding `core.router`.
diff --git a/.changeset/hip-berries-begin.md b/.changeset/hip-berries-begin.md
new file mode 100644
index 0000000000..43d176cbcf
--- /dev/null
+++ b/.changeset/hip-berries-begin.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-test-utils': patch
+---
+
+Updates for `core.router` addition.
diff --git a/.changeset/nervous-dancers-wait.md b/.changeset/nervous-dancers-wait.md
new file mode 100644
index 0000000000..388d995487
--- /dev/null
+++ b/.changeset/nervous-dancers-wait.md
@@ -0,0 +1,5 @@
+---
+'@backstage/frontend-plugin-api': patch
+---
+
+Added `createSignInPageExtension`.
diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx
index 978c40ef44..67faf8d646 100644
--- a/packages/app-next/src/App.tsx
+++ b/packages/app-next/src/App.tsx
@@ -36,12 +36,18 @@ import { collectLegacyRoutes } from '@backstage/core-compat-api';
import { FlatRoutes } from '@backstage/core-app-api';
import { Route } from 'react-router';
import { CatalogImportPage } from '@backstage/plugin-catalog-import';
-import { createApiFactory, configApiRef } from '@backstage/core-plugin-api';
+import {
+ createApiFactory,
+ configApiRef,
+ SignInPageProps,
+} from '@backstage/core-plugin-api';
import {
ScmAuth,
ScmIntegrationsApi,
scmIntegrationsApiRef,
} from '@backstage/integration-react';
+import { createSignInPageExtension } from '@backstage/frontend-plugin-api';
+import { SignInPage } from '@backstage/core-components';
/*
@@ -84,6 +90,12 @@ const homePageExtension = createExtension({
},
});
+const signInPage = createSignInPageExtension({
+ id: 'signInPage',
+ loader: async () => (props: SignInPageProps) =>
+ ,
+});
+
const scmAuthExtension = createApiExtension({
factory: ScmAuth.createDefaultApiFactory(),
});
@@ -112,7 +124,12 @@ const app = createApp({
homePlugin,
...collectedLegacyPlugins,
createExtensionOverrides({
- extensions: [homePageExtension, scmAuthExtension, scmIntegrationApi],
+ extensions: [
+ homePageExtension,
+ scmAuthExtension,
+ scmIntegrationApi,
+ signInPage,
+ ],
}),
],
/* Handled through config instead */
diff --git a/packages/frontend-app-api/src/extensions/CoreLayout.tsx b/packages/frontend-app-api/src/extensions/CoreLayout.tsx
index 97f49acac9..0099b67751 100644
--- a/packages/frontend-app-api/src/extensions/CoreLayout.tsx
+++ b/packages/frontend-app-api/src/extensions/CoreLayout.tsx
@@ -24,7 +24,7 @@ import { SidebarPage } from '@backstage/core-components';
export const CoreLayout = createExtension({
id: 'core.layout',
- attachTo: { id: 'core', input: 'root' },
+ attachTo: { id: 'core.router', input: 'children' },
inputs: {
nav: createExtensionInput(
{
diff --git a/packages/frontend-app-api/src/extensions/CoreRouter.tsx b/packages/frontend-app-api/src/extensions/CoreRouter.tsx
new file mode 100644
index 0000000000..f128a246e6
--- /dev/null
+++ b/packages/frontend-app-api/src/extensions/CoreRouter.tsx
@@ -0,0 +1,175 @@
+/*
+ * Copyright 2023 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, { ComponentType, ReactNode, useContext, useState } from 'react';
+import {
+ coreExtensionData,
+ createExtension,
+ createExtensionInput,
+} from '@backstage/frontend-plugin-api';
+import {
+ ConfigApi,
+ IdentityApi,
+ SignInPageProps,
+ configApiRef,
+ useApi,
+} from '@backstage/core-plugin-api';
+import { InternalAppContext } from '../wiring/InternalAppContext';
+// eslint-disable-next-line @backstage/no-relative-monorepo-imports
+import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy';
+import { BrowserRouter } from 'react-router-dom';
+// eslint-disable-next-line @backstage/no-relative-monorepo-imports
+import { signInPageComponentDataRef } from '../../../frontend-plugin-api/src/extensions/createSignInPageExtension';
+
+export const CoreRouter = createExtension({
+ id: 'core.router',
+ attachTo: { id: 'core', input: 'root' },
+ inputs: {
+ signInPage: createExtensionInput(
+ {
+ component: signInPageComponentDataRef,
+ },
+ { singleton: true, optional: true },
+ ),
+ children: createExtensionInput(
+ {
+ element: coreExtensionData.reactElement,
+ },
+ { singleton: true },
+ ),
+ },
+ output: {
+ element: coreExtensionData.reactElement,
+ },
+ factory({ inputs }) {
+ return {
+ element: (
+
+ {inputs.children.element}
+
+ ),
+ };
+ },
+});
+
+/**
+ * Read the configured base path.
+ *
+ * The returned path does not have a trailing slash.
+ */
+function getBasePath(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;
+ SignInPageComponent?: ComponentType;
+}
+
+/**
+ * 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 { children, SignInPageComponent } = props;
+
+ const configApi = useApi(configApiRef);
+ const basePath = getBasePath(configApi);
+ const internalAppContext = useContext(InternalAppContext);
+ if (!internalAppContext) {
+ throw new Error('AppRouter must be rendered within the AppProvider');
+ }
+ const { 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 || '/' },
+ );
+
+ return {children};
+ }
+
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts
index c44c10180a..112e1435ae 100644
--- a/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts
+++ b/packages/frontend-app-api/src/routing/extractRouteInfoFromAppNode.test.ts
@@ -33,6 +33,7 @@ import { Core } from '../extensions/Core';
import { CoreRoutes } from '../extensions/CoreRoutes';
import { CoreNav } from '../extensions/CoreNav';
import { CoreLayout } from '../extensions/CoreLayout';
+import { CoreRouter } from '../extensions/CoreRouter';
const ref1 = createRouteRef();
const ref2 = createRouteRef();
@@ -79,7 +80,7 @@ function routeInfoFromExtensions(extensions: Extension[]) {
});
const tree = createAppTree({
config: new MockConfigApi({}),
- builtinExtensions: [Core, CoreRoutes, CoreNav, CoreLayout],
+ builtinExtensions: [Core, CoreRoutes, CoreNav, CoreLayout, CoreRouter],
features: [plugin],
});
diff --git a/packages/frontend-app-api/src/wiring/InternalAppContext.ts b/packages/frontend-app-api/src/wiring/InternalAppContext.ts
new file mode 100644
index 0000000000..805c46a067
--- /dev/null
+++ b/packages/frontend-app-api/src/wiring/InternalAppContext.ts
@@ -0,0 +1,26 @@
+/*
+ * 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';
+// eslint-disable-next-line @backstage/no-relative-monorepo-imports
+import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy';
+
+export const InternalAppContext = createContext<
+ | undefined
+ | {
+ appIdentityProxy: AppIdentityProxy;
+ }
+>(undefined);
diff --git a/packages/frontend-app-api/src/wiring/createApp.test.tsx b/packages/frontend-app-api/src/wiring/createApp.test.tsx
index 80d9f846f0..52186a5029 100644
--- a/packages/frontend-app-api/src/wiring/createApp.test.tsx
+++ b/packages/frontend-app-api/src/wiring/createApp.test.tsx
@@ -127,18 +127,22 @@ describe('createApp', () => {
expect(String(tree.root)).toMatchInlineSnapshot(`
"
root [
-
- content [
-
- routes [
-
+
+ children [
+
+ content [
+
+ routes [
+
+ ]
+
]
-
+ nav [
+
+ ]
+
]
- nav [
-
- ]
-
+
]
themes [
diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx
index d39111eb99..1858bd4884 100644
--- a/packages/frontend-app-api/src/wiring/createApp.tsx
+++ b/packages/frontend-app-api/src/wiring/createApp.tsx
@@ -76,7 +76,7 @@ import {
components as defaultComponents,
icons as defaultIcons,
} from '../../../app-defaults/src/defaults';
-import { BrowserRouter, Route } from 'react-router-dom';
+import { Route } from 'react-router-dom';
import { SidebarItem } from '@backstage/core-components';
import { DarkTheme, LightTheme } from '../extensions/themes';
import { extractRouteInfoFromAppNode } from '../routing/extractRouteInfoFromAppNode';
@@ -91,9 +91,12 @@ import { collectRouteIds } from '../routing/collectRouteIds';
import { createAppTree } from '../tree';
import { AppNode } from '@backstage/frontend-plugin-api';
import { toLegacyPlugin } from '../routing/toLegacyPlugin';
+import { InternalAppContext } from './InternalAppContext';
+import { CoreRouter } from '../extensions/CoreRouter';
const builtinExtensions = [
Core,
+ CoreRouter,
CoreRoutes,
CoreNav,
CoreLayout,
@@ -299,7 +302,8 @@ export function createSpecializedApp(options?: {
),
);
- const apiHolder = createApiHolder(tree, config);
+ const appIdentityProxy = new AppIdentityProxy();
+ const apiHolder = createApiHolder(tree, config, appIdentityProxy);
const routeInfo = extractRouteInfoFromAppNode(tree.root);
const routeBindings = resolveRouteBindings(
options?.bindRoutes,
@@ -313,8 +317,9 @@ export function createSpecializedApp(options?: {
- {/* TODO: set base path using the logic from AppRouter */}
- {rootEl}
+
+ {rootEl}
+
@@ -350,7 +355,11 @@ function createLegacyAppContext(plugins: BackstagePlugin[]): AppContext {
};
}
-function createApiHolder(tree: AppTree, configApi: ConfigApi): ApiHolder {
+function createApiHolder(
+ tree: AppTree,
+ configApi: ConfigApi,
+ appIdentityProxy: AppIdentityProxy,
+): ApiHolder {
const factoryRegistry = new ApiFactoryRegistry();
const pluginApis =
@@ -379,33 +388,7 @@ function createApiHolder(tree: AppTree, configApi: ConfigApi): ApiHolder {
factoryRegistry.register('static', {
api: identityApiRef,
deps: {},
- factory: () => {
- const appIdentityProxy = new AppIdentityProxy();
- // TODO: Remove this when sign-in page is migrated
- 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: '/' },
- );
- return appIdentityProxy;
- },
+ factory: () => appIdentityProxy,
});
factoryRegistry.register('static', {
diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md
index 6d4b327dd5..bcc6115ff3 100644
--- a/packages/frontend-plugin-api/api-report.md
+++ b/packages/frontend-plugin-api/api-report.md
@@ -68,6 +68,7 @@ import { default as React_2 } from 'react';
import { ReactNode } from 'react';
import { SessionApi } from '@backstage/core-plugin-api';
import { SessionState } from '@backstage/core-plugin-api';
+import { SignInPageProps } from '@backstage/core-plugin-api';
import { StorageApi } from '@backstage/core-plugin-api';
import { storageApiRef } from '@backstage/core-plugin-api';
import { StorageValueSnapshot } from '@backstage/core-plugin-api';
@@ -455,6 +456,25 @@ export function createSchemaFromZod(
schemaCreator: (zImpl: typeof z) => ZodSchema,
): PortableSchema;
+// @public (undocumented)
+export function createSignInPageExtension<
+ TConfig extends {},
+ TInputs extends AnyExtensionInputMap,
+>(options: {
+ id: string;
+ attachTo?: {
+ id: string;
+ input: string;
+ };
+ configSchema?: PortableSchema;
+ disabled?: boolean;
+ inputs?: TInputs;
+ loader: (options: {
+ config: TConfig;
+ inputs: Expand>;
+ }) => Promise>;
+}): Extension;
+
// @public
export function createSubRouteRef<
Path extends string,
diff --git a/packages/frontend-plugin-api/src/extensions/createSignInPageExtension.test.tsx b/packages/frontend-plugin-api/src/extensions/createSignInPageExtension.test.tsx
new file mode 100644
index 0000000000..964941e213
--- /dev/null
+++ b/packages/frontend-plugin-api/src/extensions/createSignInPageExtension.test.tsx
@@ -0,0 +1,47 @@
+/*
+ * Copyright 2023 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 { createExtensionTester } from '@backstage/frontend-test-utils';
+import { screen } from '@testing-library/react';
+import { createSignInPageExtension } from './createSignInPageExtension';
+import { coreExtensionData, createExtension } from '../wiring';
+
+describe('createSignInPageExtension', () => {
+ it('renders a sign-in page', async () => {
+ const SignInPage = createSignInPageExtension({
+ id: 'test',
+ loader: async () => () => ,
+ });
+
+ createExtensionTester(
+ createExtension({
+ id: 'dummy',
+ attachTo: { id: 'ignored', input: 'ignored' },
+ output: {
+ element: coreExtensionData.reactElement,
+ },
+ factory: () => ({ element: }),
+ }),
+ )
+ .add(SignInPage)
+ .render();
+
+ await expect(
+ screen.findByTestId('sign-in-page'),
+ ).resolves.toBeInTheDocument();
+ });
+});
diff --git a/packages/frontend-plugin-api/src/extensions/createSignInPageExtension.tsx b/packages/frontend-plugin-api/src/extensions/createSignInPageExtension.tsx
new file mode 100644
index 0000000000..048be333b7
--- /dev/null
+++ b/packages/frontend-plugin-api/src/extensions/createSignInPageExtension.tsx
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2023 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, { ComponentType, lazy } from 'react';
+import { ExtensionBoundary } from '../components';
+import { PortableSchema } from '../schema';
+import {
+ createExtension,
+ Extension,
+ ExtensionInputValues,
+ AnyExtensionInputMap,
+ createExtensionDataRef,
+} from '../wiring';
+import { Expand } from '../types';
+import { SignInPageProps } from '@backstage/core-plugin-api';
+
+/** @internal */
+export const signInPageComponentDataRef =
+ createExtensionDataRef>('core.signInPage');
+
+/**
+ *
+ * @public
+ */
+export function createSignInPageExtension<
+ TConfig extends {},
+ TInputs extends AnyExtensionInputMap,
+>(options: {
+ id: string;
+ attachTo?: { id: string; input: string };
+ configSchema?: PortableSchema;
+ disabled?: boolean;
+ inputs?: TInputs;
+ loader: (options: {
+ config: TConfig;
+ inputs: Expand>;
+ }) => Promise>;
+}): Extension {
+ const { id } = options;
+
+ return createExtension({
+ id,
+ attachTo: options.attachTo ?? { id: 'core.router', input: 'signInPage' },
+ configSchema: options.configSchema,
+ inputs: options.inputs,
+ disabled: options.disabled,
+ output: {
+ component: signInPageComponentDataRef,
+ },
+ factory({ config, inputs, source }) {
+ const ExtensionComponent = lazy(() =>
+ options
+ .loader({ config, inputs })
+ .then(component => ({ default: component })),
+ );
+
+ return {
+ component: props => (
+
+
+
+ ),
+ };
+ },
+ });
+}
diff --git a/packages/frontend-plugin-api/src/extensions/index.ts b/packages/frontend-plugin-api/src/extensions/index.ts
index 00cf4f919c..d696774272 100644
--- a/packages/frontend-plugin-api/src/extensions/index.ts
+++ b/packages/frontend-plugin-api/src/extensions/index.ts
@@ -17,4 +17,5 @@
export { createApiExtension } from './createApiExtension';
export { createPageExtension } from './createPageExtension';
export { createNavItemExtension } from './createNavItemExtension';
+export { createSignInPageExtension } from './createSignInPageExtension';
export { createThemeExtension } from './createThemeExtension';
diff --git a/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts b/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts
index 42648fcd96..f69b54aa42 100644
--- a/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts
+++ b/packages/frontend-plugin-api/src/wiring/createPlugin.test.ts
@@ -133,7 +133,7 @@ describe('createPlugin', () => {
await renderWithEffects(
createTestAppRoot({
features: [plugin],
- config: { app: { extensions: [{ 'core.layout': false }] } },
+ config: { app: { extensions: [{ 'core.router': false }] } },
}),
);
@@ -161,7 +161,7 @@ describe('createPlugin', () => {
config: {
app: {
extensions: [
- { 'core.layout': false },
+ { 'core.router': false },
{
'plugin.catalog.page': {
config: { name: 'CatalogRenamed' },
diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx
index 946dd3cb0c..398acf4c74 100644
--- a/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx
+++ b/packages/frontend-test-utils/src/app/createExtensionTester.test.tsx
@@ -62,7 +62,7 @@ describe('createExtensionTester', () => {
}),
).render(),
).toThrow(
- "Failed to instantiate extension 'core', input 'root' did not receive required extension data 'core.reactElement' from extension 'test'",
+ "Failed to instantiate extension 'core.router', input 'children' did not receive required extension data 'core.reactElement' from extension 'test'",
);
});
});
diff --git a/packages/frontend-test-utils/src/app/createExtensionTester.ts b/packages/frontend-test-utils/src/app/createExtensionTester.ts
index 34833dfc69..c95ece342d 100644
--- a/packages/frontend-test-utils/src/app/createExtensionTester.ts
+++ b/packages/frontend-test-utils/src/app/createExtensionTester.ts
@@ -67,7 +67,7 @@ export class ExtensionTester {
})),
{
[subject.extension.id]: {
- attachTo: { id: 'core', input: 'root' },
+ attachTo: { id: 'core.router', input: 'children' },
config: subject.config,
disabled: false,
},