diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index b4d01e067b..c18169ae03 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -24,6 +24,18 @@ import apis from './apis'; const app = createApp({ apis, plugins: Object.values(plugins), + configLoader: async () => ({ + app: { + title: 'Backstage Example App', + baseUrl: 'http://localhost:3000', + }, + backend: { + baseUrl: 'http://localhost:7000', + }, + organization: { + name: 'Spotify', + }, + }), }); const AppProvider = app.getProvider(); diff --git a/packages/core-api/src/apis/definitions/ConfigApi.ts b/packages/core-api/src/apis/definitions/ConfigApi.ts new file mode 100644 index 0000000000..20676df899 --- /dev/null +++ b/packages/core-api/src/apis/definitions/ConfigApi.ts @@ -0,0 +1,38 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { createApiRef } from '../ApiRef'; + +export type Config = { + getConfig(key: string): Config; + + getConfigArray(key: string): Config[]; + + getNumber(key: string): number | undefined; + + getBoolean(key: string): boolean | undefined; + + getString(key: string): string | undefined; + + getStringArray(key: string): string[] | undefined; +}; + +// Using interface to make the ConfigApi name show up in docs +export interface ConfigApi extends Config {} + +export const configApiRef = createApiRef({ + id: 'core.config', + description: 'Used to access runtime configuration', +}); diff --git a/packages/core-api/src/apis/definitions/index.ts b/packages/core-api/src/apis/definitions/index.ts index 2e008965cd..2e9db325dc 100644 --- a/packages/core-api/src/apis/definitions/index.ts +++ b/packages/core-api/src/apis/definitions/index.ts @@ -24,6 +24,7 @@ export * from './auth'; export * from './AlertApi'; export * from './AppThemeApi'; +export * from './ConfigApi'; export * from './ErrorApi'; export * from './FeatureFlagsApi'; export * from './OAuthRequestApi'; diff --git a/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.test.ts b/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.test.ts new file mode 100644 index 0000000000..68c1fd5353 --- /dev/null +++ b/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.test.ts @@ -0,0 +1,226 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { ConfigReader } from './ConfigReader'; + +const DATA = { + zero: 0, + one: 1, + true: true, + false: false, + null: null, + string: 'string', + emptyString: '', + strings: ['string1', 'string2'], + badStrings: ['string1', ''], + worseStrings: ['string1', 3] as string[], + worstStrings: ['string1', 'string2', {}] as string[], + nested: { + one: 1, + string: 'string', + strings: ['string1', 'string2'], + }, + nestlings: [{ boolean: true }, { string: 'string' }, { number: 42 }] as {}[], +}; + +function expectValidValues(config: ConfigReader) { + expect(config.getNumber('zero')).toBe(0); + expect(config.getNumber('one')).toBe(1); + expect(config.getBoolean('true')).toBe(true); + expect(config.getBoolean('false')).toBe(false); + expect(config.getString('string')).toBe('string'); + expect(config.getStringArray('strings')).toEqual(['string1', 'string2']); + expect(config.getConfig('nested').getNumber('one')).toBe(1); + expect(config.getConfig('nested').getString('string')).toBe('string'); + expect(config.getConfig('nested').getStringArray('strings')).toEqual([ + 'string1', + 'string2', + ]); + + const [config1, config2, config3] = config.getConfigArray('nestlings'); + expect(config1.getBoolean('boolean')).toBe(true); + expect(config2.getString('string')).toBe('string'); + expect(config3.getNumber('number')).toBe(42); +} + +function expectInvalidValues(config: ConfigReader) { + expect(() => config.getNumber('string')).toThrow( + 'Invalid type in config for key string, got string, wanted number', + ); + expect(() => config.getString('one')).toThrow( + 'Invalid type in config for key one, got number, wanted string', + ); + expect(() => config.getNumber('true')).toThrow( + 'Invalid type in config for key true, got boolean, wanted number', + ); + expect(() => config.getStringArray('null')).toThrow( + 'Invalid type in config for key null, got null, wanted string-array', + ); + expect(() => config.getString('emptyString')).toThrow( + 'Invalid type in config for key emptyString, got empty-string, wanted string', + ); + expect(() => config.getStringArray('badStrings')).toThrow( + 'Invalid type in config for key badStrings[1], got empty-string, wanted string', + ); + expect(() => config.getStringArray('worseStrings')).toThrow( + 'Invalid type in config for key worseStrings[1], got number, wanted string', + ); + expect(() => config.getStringArray('worstStrings')).toThrow( + 'Invalid type in config for key worstStrings[2], got object, wanted string', + ); + expect(() => config.getConfig('one')).toThrow( + 'Invalid type in config for key one, got number, wanted object', + ); + expect(() => config.getConfigArray('one')).toThrow( + 'Invalid type in config for key one, got number, wanted object-array', + ); +} + +describe('ConfigReader', () => { + it('should read empty config with valid keys', () => { + const config = new ConfigReader({}); + expect(config.getString('x')).toBeUndefined(); + expect(config.getString('x_x')).toBeUndefined(); + expect(config.getString('x-X')).toBeUndefined(); + expect(config.getString('x0')).toBeUndefined(); + expect(config.getString('X-x2')).toBeUndefined(); + expect(config.getString('x0_x0')).toBeUndefined(); + expect(config.getString('x_x-x_x')).toBeUndefined(); + }); + + it('should throw on invalid keys', () => { + const config = new ConfigReader({}); + + expect(() => config.getString('.')).toThrow(/^Invalid config key/); + expect(() => config.getString('0')).toThrow(/^Invalid config key/); + expect(() => config.getString('(')).toThrow(/^Invalid config key/); + expect(() => config.getString('z-_')).toThrow(/^Invalid config key/); + expect(() => config.getString('-')).toThrow(/^Invalid config key/); + expect(() => config.getString('.a')).toThrow(/^Invalid config key/); + expect(() => config.getString('0.a')).toThrow(/^Invalid config key/); + expect(() => config.getString('0a')).toThrow(/^Invalid config key/); + expect(() => config.getString('a.0a')).toThrow(/^Invalid config key/); + expect(() => config.getString('a..a')).toThrow(/^Invalid config key/); + expect(() => config.getString('a.')).toThrow(/^Invalid config key/); + expect(() => config.getString('a...')).toThrow(/^Invalid config key/); + expect(() => config.getString('a.a.a.a.')).toThrow(/^Invalid config key/); + expect(() => config.getString('a._')).toThrow(/^Invalid config key/); + expect(() => config.getString('a.-.a')).toThrow(/^Invalid config key/); + }); + + it('should read valid values', () => { + const config = new ConfigReader(DATA); + expectValidValues(config); + }); + + it('should fail to read invalid values', () => { + const config = new ConfigReader(DATA); + expectInvalidValues(config); + }); +}); + +describe('ConfigReader with fallback', () => { + it('should behave as if without fallback', () => { + const config = new ConfigReader({}, new ConfigReader(DATA)); + expect(config.getString('x')).toBeUndefined(); + expect(() => config.getString('.')).toThrow(/^Invalid config key/); + expect(() => config.getString('a.')).toThrow(/^Invalid config key/); + }); + + it('should read values from itself', () => { + const config = new ConfigReader(DATA, new ConfigReader({})); + expectValidValues(config); + expectInvalidValues(config); + }); + + it('should read values from a fallback', () => { + const config = new ConfigReader({}, new ConfigReader(DATA)); + expectValidValues(config); + expectInvalidValues(config); + }); + + it('should read values from multiple levels of fallbacks', () => { + const config = new ConfigReader( + {}, + new ConfigReader({}, new ConfigReader({}, new ConfigReader(DATA))), + ); + expectValidValues(config); + expectInvalidValues(config); + }); + + it('should read merged objects', () => { + const a = { + merged: { + x: 'x', + z: 'z1', + arr: ['a', 'b'], + config: { d: 'd' }, + configs: [{ a: 'a' }], + }, + }; + const b = { + merged: { + y: 'y', + z: 'z2', + arr: ['c'], + config: { e: 'e' }, + configs: [{ b: 'b' }], + }, + }; + + const config = new ConfigReader(a, new ConfigReader(b)); + + expect(config.getString('merged.x')).toBe('x'); + expect(config.getString('merged.y')).toBe('y'); + expect(config.getString('merged.z')).toBe('z1'); + expect(config.getConfig('merged').getString('x')).toBe('x'); + expect(config.getConfig('merged').getString('y')).toBe('y'); + expect(config.getConfig('merged').getString('z')).toBe('z1'); + expect(config.getString('merged.config.d')).toBe('d'); + expect(config.getString('merged.config.e')).toBe('e'); + expect(config.getConfig('merged').getString('config.d')).toBe('d'); + expect(config.getConfig('merged').getString('config.e')).toBe('e'); + expect(config.getConfig('merged').getConfig('config').getString('d')).toBe( + 'd', + ); + expect(config.getConfig('merged').getConfig('config').getString('e')).toBe( + 'e', + ); + + // Arrays are not merged + expect(config.getStringArray('merged.arr')).toEqual(['a', 'b']); + expect(config.getConfig('merged').getStringArray('arr')).toEqual([ + 'a', + 'b', + ]); + + // Config arrays aren't merged either + expect(config.getConfigArray('merged.configs').length).toBe(1); + expect(config.getConfigArray('merged.configs')[0].getString('a')).toBe('a'); + expect( + config.getConfigArray('merged.configs')[0].getString('b'), + ).toBeUndefined(); + + // Config arrays aren't merged either + expect(config.getConfig('merged').getConfigArray('configs').length).toBe(1); + expect( + config.getConfig('merged').getConfigArray('configs')[0].getString('a'), + ).toBe('a'); + expect( + config.getConfig('merged').getConfigArray('configs')[0].getString('b'), + ).toBeUndefined(); + }); +}); diff --git a/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.ts b/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.ts new file mode 100644 index 0000000000..7a5cf3b185 --- /dev/null +++ b/packages/core-api/src/apis/implementations/ConfigApi/ConfigReader.ts @@ -0,0 +1,168 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { ConfigApi, Config } from '../../definitions/ConfigApi'; + +const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i; + +type JsonObject = { [key in string]: JsonValue }; +type JsonArray = JsonValue[]; +type JsonValue = JsonObject | JsonArray | number | string | boolean | null; + +function isObject(value: JsonValue | undefined): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function typeOf(value: JsonValue | undefined): string { + if (value === null) { + return 'null'; + } else if (Array.isArray(value)) { + return 'array'; + } + const type = typeof value; + if (type === 'number' && isNaN(value as number)) { + return 'nan'; + } + return type; +} + +function typeErrorMessage(key: string, got: string, wanted: string) { + return `Invalid type in config for key ${key}, got ${got}, wanted ${wanted}`; +} + +function validateString( + key: string, + value: JsonValue | undefined, +): value is string { + if (typeof value === 'string' && value.length > 0) { + return true; + } + if (value === '') { + throw new TypeError(typeErrorMessage(key, 'empty-string', 'string')); + } + if (value !== undefined) { + throw new TypeError(typeErrorMessage(key, typeOf(value), 'string')); + } + return false; +} + +export class ConfigReader implements ConfigApi { + static nullReader = new ConfigReader({}); + + constructor( + private readonly data: JsonObject, + private readonly fallback?: ConfigApi, + ) {} + + getConfig(key: string): Config { + const value = this.readValue(key); + const fallbackConfig = this.fallback?.getConfig(key); + if (isObject(value)) { + return new ConfigReader(value, fallbackConfig); + } + if (value !== undefined) { + throw new TypeError(typeErrorMessage(key, typeOf(value), 'object')); + } + return fallbackConfig ?? ConfigReader.nullReader; + } + + getConfigArray(key: string): Config[] { + const values = this.readValue(key); + if (Array.isArray(values)) { + return values.map((value, index) => { + if (isObject(value)) { + return new ConfigReader(value); + } + throw new TypeError( + typeErrorMessage(`${key}[${index}]`, typeOf(value), 'object'), + ); + }); + } + if (values !== undefined) { + throw new TypeError( + typeErrorMessage(key, typeOf(values), 'object-array'), + ); + } + return this.fallback?.getConfigArray(key) ?? []; + } + + getNumber(key: string): number | undefined { + const value = this.readValue(key); + if (typeof value === 'number' && !isNaN(value)) { + return value; + } + if (value !== undefined) { + throw new TypeError(typeErrorMessage(key, typeOf(value), 'number')); + } + return this.fallback?.getNumber(key); + } + + getBoolean(key: string): boolean | undefined { + const value = this.readValue(key); + if (typeof value === 'boolean') { + return value; + } + if (value !== undefined) { + throw new TypeError(typeErrorMessage(key, typeOf(value), 'boolean')); + } + return this.fallback?.getBoolean(key); + } + + getString(key: string): string | undefined { + const value = this.readValue(key); + if (validateString(key, value)) { + return value; + } + return this.fallback?.getString(key); + } + + getStringArray(key: string): string[] | undefined { + const values = this.readValue(key); + if (Array.isArray(values)) { + for (const [index, value] of values.entries()) { + const iKey = `${key}[${index}]`; + if (!validateString(iKey, value)) { + throw new TypeError(typeErrorMessage(iKey, typeOf(value), 'string')); + } + } + return values as string[]; + } + if (values !== undefined) { + throw new TypeError( + typeErrorMessage(key, typeOf(values), 'string-array'), + ); + } + return this.fallback?.getStringArray(key); + } + + private readValue(key: string): JsonValue | undefined { + const parts = key.split('.'); + + let value: JsonValue | undefined = this.data; + for (const part of parts) { + if (!CONFIG_KEY_PART_PATTERN.test(part)) { + throw new TypeError(`Invalid config key '${key}'`); + } + if (isObject(value)) { + value = value[part]; + } else { + value = undefined; + } + } + + return value; + } +} diff --git a/packages/core-api/src/apis/implementations/ConfigApi/index.ts b/packages/core-api/src/apis/implementations/ConfigApi/index.ts new file mode 100644 index 0000000000..8839cb948e --- /dev/null +++ b/packages/core-api/src/apis/implementations/ConfigApi/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * 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. + */ + +export { ConfigReader } from './ConfigReader'; diff --git a/packages/core-api/src/apis/implementations/index.ts b/packages/core-api/src/apis/implementations/index.ts index bb77cf5bd3..b5cc250ae4 100644 --- a/packages/core-api/src/apis/implementations/index.ts +++ b/packages/core-api/src/apis/implementations/index.ts @@ -22,5 +22,6 @@ export * from './auth'; export * from './AlertApi'; export * from './AppThemeApi'; +export * from './ConfigApi'; export * from './ErrorApi'; export * from './OAuthRequestApi'; diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index 844c38ecce..49d2291acd 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -17,7 +17,7 @@ import React, { ComponentType, FC } from 'react'; import { Route, Switch, Redirect } from 'react-router-dom'; import { AppContextProvider } from './AppContext'; -import { BackstageApp, AppComponents } from './types'; +import { BackstageApp, AppComponents, AppConfigLoader } from './types'; import { BackstagePlugin } from '../plugin'; import { FeatureFlagsRegistryItem } from './FeatureFlags'; import { featureFlagsApiRef } from '../apis/definitions'; @@ -31,8 +31,11 @@ import { AppTheme, AppThemeSelector, appThemeApiRef, + configApiRef, + ConfigReader, } from '../apis'; import { ApiAggregator } from '../apis/ApiAggregator'; +import { useAsync } from 'react-use'; type FullAppOptions = { apis: ApiHolder; @@ -40,6 +43,7 @@ type FullAppOptions = { plugins: BackstagePlugin[]; components: AppComponents; themes: AppTheme[]; + configLoader: AppConfigLoader; }; export class PrivateAppImpl implements BackstageApp { @@ -48,6 +52,7 @@ export class PrivateAppImpl implements BackstageApp { private readonly plugins: BackstagePlugin[]; private readonly components: AppComponents; private readonly themes: AppTheme[]; + private readonly configLoader: AppConfigLoader; constructor(options: FullAppOptions) { this.apis = options.apis; @@ -55,6 +60,7 @@ export class PrivateAppImpl implements BackstageApp { this.plugins = options.plugins; this.components = options.components; this.themes = options.themes; + this.configLoader = options.configLoader; } getApis(): ApiHolder { @@ -141,18 +147,33 @@ export class PrivateAppImpl implements BackstageApp { } getProvider(): ComponentType<{}> { - const appApis = ApiRegistry.from([ - [appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)], - ]); - const apis = new ApiAggregator(this.apis, appApis); + const Provider: FC<{}> = ({ children }) => { + const config = useAsync(this.configLoader); - const Provider: FC<{}> = ({ children }) => ( - - - {children} - - - ); + let childNode = children; + + if (config.loading) { + const { Progress } = this.components; + childNode = ; + } else if (config.error) { + const { BootErrorPage } = this.components; + childNode = ; + } + + const appApis = ApiRegistry.from([ + [appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)], + [configApiRef, new ConfigReader(config.value ?? {})], + ]); + const apis = new ApiAggregator(this.apis, appApis); + + return ( + + + {childNode} + + + ); + }; return Provider; } diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index ea3a812557..defc155a82 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -20,10 +20,27 @@ import { BackstagePlugin } from '../plugin'; import { ApiHolder } from '../apis'; import { AppTheme } from '../apis/definitions'; +export type BootErrorPageProps = { + step: 'load-config'; + error: Error; +}; + export type AppComponents = { NotFoundErrorPage: ComponentType<{}>; + BootErrorPage: ComponentType; + Progress: ComponentType<{}>; }; +/** + * TBD + */ +export type AppConfig = any; + +/** + * A function that loads in the App config that will be accessible via the ConfigApi. + */ +export type AppConfigLoader = () => Promise; + export type AppOptions = { /** * A holder of all APIs available in the app. @@ -68,6 +85,17 @@ export type AppOptions = { * ``` */ themes?: AppTheme[]; + + /** + * A function that loads in App configuration that will be accessible via + * the ConfigApi. + * + * Defaults to an empty config. + * + * TODO(Rugvip): Omitting this should instead default to loading in configuration + * that was packaged by the backstage-cli and default docker container boot script. + */ + configLoader?: AppConfigLoader; }; export type BackstageApp = { diff --git a/packages/core/src/api-wrappers/createApp.tsx b/packages/core/src/api-wrappers/createApp.tsx index 03adc41ec6..7c605c4e18 100644 --- a/packages/core/src/api-wrappers/createApp.tsx +++ b/packages/core/src/api-wrappers/createApp.tsx @@ -14,14 +14,17 @@ * limitations under the License. */ -import React from 'react'; +import React, { FC } from 'react'; import privateExports, { AppOptions, ApiRegistry, defaultSystemIcons, + BootErrorPageProps, } from '@backstage/core-api'; +import { BrowserRouter as Router } from 'react-router-dom'; import { ErrorPage } from '../layout/ErrorPage'; +import Progress from '../components/Progress'; import { lightTheme, darkTheme } from '@backstage/theme'; const { PrivateAppImpl } = privateExports; @@ -38,12 +41,26 @@ export function createApp(options?: AppOptions) { const DefaultNotFoundPage = () => ( ); + const DefaultBootErrorPage: FC = ({ step, error }) => { + let message = ''; + if (step === 'load-config') { + message = `The configuration failed to load, someone should have a look at this error: ${error.message}`; + } + // TODO: figure out a nicer way to handle routing on the error page, when it can be done. + return ( + + + + ); + }; const apis = options?.apis ?? ApiRegistry.from([]); const icons = { ...defaultSystemIcons, ...options?.icons }; const plugins = options?.plugins ?? []; const components = { NotFoundErrorPage: DefaultNotFoundPage, + BootErrorPage: DefaultBootErrorPage, + Progress: Progress, ...options?.components, }; const themes = options?.themes ?? [ @@ -60,8 +77,16 @@ export function createApp(options?: AppOptions) { theme: darkTheme, }, ]; + const configLoader = options?.configLoader ?? (async () => ({})); - const app = new PrivateAppImpl({ apis, icons, plugins, components, themes }); + const app = new PrivateAppImpl({ + apis, + icons, + plugins, + components, + themes, + configLoader, + }); app.verify(); diff --git a/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx b/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx index 9cba76fddc..b639da9d50 100644 --- a/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx +++ b/plugins/welcome/src/components/WelcomePage/WelcomePage.test.tsx @@ -19,14 +19,23 @@ import { render } from '@testing-library/react'; import WelcomePage from './WelcomePage'; import { ThemeProvider } from '@material-ui/core'; import { lightTheme } from '@backstage/theme'; -import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; +import { + ApiProvider, + ApiRegistry, + errorApiRef, + configApiRef, + ConfigReader, +} from '@backstage/core'; describe('WelcomePage', () => { it('should render', () => { // TODO: use common test app with mock implementations of all core APIs const rendered = render( diff --git a/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx b/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx index 1036096ae5..6a1b0e0008 100644 --- a/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx +++ b/plugins/welcome/src/components/WelcomePage/WelcomePage.tsx @@ -34,15 +34,18 @@ import { ContentHeader, SupportButton, WarningPanel, + useApi, + configApiRef, } from '@backstage/core'; const WelcomePage: FC<{}> = () => { + const appTitle = useApi(configApiRef).getString('app.title') ?? 'Backstage'; const profile = { givenName: '' }; return (