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, },