frontend-defaults: add createPublicSignInApp

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-08-29 12:40:55 +02:00
parent 7c80650a1e
commit 7d19cd56bd
14 changed files with 290 additions and 72 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-defaults': patch
---
Added a new `CreateAppOptions` type for the `createApp` options.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-defaults': patch
---
Added `createPublicSignInApp`, used to creating apps for the public entry point.
+4 -32
View File
@@ -106,40 +106,12 @@ That's it!
If your app uses the new frontend system, you can still use the public entry point feature. The `index-public-experimental.tsx` file does end up looking a bit different in this case:
```tsx title="in packages/app/src/index-public-experimental.tsx"
import React from 'react';
import ReactDOM from 'react-dom/client';
import { CookieAuthRedirect } from '@backstage/plugin-auth-react';
import { createApp } from '@backstage/frontend-app-api';
import {
coreExtensionData,
createExtension,
createExtensionOverrides,
createSignInPageExtension,
} from '@backstage/frontend-plugin-api';
import { signInPageModule } from './overrides/SignInPage';
import { createPublicSignInApp } from '@backstage/frontend-defaults';
const signInPage = createSignInPageExtension({
name: 'guest',
loader: async () => props => <SignInPage {...props} providers={['guest']} />,
});
const authRedirectExtension = createExtension({
namespace: 'app',
name: 'layout',
attachTo: { id: 'app/root', input: 'children' },
output: {
element: coreExtensionData.reactElement,
},
factory: () => ({
element: <CookieAuthRedirect />,
}),
});
const app = createApp({
features: [
createExtensionOverrides({
extensions: [signInPage, authRedirectExtension],
}),
],
const app = createPublicSignInApp({
features: [signInPageModule],
});
ReactDOM.createRoot(document.getElementById('root')!).render(app.createRoot());
+1
View File
@@ -21,6 +21,7 @@
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/frontend-app-api": "workspace:^",
"@backstage/frontend-defaults": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/integration-react": "workspace:^",
"@backstage/plugin-api-docs": "workspace:^",
+2 -2
View File
@@ -48,7 +48,7 @@ import {
scmIntegrationsApiRef,
} from '@backstage/integration-react';
import kubernetesPlugin from '@backstage/plugin-kubernetes/alpha';
import { signInPageOverrides } from './overrides/SignInPage';
import { signInPageModule } from './overrides/SignInPage';
import { convertLegacyPlugin } from '@backstage/core-compat-api';
import { convertLegacyPageExtension } from '@backstage/core-compat-api';
import { convertLegacyEntityContentExtension } from '@backstage/plugin-catalog-react/alpha';
@@ -154,7 +154,7 @@ const app = createApp({
homePlugin,
appVisualizerPlugin,
kubernetesPlugin,
signInPageOverrides,
signInPageModule,
scmModule,
notFoundErrorPageModule,
customHomePageModule,
@@ -14,32 +14,12 @@
* limitations under the License.
*/
import React from 'react';
import ReactDOM from 'react-dom/client';
import { CookieAuthRedirect } from '@backstage/plugin-auth-react';
import { createApp } from '@backstage/frontend-app-api';
import { signInPageOverrides } from './overrides/SignInPage';
import {
coreExtensionData,
createExtension,
createExtensionOverrides,
} from '@backstage/frontend-plugin-api';
import { signInPageModule } from './overrides/SignInPage';
import { createPublicSignInApp } from '@backstage/frontend-defaults';
const authRedirectExtension = createExtension({
namespace: 'app',
name: 'layout',
attachTo: { id: 'app/root', input: 'children' },
output: [coreExtensionData.reactElement],
factory: () => [coreExtensionData.reactElement(<CookieAuthRedirect />)],
});
const app = createApp({
features: [
signInPageOverrides,
createExtensionOverrides({
extensions: [authRedirectExtension],
}),
],
const app = createPublicSignInApp({
features: [signInPageModule],
});
ReactDOM.createRoot(document.getElementById('root')!).render(app.createRoot());
@@ -18,7 +18,7 @@ import React from 'react';
import { SignInPage } from '@backstage/core-components';
import {
SignInPageBlueprint,
createExtensionOverrides,
createFrontendModule,
} from '@backstage/frontend-plugin-api';
const signInPage = SignInPageBlueprint.make({
@@ -29,6 +29,7 @@ const signInPage = SignInPageBlueprint.make({
},
});
export const signInPageOverrides = createExtensionOverrides({
export const signInPageModule = createFrontendModule({
pluginId: 'app',
extensions: [signInPage],
});
+20 -8
View File
@@ -7,17 +7,11 @@ import { ConfigApi } from '@backstage/frontend-plugin-api';
import { CreateAppRouteBinder } from '@backstage/frontend-app-api';
import { FrontendFeature } from '@backstage/frontend-app-api';
import { JSX as JSX_2 } from 'react';
import { default as React_2 } from 'react';
import { ReactNode } from 'react';
// @public
export function createApp(options?: {
features?: (FrontendFeature | CreateAppFeatureLoader)[];
configLoader?: () => Promise<{
config: ConfigApi;
}>;
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
loadingComponent?: ReactNode;
}): {
export function createApp(options?: CreateAppOptions): {
createRoot(): JSX_2.Element;
};
@@ -28,4 +22,22 @@ export interface CreateAppFeatureLoader {
features: FrontendFeature[];
}>;
}
// @public
export interface CreateAppOptions {
// (undocumented)
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
// (undocumented)
configLoader?: () => Promise<{
config: ConfigApi;
}>;
// (undocumented)
features?: (FrontendFeature | CreateAppFeatureLoader)[];
loadingComponent?: ReactNode;
}
// @public
export function createPublicSignInApp(options?: CreateAppOptions): {
createRoot(): React_2.JSX.Element;
};
```
+1
View File
@@ -43,6 +43,7 @@
"@backstage/frontend-app-api": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/plugin-app": "workspace:^",
"@react-hookz/web": "^24.0.0",
"@types/react": "^16.13.1 || ^17.0.0 || ^18.0.0"
},
"peerDependencies": {
+10 -3
View File
@@ -50,11 +50,11 @@ export interface CreateAppFeatureLoader {
}
/**
* Creates a new Backstage frontend app instance. See https://backstage.io/docs/frontend-system/building-apps/index
* Options for {@link createApp}.
*
* @public
*/
export function createApp(options?: {
export interface CreateAppOptions {
features?: (FrontendFeature | CreateAppFeatureLoader)[];
configLoader?: () => Promise<{ config: ConfigApi }>;
bindRoutes?(context: { bind: CreateAppRouteBinder }): void;
@@ -65,7 +65,14 @@ export function createApp(options?: {
* If set to "null" then no loading fallback component is rendered. *
*/
loadingComponent?: ReactNode;
}): {
}
/**
* Creates a new Backstage frontend app instance. See https://backstage.io/docs/frontend-system/building-apps/index
*
* @public
*/
export function createApp(options?: CreateAppOptions): {
createRoot(): JSX.Element;
} {
let suspenseFallback = options?.loadingComponent;
@@ -0,0 +1,119 @@
/*
* Copyright 2024 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 {
IdentityApi,
SignInPageBlueprint,
createFrontendModule,
} from '@backstage/frontend-plugin-api';
import { render, screen, waitFor } from '@testing-library/react';
import React, { useEffect } from 'react';
import { createPublicSignInApp } from './createPublicSignInApp';
import { MockConfigApi } from '@backstage/test-utils';
describe('createPublicSignInApp', () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('should render a sign-in page', async () => {
const app = createPublicSignInApp({
configLoader: async () => ({ config: new MockConfigApi({}) }),
features: [
createFrontendModule({
pluginId: 'app',
extensions: [
SignInPageBlueprint.make({
params: {
loader: async () => () => <div>Sign in page</div>,
},
}),
],
}),
],
});
render(app.createRoot());
await expect(
screen.findByText('Sign in page'),
).resolves.toBeInTheDocument();
});
it('should render the form redirect on sign-in', async () => {
const submitSpy = jest
.spyOn(HTMLFormElement.prototype, 'submit')
.mockReturnValue();
const app = createPublicSignInApp({
configLoader: async () => ({ config: new MockConfigApi({}) }),
features: [
createFrontendModule({
pluginId: 'app',
extensions: [
SignInPageBlueprint.make({
params: {
loader:
async () =>
({ onSignInSuccess }) => {
useEffect(() => {
onSignInSuccess({
getCredentials: async () => ({ token: 'mock-token' }),
} as IdentityApi);
}, [onSignInSuccess]);
return <div />;
},
},
}),
],
}),
],
});
const { baseElement } = render(app.createRoot());
await waitFor(() => {
expect(submitSpy).toHaveBeenCalled();
});
expect(baseElement).toMatchInlineSnapshot(`
<body>
<div>
<form
action="http://localhost/"
method="POST"
style="visibility: hidden;"
>
<input
name="type"
type="hidden"
value="sign-in"
/>
<input
name="token"
type="hidden"
value="mock-token"
/>
<input
type="submit"
value="Continue"
/>
</form>
</div>
</body>
`);
});
});
@@ -0,0 +1,108 @@
/*
* Copyright 2024 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 {
coreExtensionData,
createFrontendModule,
identityApiRef,
useApi,
} from '@backstage/frontend-plugin-api';
import React from 'react';
import { useAsync, useMountEffect } from '@react-hookz/web';
import { CreateAppOptions, createApp } from './createApp';
import appPlugin from '@backstage/plugin-app';
// This is a copy of the CookieAuthRedirect component from the auth-react
// plugin, to avoid a dependency on that package. Long-term we want this to be
// the only implementation and remove the one in auth-react once the old frontend system is gone.
// TODO(Rugvip): Should this be part of the app plugin instead? since it owns the backend part of it.
/** @internal */
export function InternalCookieAuthRedirect() {
const identityApi = useApi(identityApiRef);
const [state, actions] = useAsync(async () => {
const { token } = await identityApi.getCredentials();
if (!token) {
throw new Error('Expected Backstage token in sign-in response');
}
return token;
});
useMountEffect(actions.execute);
if (state.status === 'error' && state.error) {
return <>An error occurred: {state.error.message}</>;
}
if (state.status === 'success' && state.result) {
return (
<form
ref={form => form?.submit()}
action={window.location.href}
method="POST"
style={{ visibility: 'hidden' }}
>
<input type="hidden" name="type" value="sign-in" />
<input type="hidden" name="token" value={state.result} />
<input type="submit" value="Continue" />
</form>
);
}
return null;
}
/**
* Creates an app that is suitable for the public sign-in page, for use in the `index-public-experimental.tsx` file.
*
* @remarks
*
* This app has an override for the `app/layout` extension, which means that
* most extension typically installed in an app will be ignored. However, you
* can still for example install API and root element extensions.
*
* A typical setup of this app will only install a custom sign-in page.
*
* @example
* ```ts
* const app = createPublicSignInApp({
* features: [signInPageModule],
* });
* ```
*
* @public
*/
export function createPublicSignInApp(options?: CreateAppOptions) {
return createApp({
...options,
features: [
...(options?.features ?? []),
// This is a rather than app plugin override in order for it to take precedence over any supplied app plugin override
createFrontendModule({
pluginId: 'app',
extensions: [
appPlugin.getExtension('app/layout').override({
factory: () => [
coreExtensionData.reactElement(<InternalCookieAuthRedirect />),
],
}),
],
}),
],
});
}
+6 -1
View File
@@ -20,4 +20,9 @@
* @packageDocumentation
*/
export { createApp, type CreateAppFeatureLoader } from './createApp';
export {
createApp,
type CreateAppOptions,
type CreateAppFeatureLoader,
} from './createApp';
export { createPublicSignInApp } from './createPublicSignInApp';
+2
View File
@@ -4456,6 +4456,7 @@ __metadata:
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/plugin-app": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@react-hookz/web": ^24.0.0
"@testing-library/jest-dom": ^6.0.0
"@testing-library/react": ^15.0.0
"@types/react": ^16.13.1 || ^17.0.0 || ^18.0.0
@@ -26627,6 +26628,7 @@ __metadata:
"@backstage/core-components": "workspace:^"
"@backstage/core-plugin-api": "workspace:^"
"@backstage/frontend-app-api": "workspace:^"
"@backstage/frontend-defaults": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/integration-react": "workspace:^"
"@backstage/plugin-api-docs": "workspace:^"