Skip to content

Commit e2d623d

Browse files
committed
feat(app): dynamic authentication provider support
This change adds support for loading authentication providers or modules from dynamic plugins. An environment variable ENABLE_AUTH_PROVIDER_MODULE_OVERRIDE controls whether or not the backend installs the default authentication provider module. When this override is enabled dynamic plugins can be used to supply custom authentication providers. This change also adds a "components" configuration for frontend dynamic plugins, which can be used to supply overrides for the AppComponents option. This is required for dynamic plugins to be able to provide a custom SignInPage component, for example: ``` dynamicPlugins: frontend: my-plugin-package: components: - name: SignInPage module: PluginRoot importName: SignInPage ``` Where the named export SignInPage will be mapped to `components.SignInPage` when the frontend is initialized. Finally, to ensure authentication providers can be managed by the user a new `providerSettings` configuration field is available for frontend dynamic plugins, which can be used to inform the user settings page of the new provider, for example: ```yaml dynamicPlugins: frontend: my-plugin-package: providerSettings: - title: Github Two description: Sign in with GitHub Org Two provider: github-two ``` Each `providerSettings` item will be turned into a new row under the "Authentication Providers" tab in the user settings. The `provider` field is used to look up the API ref for the external auth provider by expanding the simple provider name to the auth ID convention `core.auth.<provider name>`. Signed-off-by: Stan Lewis <[email protected]>
1 parent f733e20 commit e2d623d

File tree

9 files changed

+188
-18
lines changed

9 files changed

+188
-18
lines changed

packages/app/src/components/AppBase/AppBase.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const AppBase = () => {
3636
AppRouter,
3737
dynamicRoutes,
3838
entityTabOverrides,
39+
providerSettings,
3940
scaffolderFieldExtensions,
4041
} = useContext(DynamicRootContext);
4142

