diff --git a/packages/app/config.d.ts b/packages/app/config.d.ts index 468e075846..56b41d945f 100644 --- a/packages/app/config.d.ts +++ b/packages/app/config.d.ts @@ -143,6 +143,36 @@ export interface Config { module?: string; importName?: string; }[]; + providerSettings?: { + title: string; + description: string; + provider: string; + }[]; + scaffolderFieldExtensions?: { + module?: string; + importName?: string; + }[]; + signInPage?: { + module?: string; + importName: string; + }; + techdocsAddons?: { + module?: string; + importName?: string; + config?: { + props?: { + [key: string]: string; + }; + }; + }[]; + themes?: { + module?: string; + id: string; + title: string; + variant: 'light' | 'dark'; + icon: string; + importName?: string; + }[]; }; }; }; diff --git a/packages/app/src/components/AppBase/AppBase.tsx b/packages/app/src/components/AppBase/AppBase.tsx index bc3d8f2a71..94b51293c7 100644 --- a/packages/app/src/components/AppBase/AppBase.tsx +++ b/packages/app/src/components/AppBase/AppBase.tsx @@ -37,6 +37,7 @@ const AppBase = () => { AppRouter, dynamicRoutes, entityTabOverrides, + providerSettings, scaffolderFieldExtensions, } = useContext(DynamicRootContext); @@ -123,7 +124,7 @@ const AppBase = () => { }> - {settingsPage} + {settingsPage(providerSettings)} } /> } /> diff --git a/packages/app/src/components/DynamicRoot/DynamicRoot.tsx b/packages/app/src/components/DynamicRoot/DynamicRoot.tsx index 1b603f769e..e26b34a3b5 100644 --- a/packages/app/src/components/DynamicRoot/DynamicRoot.tsx +++ b/packages/app/src/components/DynamicRoot/DynamicRoot.tsx @@ -3,7 +3,11 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { createApp } from '@backstage/app-defaults'; import { BackstageApp } from '@backstage/core-app-api'; -import { AnyApiFactory, BackstagePlugin } from '@backstage/core-plugin-api'; +import { + AnyApiFactory, + AppComponents, + BackstagePlugin, +} from '@backstage/core-plugin-api'; import { useThemes } from '@redhat-developer/red-hat-developer-hub-theme'; import { AppsConfig } from '@scalprum/core'; @@ -63,7 +67,9 @@ export const DynamicRoot = ({ React.ComponentType | undefined >(undefined); // registry of remote components loaded at bootstrap - const [components, setComponents] = useState(); + const [componentRegistry, setComponentRegistry] = useState< + ComponentRegistry | undefined + >(); const { initialized, pluginStore, api: scalprumApi } = useScalprum(); const themes = useThemes(); @@ -78,11 +84,13 @@ export const DynamicRoot = ({ menuItems, entityTabs, mountPoints, + providerSettings, routeBindings, routeBindingTargets, scaffolderFieldExtensions, techdocsAddons, themes: pluginThemes, + signInPages, } = extractDynamicConfig(dynamicPlugins); const requiredModules = [ ...pluginModules.map(({ scope, module }) => ({ @@ -117,6 +125,10 @@ export const DynamicRoot = ({ scope, module, })), + ...signInPages.map(({ scope, module }) => ({ + scope, + module, + })), ]; const staticPlugins = Object.keys(staticPluginStore).reduce( @@ -416,6 +428,25 @@ export const DynamicRoot = ({ [], ); + // the config allows for multiple sign-in pages, discover and use the first + // working instance but check all of them + const signInPage = signInPages + .map | undefined>( + ({ scope, module, importName }) => { + const candidate = allPlugins[scope]?.[module]?.[ + importName + ] as React.ComponentType<{}>; + if (!candidate) { + // eslint-disable-next-line no-console + console.warn( + `Plugin ${scope} is not configured properly: ${module}.${importName} not found, ignoring SignInPage: ${importName}`, + ); + } + return candidate; + }, + ) + .find(candidate => candidate !== undefined); + if (!app.current) { const filteredStaticThemes = themes.filter( theme => @@ -437,7 +468,12 @@ export const DynamicRoot = ({ ...remoteBackstagePlugins, ], themes: [...filteredStaticThemes, ...dynamicThemeProviders], - components: defaultAppComponents, + components: { + ...defaultAppComponents, + ...(signInPage && { + SignInPage: signInPage, + }), + } as Partial, }); } @@ -454,17 +490,17 @@ export const DynamicRoot = ({ dynamicRootConfig.techdocsAddons = techdocsAddonComponents; // make the dynamic UI configuration available to DynamicRootContext consumers - setComponents({ + setComponentRegistry({ AppProvider: app.current.getProvider(), AppRouter: app.current.getRouter(), dynamicRoutes: dynamicRoutesComponents, menuItems: dynamicRoutesMenuItems, entityTabOverrides, mountPoints: mountPointComponents, + providerSettings, scaffolderFieldExtensions: scaffolderFieldExtensionComponents, techdocsAddons: techdocsAddonComponents, }); - afterInit().then(({ default: Component }) => { setChildComponent(() => Component); }); @@ -480,17 +516,17 @@ export const DynamicRoot = ({ ]); useEffect(() => { - if (initialized && !components) { + if (initialized && !componentRegistry) { initializeRemoteModules(); } - }, [initialized, components, initializeRemoteModules]); + }, [initialized, componentRegistry, initializeRemoteModules]); - if (!initialized || !components) { + if (!initialized || !componentRegistry) { return ; } return ( - + {ChildComponent ? : } ); diff --git a/packages/app/src/components/DynamicRoot/DynamicRootContext.tsx b/packages/app/src/components/DynamicRoot/DynamicRootContext.tsx index 5a1d6157f3..b8d04adc15 100644 --- a/packages/app/src/components/DynamicRoot/DynamicRootContext.tsx +++ b/packages/app/src/components/DynamicRoot/DynamicRootContext.tsx @@ -128,11 +128,18 @@ export type TechdocsAddon = { }; }; +export type ProviderSetting = { + title: string; + description: string; + provider: string; +}; + export type DynamicRootConfig = { dynamicRoutes: ResolvedDynamicRoute[]; entityTabOverrides: EntityTabOverrides; mountPoints: MountPoints; menuItems: ResolvedMenuItem[]; + providerSettings: ProviderSetting[]; scaffolderFieldExtensions: ScaffolderFieldExtension[]; techdocsAddons: TechdocsAddon[]; }; @@ -149,6 +156,7 @@ const DynamicRootContext = createContext({ entityTabOverrides: {}, mountPoints: {}, menuItems: [], + providerSettings: [], scaffolderFieldExtensions: [], techdocsAddons: [], }); diff --git a/packages/app/src/components/DynamicRoot/ScalprumRoot.tsx b/packages/app/src/components/DynamicRoot/ScalprumRoot.tsx index 7bc7888f84..d3c8ec95d8 100644 --- a/packages/app/src/components/DynamicRoot/ScalprumRoot.tsx +++ b/packages/app/src/components/DynamicRoot/ScalprumRoot.tsx @@ -78,6 +78,7 @@ const ScalprumRoot = ({ mountPoints: {}, scaffolderFieldExtensions: [], techdocsAddons: [], + providerSettings: [], } as DynamicRootConfig, }; return ( diff --git a/packages/app/src/components/UserSettings/SettingsPages.tsx b/packages/app/src/components/UserSettings/SettingsPages.tsx index 2d04ae111e..dc63a09873 100644 --- a/packages/app/src/components/UserSettings/SettingsPages.tsx +++ b/packages/app/src/components/UserSettings/SettingsPages.tsx @@ -1,11 +1,83 @@ +import { ErrorBoundary } from '@backstage/core-components'; import { + AnyApiFactory, + ApiRef, + configApiRef, + ProfileInfoApi, + SessionApi, + useApi, + useApp, +} from '@backstage/core-plugin-api'; +import { + DefaultProviderSettings, + ProviderSettingsItem, SettingsLayout, UserSettingsAuthProviders, } from '@backstage/plugin-user-settings'; +import Star from '@mui/icons-material/Star'; + +import { ProviderSetting } from '../DynamicRoot/DynamicRootContext'; import { GeneralPage } from './GeneralPage'; -export const settingsPage = ( +const DynamicProviderSettingsItem = ({ + title, + description, + provider, +}: { + title: string; + description: string; + provider: string; +}) => { + const app = useApp(); + // The provider API needs to be registered with the app + const apiRef = app + .getPlugins() + .flatMap(plugin => Array.from(plugin.getApis())) + .filter((api: AnyApiFactory) => api.api.id === provider) + .at(0)?.api; + if (!apiRef) { + // eslint-disable-next-line no-console + console.warn( + `No API factory found for provider ref "${provider}", hiding the related provider settings UI`, + ); + return <>; + } + return ( + } + icon={Star} + /> + ); +}; + +const DynamicProviderSettings = ({ + providerSettings, +}: { + providerSettings: ProviderSetting[]; +}) => { + const configApi = useApi(configApiRef); + const providersConfig = configApi.getOptionalConfig('auth.providers'); + const configuredProviders = providersConfig?.keys() || []; + return ( + <> + + {providerSettings.map(({ title, description, provider }) => ( + + + + ))} + + ); +}; + +export const settingsPage = (providerSettings: ProviderSetting[]) => ( @@ -14,7 +86,11 @@ export const settingsPage = ( path="auth-providers" title="Authentication Providers" > - + + } + /> ); diff --git a/packages/app/src/utils/dynamicUI/extractDynamicConfig.test.ts b/packages/app/src/utils/dynamicUI/extractDynamicConfig.test.ts index 31f89d4251..688f84ba25 100644 --- a/packages/app/src/utils/dynamicUI/extractDynamicConfig.test.ts +++ b/packages/app/src/utils/dynamicUI/extractDynamicConfig.test.ts @@ -154,15 +154,30 @@ describe('extractDynamicConfig', () => { menuItems: [], mountPoints: [], appIcons: [], + providerSettings: [], routeBindingTargets: [], apiFactories: [], scaffolderFieldExtensions: [], + signInPages: [], techdocsAddons: [], themes: [], }); }); it.each([ + [ + 'a SignInPage', + { signInPage: { importName: 'blah' } }, + { + signInPages: [ + { + importName: 'blah', + module: 'PluginRoot', + scope: 'janus-idp.plugin-foo', + }, + ], + }, + ], [ 'a dynamicRoute', { dynamicRoutes: [{ path: '/foo' }] }, @@ -496,6 +511,58 @@ describe('extractDynamicConfig', () => { ], }, ], + [ + 'a providerSettings', + { + providerSettings: [ + { + title: 'foo', + description: 'bar', + provider: 'foo.bar', + }, + ], + }, + { + providerSettings: [ + { + title: 'foo', + description: 'bar', + provider: 'foo.bar', + }, + ], + }, + ], + [ + 'multiple providerSettings', + { + providerSettings: [ + { + title: 'foo1', + description: 'bar1', + provider: 'foo.bar1', + }, + { + title: 'foo2', + description: 'bar2', + provider: 'foo.bar2', + }, + ], + }, + { + providerSettings: [ + { + title: 'foo1', + description: 'bar1', + provider: 'foo.bar1', + }, + { + title: 'foo2', + description: 'bar2', + provider: 'foo.bar2', + }, + ], + }, + ], [ 'a techdocs field extension', { @@ -569,8 +636,10 @@ describe('extractDynamicConfig', () => { appIcons: [], apiFactories: [], scaffolderFieldExtensions: [], + signInPages: [], techdocsAddons: [], themes: [], + providerSettings: [], ...output, }); }); diff --git a/packages/app/src/utils/dynamicUI/extractDynamicConfig.ts b/packages/app/src/utils/dynamicUI/extractDynamicConfig.ts index 370aa803c9..7569c8a5e4 100644 --- a/packages/app/src/utils/dynamicUI/extractDynamicConfig.ts +++ b/packages/app/src/utils/dynamicUI/extractDynamicConfig.ts @@ -125,6 +125,18 @@ type ThemeEntry = { importName: string; }; +type SignInPageEntry = { + scope: string; + module: string; + importName: string; +}; + +type ProviderSetting = { + title: string; + description: string; + provider: string; +}; + type CustomProperties = { pluginModule?: string; dynamicRoutes?: (DynamicModuleEntry & { @@ -142,7 +154,9 @@ type CustomProperties = { mountPoints?: MountPoint[]; appIcons?: AppIcon[]; apiFactories?: ApiFactory[]; + providerSettings?: ProviderSetting[]; scaffolderFieldExtensions?: ScaffolderFieldExtension[]; + signInPage: SignInPageEntry; techdocsAddons?: TechdocsAddon[]; themes?: ThemeEntry[]; }; @@ -163,9 +177,11 @@ type DynamicConfig = { menuItems: MenuItem[]; entityTabs: EntityTabEntry[]; mountPoints: MountPoint[]; + providerSettings: ProviderSetting[]; routeBindings: RouteBinding[]; routeBindingTargets: BindingTarget[]; scaffolderFieldExtensions: ScaffolderFieldExtension[]; + signInPages: SignInPageEntry[]; techdocsAddons: TechdocsAddon[]; themes: ThemeEntry[]; }; @@ -188,10 +204,30 @@ function extractDynamicConfig( mountPoints: [], routeBindings: [], routeBindingTargets: [], + providerSettings: [], scaffolderFieldExtensions: [], + signInPages: [], techdocsAddons: [], themes: [], }; + config.signInPages = Object.entries(frontend).reduce( + (pluginSet, [scope, { signInPage }]) => { + if (!signInPage) { + return pluginSet; + } + const { importName, module } = signInPage; + if (!importName) { + return pluginSet; + } + pluginSet.push({ + scope, + module: module ?? 'PluginRoot', + importName, + }); + return pluginSet; + }, + [], + ); config.pluginModules = Object.entries(frontend).reduce( (pluginSet, [scope, customProperties]) => { pluginSet.push({ @@ -330,6 +366,12 @@ function extractDynamicConfig( }, [], ); + config.providerSettings = Object.entries(frontend).reduce( + (accProviderSettings, [_, { providerSettings = [] }]) => { + return [...accProviderSettings, ...providerSettings]; + }, + [], + ); return config; } diff --git a/packages/app/src/utils/test/TestRoot.tsx b/packages/app/src/utils/test/TestRoot.tsx index 3d1709af7a..495d2d2146 100644 --- a/packages/app/src/utils/test/TestRoot.tsx +++ b/packages/app/src/utils/test/TestRoot.tsx @@ -42,6 +42,7 @@ const TestRoot = ({ children }: PropsWithChildren<{}>) => { mountPoints: {}, entityTabOverrides: {}, scaffolderFieldExtensions: [], + providerSettings: [], techdocsAddons: [], }), [current], diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 812796b107..31f0aa6414 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -91,7 +91,11 @@ backend.add(rbacDynamicPluginsProvider); backend.add(import('@backstage/plugin-auth-backend')); backend.add(import('@backstage/plugin-auth-backend-module-guest-provider')); -backend.add(import('./modules/authProvidersModule')); +if (process.env.ENABLE_AUTH_PROVIDER_MODULE_OVERRIDE !== 'true') { + backend.add(import('./modules/authProvidersModule')); +} else { + staticLogger.info(`Default authentication provider module disabled`); +} backend.add(import('@internal/plugin-dynamic-plugins-info-backend')); backend.add(import('@internal/plugin-scalprum-backend'));