frontend-app-api: working sign-in page

Co-authored-by: Camila Belo <camilaibs@gmail.com>
Co-authored-by: Fredrik Adelöw <freben@gmail.com>
Co-authored-by: Vincenzo Scamporlino <vincenzos@spotify.com>
Co-authored-by: Philipp Hugenroth <philipph@spotify.com>
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2023-11-15 14:48:52 +01:00
parent 297e9a5d80
commit c79d9b6e78
4 changed files with 230 additions and 36 deletions
+55 -3
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React from 'react';
import React, { ComponentType } from 'react';
import { createApp } from '@backstage/frontend-app-api';
import { pagesPlugin } from './examples/pagesPlugin';
import graphiqlPlugin from '@backstage/plugin-graphiql/alpha';
@@ -29,6 +29,7 @@ import {
createExtension,
createApiExtension,
createExtensionOverrides,
createExtensionDataRef,
} from '@backstage/frontend-plugin-api';
import techdocsPlugin from '@backstage/plugin-techdocs/alpha';
import { homePage } from './HomePage';
@@ -36,12 +37,17 @@ 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 Button from '@material-ui/core/Button';
/*
@@ -84,6 +90,47 @@ const homePageExtension = createExtension({
},
});
const signInPageComponentDataRef =
createExtensionDataRef<ComponentType<SignInPageProps>>('core.signInPage');
const signInPage = createExtension({
id: 'signInPage',
attachTo: { id: 'core', input: 'signInPage' },
output: {
component: signInPageComponentDataRef,
},
factory() {
return {
component: (props: SignInPageProps) => (
<div>
<h1>Sign in page</h1>
<div>
<Button
onClick={() =>
props.onSignInSuccess({
getProfileInfo: async () => ({
email: 'guest@example.com',
displayName: 'Guest',
}),
getBackstageIdentity: async () => ({
type: 'user',
userEntityRef: 'user:default/guest',
ownershipEntityRefs: ['user:default/guest'],
}),
getCredentials: async () => ({}),
signOut: async () => {},
})
}
>
Sign in
</Button>
</div>
</div>
),
};
},
});
const scmAuthExtension = createApiExtension({
factory: ScmAuth.createDefaultApiFactory(),
});
@@ -112,7 +159,12 @@ const app = createApp({
homePlugin,
...collectedLegacyPlugins,
createExtensionOverrides({
extensions: [homePageExtension, scmAuthExtension, scmIntegrationApi],
extensions: [
homePageExtension,
scmAuthExtension,
scmIntegrationApi,
signInPage,
],
}),
],
/* Handled through config instead */
@@ -14,11 +14,24 @@
* limitations under the License.
*/
import React, { ComponentType, ReactNode, useContext, useState } from 'react';
import {
coreExtensionData,
createExtension,
createExtensionDataRef,
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';
export const Core = createExtension({
id: 'core',
@@ -30,6 +43,15 @@ export const Core = createExtension({
themes: createExtensionInput({
theme: coreExtensionData.theme,
}),
signInPage: createExtensionInput(
{
component:
createExtensionDataRef<ComponentType<SignInPageProps>>(
'core.signInPage',
),
},
{ singleton: true, optional: true },
),
root: createExtensionInput(
{
element: coreExtensionData.reactElement,
@@ -42,7 +64,120 @@ export const Core = createExtension({
},
factory({ inputs }) {
return {
root: inputs.root.element,
root: (
<AppRouter SignInPageComponent={inputs.signInPage?.component}>
{inputs.root.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>
);
}
@@ -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);
@@ -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,6 +91,7 @@ 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';
const builtinExtensions = [
Core,
@@ -299,7 +300,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 +315,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 +353,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 +386,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', {