@@ -122,7 +123,7 @@ const AppBase = () => {
122123
<SearchPage />
123124
</Route>
124125
<Route path="/settings" element={<UserSettingsPage />}>
125-
{settingsPage}
126+
{settingsPage(providerSettings)}
126127
</Route>
127128
<Route path="/catalog-graph" element={<CatalogGraphPage />} />
128129
<Route path="/learning-paths" element={<LearningPaths />} />

packages/app/src/components/DynamicRoot/DynamicRoot.tsx

+71-13
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,14 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
33

44
import { createApp } from '@backstage/app-defaults';
55
import { BackstageApp } from '@backstage/core-app-api';
6-
import { AnyApiFactory, BackstagePlugin } from '@backstage/core-plugin-api';
6+
import {
7+
AnyApiFactory,
8+
ApiRef,
9+
AppComponents,
10+
BackstagePlugin,
11+
ProfileInfoApi,
12+
SessionApi,
13+
} from '@backstage/core-plugin-api';
714

815
import { useThemes } from '@redhat-developer/red-hat-developer-hub-theme';
916
import { AppsConfig } from '@scalprum/core';
@@ -24,6 +31,7 @@ import DynamicRootContext, {
2431
ComponentRegistry,
2532
EntityTabOverrides,
2633
MountPoints,
34+
ProviderSetting,
2735
RemotePlugins,
2836
ResolvedDynamicRoute,
2937
ResolvedDynamicRouteMenuItem,
@@ -61,7 +69,9 @@ export const DynamicRoot = ({
6169
React.ComponentType | undefined
6270
>(undefined);
6371
// registry of remote components loaded at bootstrap
64-
const [components, setComponents] = useState<ComponentRegistry | undefined>();
72+
const [componentRegistry, setComponentRegistry] = useState<
73+
ComponentRegistry | undefined
74+
>();
6575
const { initialized, pluginStore, api: scalprumApi } = useScalprum();
6676

6777
const themes = useThemes();
@@ -72,10 +82,12 @@ export const DynamicRoot = ({
7282
pluginModules,
7383
apiFactories,
7484
appIcons,
85+
components,
7586
dynamicRoutes,
7687
menuItems,
7788
entityTabs,
7889
mountPoints,
90+
providerSettings: providerSettingsConfig,
7991
routeBindings,
8092
routeBindingTargets,
8193
scaffolderFieldExtensions,
@@ -86,6 +98,10 @@ export const DynamicRoot = ({
8698
scope,
8799
module,
88100
})),
101+
...components.map(({ scope, module }) => ({
102+
scope,
103+
module,
104+
})),
89105
...routeBindingTargets.map(({ scope, module }) => ({
90106
scope,
91107
module,
@@ -172,6 +188,23 @@ export const DynamicRoot = ({
172188
),
173189
);
174190

191+
const appComponents = components.reduce<Partial<AppComponents>>(
192+
(componentMap, { scope, module, importName, name }) => {
193+
if (typeof allPlugins[scope]?.[module]?.[importName] !== 'undefined') {
194+
componentMap[name] = allPlugins[scope]?.[module]?.[
195+
importName
196+
] as React.ComponentType<any>;
197+
} else {
198+
// eslint-disable-next-line no-console
199+
console.warn(
200+
`Plugin ${scope} is not configured properly: ${module}.${importName} not found, ignoring AppComponent: ${name}`,
201+
);
202+
}
203+
return componentMap;
204+
},
205+
{},
206+
);
207+
175208
let icons = Object.fromEntries(
176209
appIcons.reduce<[string, React.ComponentType<{}>][]>(
177210
(acc, { scope, module, importName, name }) => {
@@ -386,6 +419,31 @@ export const DynamicRoot = ({
386419
},
387420
[],
388421
);
422+
const filteredStaticApis = staticApis.filter(
423+
api => !remoteApis.some(remoteApi => remoteApi.api.id === api.api.id),
424+
);
425+
const apis = [...filteredStaticApis, ...remoteApis];
426+
const providerSettings: ProviderSetting[] = providerSettingsConfig
427+
.map(({ title, description, provider }) => {
428+
try {
429+
return {
430+
title,
431+
description,
432+
apiRef: apis
433+
.filter(api => api.api.id === `core.auth.${provider}`)
434+
.pop()!.api as ApiRef<ProfileInfoApi & SessionApi>,
435+
};
436+
} catch (err) {
437+
// eslint-disable-next-line no-console
438+
console.warn(
439+
'Could not find auth provider ',
440+
provider,
441+
' this auth provider user settings will be unavailable',
442+
);
443+
return undefined;
444+
}
445+
})
446+
.filter(item => item !== undefined);
389447

390448
if (!app.current) {
391449
const filteredStaticThemes = themes.filter(
@@ -394,11 +452,8 @@ export const DynamicRoot = ({
394452
dynamicTheme => dynamicTheme.id === theme.id,
395453
),
396454
);
397-
const filteredStaticApis = staticApis.filter(
398-
api => !remoteApis.some(remoteApi => remoteApi.api.id === api.api.id),
399-
);
400455
app.current = createApp({
401-
apis: [...filteredStaticApis, ...remoteApis],
456+
apis,
402457
bindRoutes({ bind }) {
403458
bindAppRoutes(bind, resolvedRouteBindingTargets, routeBindings);
404459
},
@@ -408,7 +463,10 @@ export const DynamicRoot = ({
408463
...remoteBackstagePlugins,
409464
],
410465
themes: [...filteredStaticThemes, ...dynamicThemeProviders],
411-
components: defaultAppComponents,
466+
components: {
467+
...defaultAppComponents,
468+
...appComponents,
469+
} as Partial<AppComponents>,
412470
});
413471
}
414472

@@ -424,16 +482,16 @@ export const DynamicRoot = ({
424482
scaffolderFieldExtensionComponents;
425483

426484
// make the dynamic UI configuration available to DynamicRootContext consumers
427-
setComponents({
485+
setComponentRegistry({
428486
AppProvider: app.current.getProvider(),
429487
AppRouter: app.current.getRouter(),
430488
dynamicRoutes: dynamicRoutesComponents,
431489
menuItems: dynamicRoutesMenuItems,
432490
entityTabOverrides,
433491
mountPoints: mountPointComponents,
492+
providerSettings,
434493
scaffolderFieldExtensions: scaffolderFieldExtensionComponents,
435494
});
436-
437495
afterInit().then(({ default: Component }) => {
438496
setChildComponent(() => Component);
439497
});
@@ -449,17 +507,17 @@ export const DynamicRoot = ({
449507
]);
450508

451509
useEffect(() => {
452-
if (initialized && !components) {
510+
if (initialized && !componentRegistry) {
453511
initializeRemoteModules();
454512
}
455-
}, [initialized, components, initializeRemoteModules]);
513+
}, [initialized, componentRegistry, initializeRemoteModules]);
456514

457-
if (!initialized || !components) {
515+
if (!initialized || !componentRegistry) {
458516
return <Loader />;
459517
}
460518

461519
return (
462-
<DynamicRootContext.Provider value={components}>
520+
<DynamicRootContext.Provider value={componentRegistry}>
463521
{ChildComponent ? <ChildComponent /> : <Loader />}
464522
</DynamicRootContext.Provider>
465523
);

packages/app/src/components/DynamicRoot/DynamicRootContext.tsx

+11
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@ import React, { createContext } from 'react';
33
import { Entity } from '@backstage/catalog-model';
44
import {
55
AnyApiFactory,
6+
ApiRef,
67
AppTheme,
78
BackstagePlugin,
9+
ProfileInfoApi,
10+
SessionApi,
811
} from '@backstage/core-plugin-api';
912

1013
import { ScalprumComponentProps } from '@scalprum/react-core';
@@ -112,11 +115,18 @@ export type ScaffolderFieldExtension = {
112115
Component: React.ComponentType<{}>;
113116
};
114117

118+
export type ProviderSetting = {
119+
title: string;
120+
description: string;
121+
apiRef: ApiRef<ProfileInfoApi & SessionApi>;
122+
};
123+
115124
export type DynamicRootConfig = {
116125
dynamicRoutes: ResolvedDynamicRoute[];
117126
entityTabOverrides: EntityTabOverrides;
118127
mountPoints: MountPoints;
119128
menuItems: ResolvedMenuItem[];
129+
providerSettings: ProviderSetting[];
120130
scaffolderFieldExtensions: ScaffolderFieldExtension[];
121131
};
122132

@@ -132,6 +142,7 @@ const DynamicRootContext = createContext<ComponentRegistry>({
132142
entityTabOverrides: {},
133143
mountPoints: {},
134144
menuItems: [],
145+
providerSettings: [],
135146
scaffolderFieldExtensions: [],
136147
});
137148

packages/app/src/components/DynamicRoot/ScalprumRoot.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ const ScalprumRoot = ({
7777
entityTabOverrides: {},
7878
mountPoints: {},
7979
scaffolderFieldExtensions: [],
80+
providerSettings: [],
8081
} as DynamicRootConfig,
8182
};
8283
return (
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,43 @@
1+
import { ErrorBoundary } from '@backstage/core-components';
2+
import { configApiRef, useApi } from '@backstage/core-plugin-api';
13
import {
4+
DefaultProviderSettings,
5+
ProviderSettingsItem,
26
SettingsLayout,
37
UserSettingsAuthProviders,
48
} from '@backstage/plugin-user-settings';
59

10+
import Star from '@mui/icons-material/Star';
11+
12+
import { ProviderSetting } from '../DynamicRoot/DynamicRootContext';
613
import { GeneralPage } from './GeneralPage';
714

8-
export const settingsPage = (
15+
export const DynamicProviderSettings = ({
16+
providerSettings,
17+
}: {
18+
providerSettings: ProviderSetting[];
19+
}) => {
20+
const configApi = useApi(configApiRef);
21+
const providersConfig = configApi.getOptionalConfig('auth.providers');
22+
const configuredProviders = providersConfig?.keys() || [];
23+
return (
24+
<>
25+
<DefaultProviderSettings configuredProviders={configuredProviders} />
26+
{providerSettings.map(({ title, description, apiRef }) => (
27+
<ErrorBoundary>
28+
<ProviderSettingsItem
29+
title={title}
30+
description={description}
31+
apiRef={apiRef}
32+
icon={Star}
33+
/>
34+
</ErrorBoundary>
35+
))}
36+
</>
37+
);
38+
};
39+
40+
export const settingsPage = (providerSettings: ProviderSetting[]) => (
941
<SettingsLayout>
1042
<SettingsLayout.Route path="general" title="General">
1143
<GeneralPage />
@@ -14,7 +46,11 @@ export const settingsPage = (
1446
path="auth-providers"
1547
title="Authentication Providers"
1648
>
17-
<UserSettingsAuthProviders />
49+
<UserSettingsAuthProviders
50+
providerSettings={
51+
<DynamicProviderSettings providerSettings={providerSettings} />
52+
}
53+
/>
1854
</SettingsLayout.Route>
1955
</SettingsLayout>
2056
);

packages/app/src/utils/dynamicUI/extractDynamicConfig.test.ts

+18
Original file line numberDiff line numberDiff line change
@@ -148,12 +148,14 @@ describe('extractDynamicConfig', () => {
148148
const config = extractDynamicConfig(source as DynamicPluginConfig);
149149
expect(config).toEqual({
150150
pluginModules: [],
151+
components: [],
151152
routeBindings: [],
152153
dynamicRoutes: [],
153154
entityTabs: [],
154155
menuItems: [],
155156
mountPoints: [],
156157
appIcons: [],
158+
providerSettings: [],
157159
routeBindingTargets: [],
158160
apiFactories: [],
159161
scaffolderFieldExtensions: [],
@@ -162,6 +164,20 @@ describe('extractDynamicConfig', () => {
162164
});
163165

164166
it.each([
167+
[
168+
'a component',
169+
{ components: [{ name: 'foo', importName: 'blah' }] },
170+
{
171+
components: [
172+
{
173+
importName: 'blah',
174+
module: 'PluginRoot',
175+
name: 'foo',
176+
scope: 'janus-idp.plugin-foo',
177+
},
178+
],
179+
},
180+
],
165181
[
166182
'a dynamicRoute',
167183
{ dynamicRoutes: [{ path: '/foo' }] },
@@ -506,6 +522,7 @@ describe('extractDynamicConfig', () => {
506522
scope: 'janus-idp.plugin-foo',
507523
},
508524
],
525+
components: [],
509526
routeBindings: [],
510527
routeBindingTargets: [],
511528
dynamicRoutes: [],
@@ -516,6 +533,7 @@ describe('extractDynamicConfig', () => {
516533
apiFactories: [],
517534
scaffolderFieldExtensions: [],
518535
themes: [],
536+
providerSettings: [],
519537
...output,
520538
});
521539
});

0 commit comments

Comments
 (0)