From 7d19cd56bdcca3fe8425d20b950e65ddbe212f5e Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 29 Aug 2024 12:40:55 +0200 Subject: [PATCH] frontend-defaults: add createPublicSignInApp Signed-off-by: Patrik Oldsberg --- .changeset/gentle-hats-act.md | 5 + .changeset/plenty-dragons-know.md | 5 + docs/tutorials/enable-public-entry.md | 36 +----- packages/app-next/package.json | 1 + packages/app-next/src/App.tsx | 4 +- .../src/index-public-experimental.tsx | 28 +---- .../app-next/src/overrides/SignInPage.tsx | 5 +- packages/frontend-defaults/api-report.md | 28 +++-- packages/frontend-defaults/package.json | 1 + packages/frontend-defaults/src/createApp.tsx | 13 +- .../src/createPublicSignInApp.test.tsx | 119 ++++++++++++++++++ .../src/createPublicSignInApp.tsx | 108 ++++++++++++++++ packages/frontend-defaults/src/index.ts | 7 +- yarn.lock | 2 + 14 files changed, 290 insertions(+), 72 deletions(-) create mode 100644 .changeset/gentle-hats-act.md create mode 100644 .changeset/plenty-dragons-know.md create mode 100644 packages/frontend-defaults/src/createPublicSignInApp.test.tsx create mode 100644 packages/frontend-defaults/src/createPublicSignInApp.tsx diff --git a/.changeset/gentle-hats-act.md b/.changeset/gentle-hats-act.md new file mode 100644 index 0000000000..e78076c9c5 --- /dev/null +++ b/.changeset/gentle-hats-act.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-defaults': patch +--- + +Added a new `CreateAppOptions` type for the `createApp` options. diff --git a/.changeset/plenty-dragons-know.md b/.changeset/plenty-dragons-know.md new file mode 100644 index 0000000000..2aa8458011 --- /dev/null +++ b/.changeset/plenty-dragons-know.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-defaults': patch +--- + +Added `createPublicSignInApp`, used to creating apps for the public entry point. diff --git a/docs/tutorials/enable-public-entry.md b/docs/tutorials/enable-public-entry.md index 25d7382f13..1ba4983618 100644 --- a/docs/tutorials/enable-public-entry.md +++ b/docs/tutorials/enable-public-entry.md @@ -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 => , -}); - -const authRedirectExtension = createExtension({ - namespace: 'app', - name: 'layout', - attachTo: { id: 'app/root', input: 'children' }, - output: { - element: coreExtensionData.reactElement, - }, - factory: () => ({ - element: , - }), -}); - -const app = createApp({ - features: [ - createExtensionOverrides({ - extensions: [signInPage, authRedirectExtension], - }), - ], +const app = createPublicSignInApp({ + features: [signInPageModule], }); ReactDOM.createRoot(document.getElementById('root')!).render(app.createRoot()); diff --git a/packages/app-next/package.json b/packages/app-next/package.json index 8d44e0a0b3..e77c1f4f5f 100644 --- a/packages/app-next/package.json +++ b/packages/app-next/package.json @@ -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:^", diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx index 618bc79ca5..0d686cd5a5 100644 --- a/packages/app-next/src/App.tsx +++ b/packages/app-next/src/App.tsx @@ -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, diff --git a/packages/app-next/src/index-public-experimental.tsx b/packages/app-next/src/index-public-experimental.tsx index 27b2af7d91..f258a660e3 100644 --- a/packages/app-next/src/index-public-experimental.tsx +++ b/packages/app-next/src/index-public-experimental.tsx @@ -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()], -}); - -const app = createApp({ - features: [ - signInPageOverrides, - createExtensionOverrides({ - extensions: [authRedirectExtension], - }), - ], +const app = createPublicSignInApp({ + features: [signInPageModule], }); ReactDOM.createRoot(document.getElementById('root')!).render(app.createRoot()); diff --git a/packages/app-next/src/overrides/SignInPage.tsx b/packages/app-next/src/overrides/SignInPage.tsx index 52dd01fcde..f1e37a241b 100644 --- a/packages/app-next/src/overrides/SignInPage.tsx +++ b/packages/app-next/src/overrides/SignInPage.tsx @@ -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], }); diff --git a/packages/frontend-defaults/api-report.md b/packages/frontend-defaults/api-report.md index f1ef153e43..b99232b784 100644 --- a/packages/frontend-defaults/api-report.md +++ b/packages/frontend-defaults/api-report.md @@ -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; +}; ``` diff --git a/packages/frontend-defaults/package.json b/packages/frontend-defaults/package.json index c70bb42424..745c19e298 100644 --- a/packages/frontend-defaults/package.json +++ b/packages/frontend-defaults/package.json @@ -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": { diff --git a/packages/frontend-defaults/src/createApp.tsx b/packages/frontend-defaults/src/createApp.tsx index 688e01534a..79c9eb4a16 100644 --- a/packages/frontend-defaults/src/createApp.tsx +++ b/packages/frontend-defaults/src/createApp.tsx @@ -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; diff --git a/packages/frontend-defaults/src/createPublicSignInApp.test.tsx b/packages/frontend-defaults/src/createPublicSignInApp.test.tsx new file mode 100644 index 0000000000..ac1adf9b0f --- /dev/null +++ b/packages/frontend-defaults/src/createPublicSignInApp.test.tsx @@ -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 () => () =>
Sign in page
, + }, + }), + ], + }), + ], + }); + + 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
; + }, + }, + }), + ], + }), + ], + }); + + const { baseElement } = render(app.createRoot()); + + await waitFor(() => { + expect(submitSpy).toHaveBeenCalled(); + }); + + expect(baseElement).toMatchInlineSnapshot(` + +
+
+ + + +
+
+ + `); + }); +}); diff --git a/packages/frontend-defaults/src/createPublicSignInApp.tsx b/packages/frontend-defaults/src/createPublicSignInApp.tsx new file mode 100644 index 0000000000..a8fd456c73 --- /dev/null +++ b/packages/frontend-defaults/src/createPublicSignInApp.tsx @@ -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?.submit()} + action={window.location.href} + method="POST" + style={{ visibility: 'hidden' }} + > + + + +
+ ); + } + + 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(), + ], + }), + ], + }), + ], + }); +} diff --git a/packages/frontend-defaults/src/index.ts b/packages/frontend-defaults/src/index.ts index 913cd3934b..7ee60a2116 100644 --- a/packages/frontend-defaults/src/index.ts +++ b/packages/frontend-defaults/src/index.ts @@ -20,4 +20,9 @@ * @packageDocumentation */ -export { createApp, type CreateAppFeatureLoader } from './createApp'; +export { + createApp, + type CreateAppOptions, + type CreateAppFeatureLoader, +} from './createApp'; +export { createPublicSignInApp } from './createPublicSignInApp'; diff --git a/yarn.lock b/yarn.lock index 22062e72bf..450f6e53de 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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:^"