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 (
+
+ );
+ }
+
+ 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:^"