Merge pull request #21323 from backstage/mob/sign-in-page

frontend-app-api: implement sign-in page
This commit is contained in:
Patrik Oldsberg
2023-11-23 14:58:23 +01:00
committed by GitHub
17 changed files with 418 additions and 50 deletions
+5
View File
@@ -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`.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-test-utils': patch
---
Updates for `core.router` addition.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-plugin-api': patch
---
Added `createSignInPageExtension`.
+19 -2
View File
@@ -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) =>
<SignInPage {...props} providers={['guest']} />,
});
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 */
@@ -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(
{
@@ -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: (
<AppRouter SignInPageComponent={inputs.signInPage?.component}>
{inputs.children.element}
</AppRouter>
),
};
},
});
/**
* 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<SignInPageProps>;
appIdentityProxy: AppIdentityProxy;
children: ReactNode;
}) {
const [identityApi, setIdentityApi] = useState<IdentityApi>();
const configApi = useApi(configApiRef);
const basePath = getBasePath(configApi);
if (!identityApi) {
return <Component onSignInSuccess={setIdentityApi} />;
}
appIdentityProxy.setTarget(identityApi, {
signOutTargetUrl: basePath || '/',
});
return <>{children}</>;
}
/**
* Props for the {@link AppRouter} component.
* @public
*/
export interface AppRouterProps {
children?: ReactNode;
SignInPageComponent?: ComponentType<SignInPageProps>;
}
/**
* 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 <BrowserRouter basename={basePath}>{children}</BrowserRouter>;
}
return (
<BrowserRouter basename={basePath}>
<SignInPageWrapper
component={SignInPageComponent}
appIdentityProxy={appIdentityProxy}
>
{children}
</SignInPageWrapper>
</BrowserRouter>
);
}
@@ -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<unknown>[]) {
});
const tree = createAppTree({
config: new MockConfigApi({}),
builtinExtensions: [Core, CoreRoutes, CoreNav, CoreLayout],
builtinExtensions: [Core, CoreRoutes, CoreNav, CoreLayout, CoreRouter],
features: [plugin],
});
@@ -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);
@@ -127,18 +127,22 @@ describe('createApp', () => {
expect(String(tree.root)).toMatchInlineSnapshot(`
"<core out=[core.reactElement]>
root [
<core.layout out=[core.reactElement]>
content [
<core.routes out=[core.reactElement]>
routes [
<plugin.my-plugin.page out=[core.routing.path, core.routing.ref, core.reactElement] />
<core.router out=[core.reactElement]>
children [
<core.layout out=[core.reactElement]>
content [
<core.routes out=[core.reactElement]>
routes [
<plugin.my-plugin.page out=[core.routing.path, core.routing.ref, core.reactElement] />
]
</core.routes>
]
</core.routes>
nav [
<core.nav out=[core.reactElement] />
]
</core.layout>
]
nav [
<core.nav out=[core.reactElement] />
]
</core.layout>
</core.router>
]
themes [
<themes.light out=[core.theme] />
@@ -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?: {
<AppContextProvider appContext={appContext}>
<AppThemeProvider>
<RoutingProvider {...routeInfo} routeBindings={routeBindings}>
{/* TODO: set base path using the logic from AppRouter */}
<BrowserRouter>{rootEl}</BrowserRouter>
<InternalAppContext.Provider value={{ appIdentityProxy }}>
{rootEl}
</InternalAppContext.Provider>
</RoutingProvider>
</AppThemeProvider>
</AppContextProvider>
@@ -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', {
@@ -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<TOutput, TInput>(
schemaCreator: (zImpl: typeof z) => ZodSchema<TOutput, ZodTypeDef, TInput>,
): PortableSchema<TOutput>;
// @public (undocumented)
export function createSignInPageExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
>(options: {
id: string;
attachTo?: {
id: string;
input: string;
};
configSchema?: PortableSchema<TConfig>;
disabled?: boolean;
inputs?: TInputs;
loader: (options: {
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<ComponentType<SignInPageProps>>;
}): Extension<TConfig>;
// @public
export function createSubRouteRef<
Path extends string,
@@ -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 () => () => <div data-testid="sign-in-page" />,
});
createExtensionTester(
createExtension({
id: 'dummy',
attachTo: { id: 'ignored', input: 'ignored' },
output: {
element: coreExtensionData.reactElement,
},
factory: () => ({ element: <div /> }),
}),
)
.add(SignInPage)
.render();
await expect(
screen.findByTestId('sign-in-page'),
).resolves.toBeInTheDocument();
});
});
@@ -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<ComponentType<SignInPageProps>>('core.signInPage');
/**
*
* @public
*/
export function createSignInPageExtension<
TConfig extends {},
TInputs extends AnyExtensionInputMap,
>(options: {
id: string;
attachTo?: { id: string; input: string };
configSchema?: PortableSchema<TConfig>;
disabled?: boolean;
inputs?: TInputs;
loader: (options: {
config: TConfig;
inputs: Expand<ExtensionInputValues<TInputs>>;
}) => Promise<ComponentType<SignInPageProps>>;
}): Extension<TConfig> {
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 => (
<ExtensionBoundary id={id} source={source} routable>
<ExtensionComponent {...props} />
</ExtensionBoundary>
),
};
},
});
}
@@ -17,4 +17,5 @@
export { createApiExtension } from './createApiExtension';
export { createPageExtension } from './createPageExtension';
export { createNavItemExtension } from './createNavItemExtension';
export { createSignInPageExtension } from './createSignInPageExtension';
export { createThemeExtension } from './createThemeExtension';
@@ -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' },
@@ -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'",
);
});
});
@@ -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,
},