Merge branch 'master' of github.com:spotify/backstage into shmidt-i/load-scaffolder-templates-from-api

This commit is contained in:
Ivan Shmidt
2020-06-24 23:14:32 +02:00
293 changed files with 5141 additions and 2208 deletions
+3 -1
View File
@@ -4,4 +4,6 @@
# The last matching pattern takes precedence.
# https://help.github.com/articles/about-codeowners/
* @spotify/backstage-core
* @spotify/backstage-core
/plugins/techdocs @spotify/techdocs-core
/packages/techdocs-cli @spotify/techdocs-core
+35
View File
@@ -0,0 +1,35 @@
name: TechDocs
on:
pull_request:
paths:
- '.github/workflows/techdocs.yml'
- 'packages/techdocs-cli/**'
- 'plugins/techdocs/**'
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest]
python-version: [3.7]
env:
TECHDOCS_CORE_PATH: ./plugins/techdocs/mkdocs/container/techdocs-core
name: Python ${{ matrix.node-version }} on ${{ matrix.os }}
steps:
- uses: actions/checkout@v2
# Lint Python code for techdocs-core package
- name: prepare python environment
run: |
python3 -m pip install --index-url https://pypi.org/simple/ setuptools
python3 -m pip install --upgrade pip
python3 -m pip install --index-url https://pypi.org/simple/ -r $TECHDOCS_CORE_PATH/requirements.txt
- name: lint techdocs-core package
run: |
python3 -m black --check $TECHDOCS_CORE_PATH/src
+3
View File
@@ -116,3 +116,6 @@ dist
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# Temporary change files created by Vim
*.swp
+3
View File
@@ -0,0 +1,3 @@
| Organization | Contact | Description of Use |
| ------------ | ------- | ------------------ |
| [Spotify](https://www.spotify.com) |[@alund](https://github.com/alund)| Main interface towards all of Spotify's infrastructure and technical documentation.|
+5
View File
@@ -4,6 +4,11 @@ app:
backend:
baseUrl: http://localhost:7000
listen: 0.0.0.0:7000
cors:
origin: http://localhost:3000
methods: [GET, POST, PUT, DELETE]
credentials: true
organization:
name: Spotify
+2 -10
View File
@@ -97,17 +97,9 @@ app and configures which plugins are available to use in the app. The
**software engineer** uses the app's functionality and interacts with its
plugins.
### What is a "plugin" in Backstage?
### What is the use of a "plugin" in Backstage?
By far, our most-used plugin is our TechDocs plugin, which we use for creating
technical documentation. Our philosophy at Spotify is to treat "docs like code",
where you write documentation using the same workflow as you write your code.
This makes it easier to create, find, and update documentation. We hope to
release
[the open source version](https://github.com/spotify/backstage/issues/687) in
the future. (See also:
"[Will Spotify's internal plugins be open sourced, too?](https://github.com/spotify/backstage/blob/master/docs/FAQ.md#what-is-a-plugin-in-backstage)"
above)
A Backstage Plugin adds functionality to Backstage.
### Do I have to write plugins in TypeScript?
+86
View File
@@ -0,0 +1,86 @@
# User Authentication and Authorization in Backstage
## Summary
The purpose of the Auth APIs in Backstage are to identify the user, and to
provide a way for plugins to request access to 3rd party services on behalf of
the user (OAuth). This documentation focuses on the implementation of that
solution and how to extend it. For documentation on how to consume the Auth APIs
in a plugin, see [TODO](#TODO).
### Accessing Third Party Services
The main pattern for talking to third party services in Backstage is
user-to-server requests, where short-lived OAuth Access Tokens are requested by
plugins to authenticate calls to external services. These calls can be made
either directly to the services or through a backend plugin or service.
By relying on user-to-server calls we keep the coupling between the frontend and
backend low, and provide a much lower barrier for plugins to make use of third
party services. This is in comparison to for example a session-based system,
where access tokens are stored server-side. Such a solution would require a much
deeper coupling between the auth backend plugin, its session storage, and other
backend plugins or separate services. A goal of Backstage is to make it as easy
as possible to create new plugins, and an auth solution based on user-to-server
OAuth helps in that regard.
The method with which frontend plugins request access to third party services is
through [Utility APIs](../getting-started/utility-apis.md) for each service
provider. For a full list of providers, see [TODO](#TODO).
### Identity - WIP
Identity management is still work in progress, but there are already a couple of
pieces in place that can be used.
#### Identity for Plugin Developers
As a plugin developer, there are two main touchpoints for identities: the
`IdentityApi` exported by `@backstage/core` via the `identityApiRef`, and a not
yet existing middleware exported by `@backstage/backend-common`.
The `IdentityApi` gives access to the signed-in user's identity in the frontend.
It provides access to the user's ID, lightweight profile information, and an ID
token used to make authenticated calls within Backstage.
The middleware that will be provided by `@backstage/backend-common` allows
verification of Backstage ID tokens, and optionally loading additional
information about the user. The progress is tracked in
https://github.com/spotify/backstage/issues/1435.
#### Identity for App Developers
If you're setting up your own Backstage app, or want to add a new identity
provider, there are three touchpoints: the frontend auth APIs in
`@backstage/core-api`, the backend auth providers in `auth-backend`, and the
`SignInPage` component configured in the Backstage app via `createApp`.
The frontend APIs and backend providers are tightly coupled together for each
auth provider, and together they implement an e2e auth flow. Only some auth
providers also act as identity providers though. For example, at the moment of
writing, the Google Auth provider is able to act as a Backstage identity
provider, but the GitHub one can not. For an auth provider to also act as an
identity provider, it needs to implement the `BackstageIdentityApi` in the
frontend, and in the backend it needs to return a `BackstageIdentity` structure.
It is up to each provider to implement the mapping between a provider identity
and the corresponding Backstage identity. That is currently still work in
progress, and as a stop-gap for example the Google provider returns the local
part of the user's email as the user ID.
The final piece of the puzzle is the `SignInPage` component that can be
configured as part of the app. Without a sign-in page, Backstage will fall back
to a `guest` identity for all users, without any ID token. To enable sign-in, a
`SignInPage` needs to be configured, which in turn has to supply a user to the
app. The `@backstage/core` package provides a basic sign-in page that allows
both the user and the app developer to choose between a couple of different
sign-in methods.
## Further Reading
More details are provided in dedicated sections of the documentation.
- [OAuth](./oauth): Description of the generic OAuth flow implemented by the
[auth-backend](../../plugins/auth-backend).
- [Glossary](./glossary): Glossary of some common terms related to the auth
flows.
-44
View File
@@ -1,44 +0,0 @@
# User Authentication and Authorization in Backstage
## Summary
The purpose of the Auth APIs in Backstage are to identify the user, and to
provide a way for plugins to request access to 3rd party services on behalf of
the user (OAuth). This documentation focuses on the implementation of that
solution and how to extend it. For documentation on how to consume the Auth APIs
in a plugin, see [TODO](#TODO).
### Accessing Third Party Services
The main pattern for talking to third party services in Backstage is
user-to-server requests, where short-lived OAuth Access Tokens are requested by
plugins to authenticate calls to external services. These calls can be made
either directly to the services or through a backend plugin or service.
By relying on user-to-server calls we keep the coupling between the frontend and
backend low, and provide a much lower barrier for plugins to make use of third
party services. This is in comparison to for example a session-based system,
where access tokens are stored server-side. Such a solution would require a much
deeper coupling between the auth backend plugin, its session storage, and other
backend plugins or separate services. A goal of Backstage is to make it as easy
as possible to create new plugins, and an auth solution based on user-to-server
OAuth helps in that regard.
The method with which frontend plugins request access to third party services is
through [Utility APIs](../getting-started/utility-apis.md) for each service
provider. For a full list of providers, see [TODO](#TODO).
### Identity - TODO
This documentation currently only covers the OAuth use-case, as identity
management is not settled yet and part of an
[upcoming milestone](https://github.com/spotify/backstage/milestone/12).
## Further Reading
More details are provided in dedicated sections of the documentation.
- [OAuth](./oauth): Description of the generic OAuth flow implemented by the
[auth-backend](../../plugins/auth-backend).
- [Glossary](./glossary): Glossary of some common terms related to the auth
flows.
+1 -1
View File
@@ -2,5 +2,5 @@
"packages": ["packages/*", "plugins/*"],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.1.1-alpha.9"
"version": "0.1.1-alpha.12"
}
+22 -17
View File
@@ -1,39 +1,44 @@
{
"name": "example-app",
"version": "0.1.1-alpha.9",
"version": "0.1.1-alpha.12",
"private": true,
"dependencies": {
"@backstage/cli": "^0.1.1-alpha.9",
"@backstage/core": "^0.1.1-alpha.9",
"@backstage/plugin-catalog": "^0.1.1-alpha.9",
"@backstage/plugin-circleci": "^0.1.1-alpha.9",
"@backstage/plugin-explore": "^0.1.1-alpha.9",
"@backstage/plugin-gitops-profiles": "^0.1.1-alpha.9",
"@backstage/plugin-lighthouse": "^0.1.1-alpha.9",
"@backstage/plugin-register-component": "^0.1.1-alpha.9",
"@backstage/plugin-scaffolder": "^0.1.1-alpha.9",
"@backstage/plugin-sentry": "^0.1.1-alpha.9",
"@backstage/plugin-tech-radar": "^0.1.1-alpha.9",
"@backstage/plugin-welcome": "^0.1.1-alpha.9",
"@backstage/theme": "^0.1.1-alpha.9",
"@backstage/cli": "^0.1.1-alpha.12",
"@backstage/core": "^0.1.1-alpha.12",
"@backstage/plugin-catalog": "^0.1.1-alpha.12",
"@backstage/plugin-circleci": "^0.1.1-alpha.12",
"@backstage/plugin-explore": "^0.1.1-alpha.12",
"@backstage/plugin-gitops-profiles": "^0.1.1-alpha.12",
"@backstage/plugin-graphiql": "^0.1.1-alpha.12",
"@backstage/plugin-lighthouse": "^0.1.1-alpha.12",
"@backstage/plugin-register-component": "^0.1.1-alpha.12",
"@backstage/plugin-scaffolder": "^0.1.1-alpha.12",
"@backstage/plugin-sentry": "^0.1.1-alpha.12",
"@backstage/plugin-tech-radar": "^0.1.1-alpha.12",
"@backstage/plugin-techdocs": "^0.1.1-alpha.12",
"@backstage/plugin-welcome": "^0.1.1-alpha.12",
"@backstage/test-utils": "^0.1.1-alpha.12",
"@backstage/theme": "^0.1.1-alpha.12",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"prop-types": "^15.7.2",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-hot-loader": "^4.12.21",
"react-router": "6.0.0-alpha.5",
"react-router-dom": "6.0.0-alpha.5",
"react-use": "^14.2.0",
"zen-observable": "^0.8.15"
},
"devDependencies": {
"@testing-library/cypress": "^6.0.0",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^25.2.2",
"@types/jquery": "^3.3.34",
"@types/node": "^12.0.0",
"@types/react-dom": "^16.9.8",
"@types/zen-observable": "^0.8.0",
"cross-env": "^7.0.0",
"cypress": "^4.2.0",
+14 -13
View File
@@ -15,23 +15,24 @@
*/
import React from 'react';
import { render } from '@testing-library/react';
import { renderWithEffects } from '@backstage/test-utils';
import App from './App';
describe('App', () => {
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
value: jest.fn(() => {
return {
matches: true,
addListener: jest.fn(),
removeListener: jest.fn(),
};
}),
it('should render', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: [
{
data: {
app: { title: 'Test' },
},
context: 'test',
},
],
});
});
it('should render', () => {
const rendered = render(<App />);
const rendered = await renderWithEffects(<App />);
expect(rendered.baseElement).toBeInTheDocument();
});
});
+22 -1
View File
@@ -45,6 +45,10 @@ import { CircleCIApi, circleCIApiRef } from '@backstage/plugin-circleci';
import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog';
import { gitOpsApiRef, GitOpsRestApi } from '@backstage/plugin-gitops-profiles';
import {
graphQlBrowseApiRef,
GraphQLEndpoints,
} from '@backstage/plugin-graphiql';
export const apis = (config: ConfigApi) => {
// eslint-disable-next-line no-console
@@ -78,7 +82,7 @@ export const apis = (config: ConfigApi) => {
}),
);
builder.add(
const githubAuthApi = builder.add(
githubAuthApiRef,
GithubAuth.create({
apiOrigin: 'http://localhost:7000',
@@ -105,5 +109,22 @@ export const apis = (config: ConfigApi) => {
builder.add(gitOpsApiRef, new GitOpsRestApi('http://localhost:3008'));
builder.add(
graphQlBrowseApiRef,
GraphQLEndpoints.from([
GraphQLEndpoints.create({
id: 'gitlab',
title: 'GitLab',
url: 'https://gitlab.com/api/graphql',
}),
GraphQLEndpoints.github({
id: 'github',
title: 'GitHub',
errorApi,
githubAuthApi,
}),
]),
);
return builder.build();
};
@@ -39,6 +39,7 @@ import {
SidebarPinButton,
} from '@backstage/core';
import { NavLink } from 'react-router-dom';
import { graphiQLRouteRef } from '@backstage/plugin-graphiql';
const useSidebarLogoStyles = makeStyles({
root: {
@@ -94,6 +95,11 @@ const Root: FC<{}> = ({ children }) => (
<SidebarItem icon={MapIcon} to="tech-radar" text="Tech Radar" />
<SidebarItem icon={RuleIcon} to="lighthouse" text="Lighthouse" />
<SidebarItem icon={BuildIcon} to="circleci" text="CircleCI" />
<SidebarItem
icon={graphiQLRouteRef.icon!}
to={graphiQLRouteRef.path}
text={graphiQLRouteRef.title}
/>
<SidebarSpace />
<SidebarDivider />
<SidebarThemeToggle />
+2
View File
@@ -14,6 +14,8 @@
* limitations under the License.
*/
// eslint-disable-next-line monorepo/no-internal-import
import '@backstage/cli/asset-types';
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
+2
View File
@@ -23,3 +23,5 @@ export { plugin as Circleci } from '@backstage/plugin-circleci';
export { plugin as RegisterComponent } from '@backstage/plugin-register-component';
export { plugin as Sentry } from '@backstage/plugin-sentry';
export { plugin as GitopsProfiles } from '@backstage/plugin-gitops-profiles';
export { plugin as TechDocs } from '@backstage/plugin-techdocs';
export { plugin as GraphiQL } from '@backstage/plugin-graphiql';
+3 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/backend-common",
"description": "Common functionality library for Backstage backends",
"version": "0.1.1-alpha.9",
"version": "0.1.1-alpha.12",
"main": "src/index.ts",
"types": "src/index.ts",
"private": false,
@@ -29,6 +29,7 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/config": "^0.1.1-alpha.12",
"@types/cors": "^2.8.6",
"@types/express": "^4.17.6",
"compression": "^1.7.4",
@@ -40,7 +41,7 @@
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.9",
"@backstage/cli": "^0.1.1-alpha.12",
"@types/compression": "^1.7.0",
"@types/http-errors": "^1.6.3",
"@types/morgan": "^1.9.0",
+69 -41
View File
@@ -14,10 +14,38 @@
* limitations under the License.
*/
// Find all active hot module APIs of all ancestors of a module, including the module itself
function findAllAncestors(_module: NodeModule): NodeModule[] {
const ancestors = new Array<NodeModule>();
const parentIds = new Set<string | number>();
function add(id: string | number, m: NodeModule) {
if (parentIds.has(id)) {
return;
}
parentIds.add(id);
ancestors.push(m);
for (const parentId of (m as any).parents) {
const parent = require.cache[parentId];
if (parent) {
add(parentId, parent);
}
}
}
add(_module.id, _module);
return ancestors;
}
/**
* This function allows devs to cleanup
* ongoing effects when module gets hot-reloaded
* useHotCleanup allows cleanup of ongoing effects when a module is
* hot-reloaded during development. The cleanup function will be called
* whenever the module itself or any of its parent modules is hot-reloaded.
*
* Useful for cleaning intervals, timers, requests etc
*
* @example
* ```ts
* const intervalId = setInterval(doStuff, 1000);
@@ -28,69 +56,69 @@
*/
export function useHotCleanup(_module: NodeModule, cancelEffect: () => void) {
if (_module.hot) {
_module.hot.addDisposeHandler(() => {
cancelEffect();
});
const ancestors = findAllAncestors(_module);
let cancelled = false;
const handler = () => {
if (!cancelled) {
cancelled = true;
cancelEffect();
}
};
for (const m of ancestors) {
m.hot?.addDisposeHandler(handler);
}
}
}
const CURRENT_HOT_MEMOIZE_INDEX_KEY = 'backstage.io/hmr-memoize-key';
/**
* This function allows devs to preserve
* some value between hot-reloads.
* Useful for stateful parts of the backend
* Memoizes a generated value across hot-module reloads. This is useful for
* stateful parts of the backend, e.g. to retain a database.
*
* @example
* ```ts
* const db = useHotMemoize(module, () => createDB(dbParams));
* ```
* @param _module Reference to the current module where you invoke the fn
* @param valueFactory Fn that returns the value you want to memoize
*
* @warning Don't use inside conditionals or loops,
* same rules as for hooks apply (https://reactjs.org/docs/hooks-rules.html)
*
* @param _module Reference to the current module where you invoke the fn
* @param valueFactory Fn that returns the value you want to memoize
*/
export function useHotMemoize<T>(
_module: NodeModule,
valueFactory: () => T,
): T {
const CURRENT_HOT_MEMOIZE_INDEX_KEY = 'backstage.io/hmr-memoize-key';
if (!_module.hot) {
// Just return value straight away
return valueFactory();
}
if (_module.hot && typeof _module.hot.data === 'undefined') {
// First run, init the module data
// When starting blank, reset the counter
if (!_module.hot.data?.[CURRENT_HOT_MEMOIZE_INDEX_KEY]) {
for (const ancestor of findAllAncestors(_module)) {
ancestor.hot?.addDisposeHandler(data => {
data[CURRENT_HOT_MEMOIZE_INDEX_KEY] = 1;
});
}
_module.hot.data = {
[CURRENT_HOT_MEMOIZE_INDEX_KEY]: 0,
..._module.hot.data,
[CURRENT_HOT_MEMOIZE_INDEX_KEY]: 1,
};
}
// Let's store data per module based on the order of the code invocation
const index = _module.hot.data[CURRENT_HOT_MEMOIZE_INDEX_KEY];
// Increasing the counter after each call
_module.hot.data[CURRENT_HOT_MEMOIZE_INDEX_KEY] += 1;
// Store data per module, based on the order of the code invocation
const index = _module.hot.data[CURRENT_HOT_MEMOIZE_INDEX_KEY]++;
const value = _module.hot.data[index] ?? valueFactory();
const prevValue = _module.hot.data[index];
const createDisposeHandler = (value: any) => (data: {
[key: number]: any;
[indexKey: string]: number;
}) => {
// Preserving the value through the HMR process
// Always add a handler that, upon a HMR event, reinstates the value.
_module.hot.addDisposeHandler(data => {
data[index] = value;
// Decreasing the counter after each handler
data[CURRENT_HOT_MEMOIZE_INDEX_KEY] =
// First hot update is still different, need to populate the data
typeof data[CURRENT_HOT_MEMOIZE_INDEX_KEY] === 'undefined'
? _module.hot!.data[CURRENT_HOT_MEMOIZE_INDEX_KEY] - 1
: data[CURRENT_HOT_MEMOIZE_INDEX_KEY] - 1;
};
});
if (prevValue) {
_module.hot!.addDisposeHandler(createDisposeHandler(prevValue));
return prevValue;
}
const newValue = valueFactory();
_module.hot.addDisposeHandler(createDisposeHandler(newValue));
return newValue;
return value;
}
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ServiceBuilderImpl } from './ServiceBuilderImpl';
import { ServiceBuilderImpl } from './lib/ServiceBuilderImpl';
/**
* Creates a new service builder.
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { ConfigReader } from '@backstage/config';
import compression from 'compression';
import cors from 'cors';
import express, { Router } from 'express';
@@ -21,37 +22,66 @@ import helmet from 'helmet';
import { Server } from 'http';
import stoppable from 'stoppable';
import { Logger } from 'winston';
import { getRootLogger } from '../logging';
import { useHotCleanup } from '../../hot';
import { getRootLogger } from '../../logging';
import {
errorHandler,
notFoundHandler,
requestLoggingHandler,
} from '../middleware';
import { ServiceBuilder } from './types';
import { useHotCleanup } from '../hot';
} from '../../middleware';
import { ServiceBuilder } from '../types';
import { readBaseOptions, readCorsOptions } from './config';
const DEFAULT_PORT = 7000;
const DEFAULT_HOST = 'localhost';
export class ServiceBuilderImpl implements ServiceBuilder {
private port: number | undefined;
private host: string | undefined;
private logger: Logger | undefined;
private corsOptions: cors.CorsOptions | undefined;
private routers: [string, Router][];
/**
* Reference to the module where builder is created
* Needed for the HMR
*/
// Reference to the module where builder is created - needed for hot module
// reloading
private module: NodeModule;
constructor(module: NodeModule) {
this.routers = [];
this.module = module;
}
loadConfig(config: ConfigReader): ServiceBuilder {
const backendConfig = config.getOptionalConfig('backend');
if (!backendConfig) {
return this;
}
const baseOptions = readBaseOptions(backendConfig);
if (baseOptions.listenPort) {
this.port = baseOptions.listenPort;
}
if (baseOptions.listenHost) {
this.host = baseOptions.listenHost;
}
const corsOptions = readCorsOptions(backendConfig);
if (corsOptions) {
this.corsOptions = corsOptions;
}
return this;
}
setPort(port: number): ServiceBuilder {
this.port = port;
return this;
}
setHost(host: string): ServiceBuilder {
this.host = host;
return this;
}
setLogger(logger: Logger): ServiceBuilder {
this.logger = logger;
return this;
@@ -69,7 +99,7 @@ export class ServiceBuilderImpl implements ServiceBuilder {
start(): Promise<Server> {
const app = express();
const { port, logger, corsOptions } = this.getOptions();
const { port, host, logger, corsOptions } = this.getOptions();
app.use(helmet());
if (corsOptions) {
@@ -89,9 +119,10 @@ export class ServiceBuilderImpl implements ServiceBuilder {
logger.error(`Failed to start up on port ${port}, ${e}`);
reject(e);
});
const server = stoppable(
app.listen(port, () => {
logger.info(`Listening on port ${port}`);
app.listen(port, host, () => {
logger.info(`Listening on ${host}:${port}`);
}),
0,
);
@@ -108,26 +139,14 @@ export class ServiceBuilderImpl implements ServiceBuilder {
private getOptions(): {
port: number;
host: string;
logger: Logger;
corsOptions?: cors.CorsOptions;
} {
let port: number;
if (this.port !== undefined) {
port = this.port;
} else {
port = parseInt(process.env.PORT ?? '', 10) || DEFAULT_PORT;
}
let logger: Logger;
if (this.logger) {
logger = this.logger;
} else {
logger = getRootLogger();
}
return {
port,
logger,
port: this.port ?? DEFAULT_PORT,
host: this.host ?? DEFAULT_HOST,
logger: this.logger ?? getRootLogger(),
corsOptions: this.corsOptions,
};
}
@@ -0,0 +1,126 @@
/*
* 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 '@backstage/config';
import { CorsOptions } from 'cors';
export type BaseOptions = {
listenPort?: number;
listenHost?: string;
};
/**
* Reads some base options out of a config object.
*
* @param config The root of a backend config object
* @returns A base options object
*
* @example
* ```json
* {
* baseUrl: "http://localhost:7000",
* listen: "0.0.0.0:7000"
* }
* ```
*/
export function readBaseOptions(config: ConfigReader): BaseOptions {
// TODO(freben): Expand this to support more addresses and perhaps optional
const { host, port } = parseListenAddress(config.getString('listen'));
return removeUnknown({
listenPort: port,
listenHost: host,
});
}
/**
* Attempts to read a CORS options object from the root of a config object.
*
* @param config The root of a backend config object
* @returns A CORS options object, or undefined if not specified
*
* @example
* ```json
* {
* cors: {
* origin: "http://localhost:3000",
* credentials: true
* }
* }
* ```
*/
export function readCorsOptions(config: ConfigReader): CorsOptions | undefined {
const cc = config.getOptionalConfig('cors');
if (!cc) {
return undefined;
}
return removeUnknown({
origin: getOptionalStringOrStrings(cc, 'origin'),
methods: getOptionalStringOrStrings(cc, 'methods'),
allowedHeaders: getOptionalStringOrStrings(cc, 'allowedHeaders'),
exposedHeaders: getOptionalStringOrStrings(cc, 'exposedHeaders'),
credentials: cc.getOptionalBoolean('credentials'),
maxAge: cc.getOptionalNumber('maxAge'),
preflightContinue: cc.getOptionalBoolean('preflightContinue'),
optionsSuccessStatus: cc.getOptionalNumber('optionsSuccessStatus'),
});
}
function getOptionalStringOrStrings(
config: ConfigReader,
key: string,
): string | string[] | undefined {
const value = config.getOptional(key);
if (
value === undefined ||
typeof value === 'string' ||
isStringArray(value)
) {
return value;
}
throw new Error(`Expected string or array of strings, got ${typeof value}`);
}
function isStringArray(value: any): value is string[] {
if (!Array.isArray(value)) {
return false;
}
for (const v of value) {
if (typeof v !== 'string') {
return false;
}
}
return true;
}
function removeUnknown<T extends object>(obj: T): T {
return Object.fromEntries(
Object.entries(obj).filter(([, v]) => v !== undefined),
) as T;
}
function parseListenAddress(value: string): { host?: string; port?: number } {
const parts = value.split(':');
if (parts.length === 1) {
return { port: parseInt(parts[0], 10) };
}
if (parts.length === 2) {
return { host: parts[0], port: parseInt(parts[1], 10) };
}
throw new Error(
`Unable to parse listen address ${value}, expected <port> or <host>:<port>`,
);
}
@@ -14,12 +14,20 @@
* limitations under the License.
*/
import { ConfigReader } from '@backstage/config';
import cors from 'cors';
import { Router } from 'express';
import { Server } from 'http';
import { Logger } from 'winston';
export type ServiceBuilder = {
/**
* Sets the service parameters based on configuration.
*
* @param config The configuration to read
*/
loadConfig(config: ConfigReader): ServiceBuilder;
/**
* Sets the port to listen on.
*
+11 -11
View File
@@ -1,6 +1,6 @@
{
"name": "example-backend",
"version": "0.1.1-alpha.9",
"version": "0.1.1-alpha.12",
"main": "dist/index.cjs.js",
"types": "src/index.ts",
"private": true,
@@ -17,22 +17,22 @@
"migrate:create": "knex migrate:make -x ts"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.9",
"@backstage/catalog-model": "^0.1.1-alpha.9",
"@backstage/config": "^0.1.1-alpha.9",
"@backstage/config-loader": "^0.1.1-alpha.9",
"@backstage/plugin-auth-backend": "^0.1.1-alpha.9",
"@backstage/plugin-catalog-backend": "^0.1.1-alpha.9",
"@backstage/plugin-identity-backend": "^0.1.1-alpha.9",
"@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.9",
"@backstage/plugin-sentry-backend": "^0.1.1-alpha.9",
"@backstage/backend-common": "^0.1.1-alpha.12",
"@backstage/catalog-model": "^0.1.1-alpha.12",
"@backstage/config": "^0.1.1-alpha.12",
"@backstage/config-loader": "^0.1.1-alpha.12",
"@backstage/plugin-auth-backend": "^0.1.1-alpha.12",
"@backstage/plugin-catalog-backend": "^0.1.1-alpha.12",
"@backstage/plugin-identity-backend": "^0.1.1-alpha.12",
"@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.12",
"@backstage/plugin-sentry-backend": "^0.1.1-alpha.12",
"express": "^4.17.1",
"knex": "^0.21.1",
"sqlite3": "^4.2.0",
"winston": "^3.2.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.9",
"@backstage/cli": "^0.1.1-alpha.12",
"@types/express": "^4.17.6",
"@types/express-serve-static-core": "^4.17.5",
"@types/helmet": "^0.0.47"
+4 -5
View File
@@ -55,7 +55,9 @@ function makeCreateEnv(loadedConfigs: AppConfig[]) {
}
async function main() {
const createEnv = makeCreateEnv(await loadConfig());
const configs = await loadConfig();
const configReader = ConfigReader.fromConfigs(configs);
const createEnv = makeCreateEnv(configs);
const catalogEnv = useHotMemoize(module, () => createEnv('catalog'));
const scaffolderEnv = useHotMemoize(module, () => createEnv('scaffolder'));
@@ -63,10 +65,7 @@ async function main() {
const identityEnv = useHotMemoize(module, () => createEnv('identity'));
const service = createServiceBuilder(module)
.enableCors({
origin: 'http://localhost:3000',
credentials: true,
})
.loadConfig(configReader)
.addRouter('/catalog', await catalog(catalogEnv))
.addRouter('/scaffolder', await scaffolder(scaffolderEnv))
.addRouter(
+2 -1
View File
@@ -19,7 +19,8 @@ import { PluginEnvironment } from '../types';
export default async function createPlugin({
logger,
database,
config,
}: PluginEnvironment) {
return await createRouter({ logger, config });
return await createRouter({ logger, config, database });
}
+10 -3
View File
@@ -17,13 +17,20 @@
import {
CookieCutter,
createRouter,
DiskStorage,
FilePreparer,
GithubPreparer,
Preparers,
} from '@backstage/plugin-scaffolder-backend';
import type { PluginEnvironment } from '../types';
export default async function createPlugin({ logger }: PluginEnvironment) {
const storage = new DiskStorage({ logger });
const templater = new CookieCutter();
const filePreparer = new FilePreparer();
const githubPreparer = new GithubPreparer();
const preparers = new Preparers();
return await createRouter({ storage, templater, logger });
preparers.register('file', filePreparer);
preparers.register('github', githubPreparer);
return await createRouter({ preparers, templater, logger });
}
@@ -6,4 +6,4 @@ metadata:
spec:
type: service
lifecycle: experimental
owner: tools@example.com
owner: artists@example.com
@@ -0,0 +1,9 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: playback-sdk
description: Audio and video playback SDK
spec:
type: library
lifecycle: experimental
owner: players@example.com
@@ -6,4 +6,4 @@ metadata:
spec:
type: service
lifecycle: production
owner: tools@example.com
owner: guest
@@ -6,4 +6,4 @@ metadata:
spec:
type: service
lifecycle: experimental
owner: tools@example.com
owner: players@example.com
@@ -6,4 +6,4 @@ metadata:
spec:
type: service
lifecycle: production
owner: tools@example.com
owner: guest
@@ -6,4 +6,4 @@ metadata:
spec:
type: service
lifecycle: production
owner: tools@example.com
owner: guest
@@ -0,0 +1,9 @@
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: www-artist
description: Artist main website
spec:
type: website
lifecycle: production
owner: artists@example.com
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/catalog-model",
"version": "0.1.1-alpha.9",
"version": "0.1.1-alpha.12",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -20,20 +20,20 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/config": "^0.1.1-alpha.9",
"@backstage/config": "^0.1.1-alpha.12",
"@types/yup": "^0.28.2",
"lodash": "^4.17.15",
"uuid": "^8.0.0",
"yup": "^0.28.5"
"yup": "^0.29.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.9",
"@backstage/cli": "^0.1.1-alpha.12",
"@types/express": "^4.17.6",
"@types/jest": "^25.2.2",
"@types/lodash": "^4.14.151",
"yaml": "^1.9.2"
},
"files": [
"dist/**/*.{js,d.ts}"
"dist"
]
}
@@ -26,6 +26,7 @@ export interface TemplateEntityV1alpha1 extends Entity {
kind: typeof KIND;
spec: {
type: string;
path?: string;
};
}
@@ -39,6 +40,7 @@ export class TemplateEntityV1alpha1Policy implements EntityPolicy {
spec: yup
.object({
type: yup.string().required().min(1),
path: yup.string(),
})
.required(),
});
@@ -14,16 +14,12 @@
* limitations under the License.
*/
/* eslint-disable import/no-extraneous-dependencies */
/// <reference types="node" />
/// <reference types="react" />
/// <reference types="react-dom" />
declare namespace NodeJS {
interface ProcessEnv {
readonly NODE_ENV: 'development' | 'production' | 'test';
}
}
declare module '*.bmp' {
const src: string;
export default src;
@@ -54,13 +50,15 @@ declare module '*.webp' {
export default src;
}
declare module '*.icon.svg' {
import { ComponentType } from 'react';
import { SvgIconProps } from '@material-ui/core';
const Icon: ComponentType<SvgIconProps>;
export default Icon;
}
declare module '*.svg' {
import * as React from 'react';
export const ReactComponent: React.FunctionComponent<React.SVGProps<
SVGSVGElement
> & { title?: string }>;
const src: string;
export default src;
}
@@ -94,7 +92,3 @@ declare module '*.module.sass' {
const classes: { readonly [key: string]: string };
export default classes;
}
declare module 'rollup-plugin-image-files' {
export default function image(): any;
}
+2
View File
@@ -0,0 +1,2 @@
// NOOP, this is just here to unbreak module resolution
// eslint-disable-next-line notice/notice
+5
View File
@@ -0,0 +1,5 @@
{
"main": "asset-types.js",
"types": "asset-types.d.ts",
"description": "Provides types for asset files that are handled by the Backstage CLI"
}
+6 -1
View File
@@ -38,11 +38,16 @@ async function getConfig() {
transform: {
'\\.esm\\.js$': require.resolve('jest-esm-transformer'),
'\\.(js|jsx|ts|tsx)': require.resolve('ts-jest'),
'\\.(bmp|gif|jpe|png|frag|xml|svg)': require.resolve(
'./jestFileTransform.js',
),
},
// Default behaviour is to not apply transforms for node_modules, but we still want
// to apply the esm-transformer to .esm.js files, since that's what we use in backstage packages.
transformIgnorePatterns: ['/node_modules/(?!.*\\.esm\\.js$)'],
transformIgnorePatterns: [
'/node_modules/(?!.*\\.(?:esm\\.js|bmp|gif|jpe|png|frag|xml|svg)$)',
],
};
// Use src/setupTests.ts as the default location for configuring test env
+42
View File
@@ -0,0 +1,42 @@
/*
* 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.
*/
const path = require('path');
module.exports = {
process(src, filename) {
const assetFilename = JSON.stringify(path.basename(filename));
if (filename.match(/\.icon\.svg$/)) {
return `const React = require('react');
const SvgIcon = require('@material-ui/core/SvgIcon').default;
module.exports = {
__esModule: true,
default: props => React.createElement(SvgIcon, props, {
$$typeof: Symbol.for('react.element'),
type: 'svg',
ref: ref,
key: null,
props: Object.assign({}, props, {
children: ${assetFilename}
})
})
};`;
}
return `module.exports = ${assetFilename};`;
},
};
+10 -5
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/cli",
"description": "CLI for developing Backstage plugins and apps",
"version": "0.1.1-alpha.9",
"version": "0.1.1-alpha.12",
"private": false,
"publishConfig": {
"access": "public"
@@ -29,16 +29,20 @@
"backstage-cli": "bin/backstage-cli"
},
"dependencies": {
"@backstage/config": "0.1.1-alpha.9",
"@backstage/config-loader": "^0.1.1-alpha.9",
"@backstage/config": "^0.1.1-alpha.12",
"@backstage/config-loader": "^0.1.1-alpha.12",
"@hot-loader/react-dom": "^16.13.0",
"@lerna/package-graph": "^3.18.5",
"@lerna/project": "^3.18.0",
"@rollup/plugin-commonjs": "^12.0.0",
"@rollup/plugin-commonjs": "^13.0.0",
"@rollup/plugin-json": "^4.0.2",
"@rollup/plugin-node-resolve": "^7.1.1",
"@rollup/plugin-node-resolve": "^8.1.0",
"@spotify/eslint-config": "^7.0.1",
"@sucrase/webpack-loader": "^2.0.0",
"@svgr/plugin-jsx": "4.3.x",
"@svgr/plugin-svgo": "4.3.x",
"@svgr/rollup": "4.3.x",
"@svgr/webpack": "4.3.x",
"@types/start-server-webpack-plugin": "^2.2.0",
"@types/webpack-env": "^1.15.2",
"@types/webpack-node-externals": "^1.7.1",
@@ -112,6 +116,7 @@
"zombie": "^6.1.4"
},
"files": [
"asset-types",
"templates",
"config",
"bin",
+1 -2
View File
@@ -178,8 +178,7 @@ export function createBackendConfig(
context: paths.targetPath,
entry: [
'webpack/hot/poll?100',
paths.targetEntry,
...(paths.targetRunFile ? [paths.targetRunFile] : []),
paths.targetRunFile ? paths.targetRunFile : paths.targetEntry,
],
resolve: {
extensions: ['.ts', '.tsx', '.mjs', '.js', '.jsx'],
+23 -1
View File
@@ -17,6 +17,7 @@
import webpack, { Module, Plugin } from 'webpack';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import { BundlingOptions, BackendBundlingOptions } from './types';
import { svgrTemplate } from '../svgrTemplate';
type Transforms = {
loaders: Module['rules'];
@@ -46,7 +47,28 @@ export const transforms = (
},
},
{
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/, /\.frag/, /\.xml/],
test: [/\.icon\.svg$/],
use: [
{
loader: require.resolve('@sucrase/webpack-loader'),
options: { transforms: ['jsx'] },
},
{
loader: require.resolve('@svgr/webpack'),
options: { babel: false, template: svgrTemplate },
},
],
},
{
test: [
/\.bmp$/,
/\.gif$/,
/\.jpe?g$/,
/\.png$/,
/\.frag/,
{ test: /\.svg/, not: [/\.icon\.svg/] },
/\.xml/,
],
loader: require.resolve('url-loader'),
options: {
limit: 10000,
+7 -1
View File
@@ -23,12 +23,14 @@ import resolve from '@rollup/plugin-node-resolve';
import postcss from 'rollup-plugin-postcss';
import esbuild from 'rollup-plugin-esbuild';
import imageFiles from 'rollup-plugin-image-files';
import svgr from '@svgr/rollup';
import dts from 'rollup-plugin-dts';
import json from '@rollup/plugin-json';
import { RollupOptions, OutputOptions } from 'rollup';
import { BuildOptions, Output } from './types';
import { paths } from '../paths';
import { svgrTemplate } from '../svgrTemplate';
export const makeConfigs = async (
options: BuildOptions,
@@ -89,8 +91,12 @@ export const makeConfigs = async (
exclude: ['**/*.stories.*', '**/*.test.*'],
}),
postcss(),
imageFiles(),
imageFiles({ exclude: '**/*.icon.svg' }),
json(),
svgr({
include: '**/*.icon.svg',
template: svgrTemplate,
}),
esbuild({
target: 'es2019',
}),
+47
View File
@@ -0,0 +1,47 @@
/*
* 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.
*/
/**
* This template, together with loaders in the bundler and packages, allows
* for SVG to be imported directly as MUI SvgIcon components by suffixing
* them with .icon.svg
*/
export function svgrTemplate(
{ template }: any,
_opts: any,
{ imports, interfaces, componentName, jsx }: any,
) {
const iconName = {
...componentName,
name: `${componentName.name.replace(/icon$/, '')}Icon`,
};
const defaultExport = {
type: 'ExportDefaultDeclaration',
declaration: iconName,
};
const typeScriptTemplate = template.smart({ plugins: ['typescript'] });
return typeScriptTemplate.ast`
${imports}
import SvgIcon from '@material-ui/core/SvgIcon';
${interfaces}
const ${iconName} = props => React.createElement(SvgIcon, props, ${jsx.children});
${defaultExport}`;
}
+29
View File
@@ -0,0 +1,29 @@
/*
* 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.
*/
declare namespace NodeJS {
interface ProcessEnv {
readonly NODE_ENV: 'development' | 'production' | 'test';
}
}
declare module 'rollup-plugin-image-files' {
export default function image(options?: any): any;
}
declare module '@svgr/rollup' {
export default function svgr(options?: any): any;
}
@@ -31,6 +31,9 @@
"lerna": "^3.20.2",
"prettier": "^1.19.1"
},
"resolutions": {
"**/esbuild": "0.5.3"
},
"prettier": "@spotify/prettier-config",
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
@@ -8,20 +8,22 @@
"@material-ui/lab": "4.0.0-alpha.45",
"@backstage/cli": "^{{version}}",
"@backstage/core": "^{{version}}",
"@backstage/test-utils": "^{{version}}",
"@backstage/theme": "^{{version}}",
"plugin-welcome": "0.0.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router": "6.0.0-alpha.5",
"react-router-dom": "6.0.0-alpha.5",
"react-use": "^14.2.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"@types/testing-library__jest-dom": "^5.0.4",
"@types/react-dom": "^16.9.8",
"cross-env": "^7.0.0",
"cypress": "^4.2.0",
"eslint-plugin-cypress": "^2.10.3",
@@ -8,47 +8,38 @@
name="description"
content="Backstage is an open platform for building developer portals"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="apple-touch-icon" href="<%= publicPath %>/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link
rel="manifest"
href="%PUBLIC_URL%/manifest.json"
href="<%= publicPath %>/manifest.json"
crossorigin="use-credentials"
/>
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="icon" href="<%= publicPath %>/favicon.ico" />
<link rel="shortcut icon" href="<%= publicPath %>/favicon.ico" />
<link
rel="apple-touch-icon"
sizes="180x180"
href="%PUBLIC_URL%/apple-touch-icon.png"
href="<%= publicPath %>/apple-touch-icon.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="%PUBLIC_URL%/favicon-32x32.png"
href="<%= publicPath %>/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="%PUBLIC_URL%/favicon-16x16.png"
href="<%= publicPath %>/favicon-16x16.png"
/>
<link
rel="mask-icon"
href="%PUBLIC_URL%/safari-pinned-tab.svg"
href="<%= publicPath %>/safari-pinned-tab.svg"
color="#5bbad5"
/>
<style>
@@ -56,9 +47,9 @@
min-height: 100%;
}
</style>
<title>Backstage</title>
<title><%= app.title %></title>
</head>
<body style="margin: 0">
<body style="margin: 0;">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
@@ -1,10 +1,22 @@
import React from 'react';
import { render } from '@testing-library/react';
import { renderWithEffects } from '@backstage/test-utils';
import App from './App';
describe('App', () => {
it('should render', () => {
const rendered = render(<App />);
it('should render', async () => {
Object.defineProperty(process.env, 'APP_CONFIG', {
configurable: true,
value: [
{
data: {
app: { title: 'Test' },
},
context: 'test',
},
],
});
const rendered = await renderWithEffects(<App />);
expect(rendered.baseElement).toBeInTheDocument();
});
});
@@ -1,26 +1,7 @@
import { makeStyles } from '@material-ui/core';
import { createApp } from '@backstage/core';
import React, { FC } from 'react';
import * as plugins from './plugins';
const useStyles = makeStyles(theme => ({
'@global': {
html: {
height: '100%',
fontFamily: theme.typography.fontFamily,
},
body: {
height: '100%',
fontFamily: theme.typography.fontFamily,
'overscroll-behavior-y': 'none',
},
a: {
color: 'inherit',
textDecoration: 'none',
},
},
}));
const app = createApp({
plugins: Object.values(plugins),
});
@@ -29,15 +10,12 @@ const AppProvider = app.getProvider();
const AppRouter = app.getRouter();
const AppRoutes = app.getRoutes();
const App: FC<{}> = () => {
useStyles();
return (
<AppProvider>
<AppRouter>
<AppRoutes />
</AppRouter>
</AppProvider>
);
};
const App: FC<{}> = () => (
<AppProvider>
<AppRouter>
<AppRoutes />
</AppRouter>
</AppProvider>
);
export default App;
@@ -1,3 +1,4 @@
import '@backstage/cli/asset-types';
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
@@ -31,12 +31,11 @@
"devDependencies": {
"@backstage/cli": "^{{version}}",
"@backstage/dev-utils": "^{{version}}",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@types/testing-library__jest-dom": "^5.0.4",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"jest-fetch-mock": "^3.0.3"
},
"files": [
"dist/**/*.{js,d.ts}"
"dist"
]
}
@@ -33,15 +33,14 @@
"devDependencies": {
"@backstage/cli": "^{{version}}",
"@backstage/dev-utils": "^{{version}}",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"@types/testing-library__jest-dom": "^5.0.4",
"jest-fetch-mock": "^3.0.3"
},
"files": [
"dist/**/*.{js,d.ts}"
"dist"
]
}
@@ -1,18 +1,18 @@
/*
* 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.
*/
* 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 { createPlugin, createRouteRef } from '@backstage/core';
import ExampleComponent from './components/ExampleComponent';
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/config-loader",
"description": "Config loading functionality used by Backstage backend, and CLI",
"version": "0.1.1-alpha.9",
"version": "0.1.1-alpha.12",
"private": false,
"publishConfig": {
"access": "public",
@@ -30,10 +30,10 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/config": "0.1.1-alpha.9",
"@backstage/config": "^0.1.1-alpha.12",
"fs-extra": "^9.0.0",
"yaml": "^1.9.2",
"yup": "^0.28.5"
"yup": "^0.29.1"
},
"devDependencies": {
"@types/jest": "^25.2.2",
@@ -41,6 +41,6 @@
"@types/yup": "^0.28.2"
},
"files": [
"dist/**/*.{js,d.ts}"
"dist"
]
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/config",
"description": "Config API used by Backstage core, backend, and CLI",
"version": "0.1.1-alpha.9",
"version": "0.1.1-alpha.12",
"private": false,
"publishConfig": {
"access": "public",
@@ -37,6 +37,6 @@
"@types/node": "^12.0.0"
},
"files": [
"dist/**/*.{js,d.ts}"
"dist"
]
}
+9 -9
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/core-api",
"description": "Internal Core API used by Backstage plugins and apps",
"version": "0.1.1-alpha.9",
"version": "0.1.1-alpha.12",
"private": false,
"publishConfig": {
"access": "public",
@@ -29,8 +29,8 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/config": "0.1.1-alpha.9",
"@backstage/theme": "^0.1.1-alpha.9",
"@backstage/config": "^0.1.1-alpha.12",
"@backstage/theme": "^0.1.1-alpha.12",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@types/react": "^16.9",
@@ -41,17 +41,17 @@
"zen-observable": "^0.8.15"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.9",
"@backstage/test-utils-core": "0.1.1-alpha.9",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
"@backstage/cli": "^0.1.1-alpha.12",
"@backstage/test-utils-core": "^0.1.1-alpha.12",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"@types/zen-observable": "^0.8.0",
"jest-fetch-mock": "^3.0.3"
},
"files": [
"dist/**/*.{js,d.ts}"
"dist"
]
}
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { createApiRef } from '../ApiRef';
import { ProfileInfo } from './auth';
/**
* The Identity API used to identify and get information about the signed in user.
@@ -29,13 +30,18 @@ export type IdentityApi = {
*/
getUserId(): string;
/**
* The profile of the signed in user.
*/
getProfile(): ProfileInfo;
/**
* An OpenID Connect ID Token which proves the identity of the signed in user.
*
* The ID token will be undefined if the signed in user does not have a verified
* identity, such as a demo user or mocked user for e2e tests.
*/
getIdToken(): string | undefined;
getIdToken(): Promise<string | undefined>;
// TODO: getProfile(): Promise<Profile> - We want this to be async when added, but needs more work.
+58 -48
View File
@@ -37,13 +37,13 @@ import { Observable } from '../..';
*/
export type OAuthScope = string | string[];
export type AccessTokenOptions = {
export type AuthRequestOptions = {
/**
* If this is set to true, the user will not be prompted to log in,
* and an empty access token will be returned if there is no existing session.
* and an empty response will be returned if there is no existing session.
*
* This can be used to perform a check whether the user is logged in with a set of scopes,
* or if you don't want to force a user to be logged in, but provide functionality if they already are.
* This can be used to perform a check whether the user is logged in, or if you don't
* want to force a user to be logged in, but provide functionality if they already are.
*
* @default false
*/
@@ -88,7 +88,7 @@ export type OAuthApi = {
*/
getAccessToken(
scope?: OAuthScope,
options?: AccessTokenOptions,
options?: AuthRequestOptions,
): Promise<string>;
/**
@@ -97,29 +97,6 @@ export type OAuthApi = {
logout(): Promise<void>;
};
export type IdTokenOptions = {
/**
* If this is set to true, the user will not be prompted to log in,
* and an empty id token will be returned if there is no existing session.
*
* This can be used to perform a check whether the user is logged in, or if you don't
* want to force a user to be logged in, but provide functionality if they already are.
*
* @default false
*/
optional?: boolean;
/**
* If this is set to true, the request will bypass the regular oauth login modal
* and open the login popup directly.
*
* The method must be called synchronously from a user action for this to work in all browsers.
*
* @default false
*/
instantPopup?: boolean;
};
/**
* This API provides access to OpenID Connect credentials. It lets you request ID tokens,
* which can be passed to backend services to prove the user's identity.
@@ -136,7 +113,7 @@ export type OpenIdConnectApi = {
* to log in. The returned promise will not resolve until the user has successfully logged in.
* The returned promise can be rejected, but only if the user rejects the login request.
*/
getIdToken(options?: IdTokenOptions): Promise<string>;
getIdToken(options?: AuthRequestOptions): Promise<string>;
/**
* Log out the user's session. This will reload the page.
@@ -144,38 +121,65 @@ export type OpenIdConnectApi = {
logout(): Promise<void>;
};
export type ProfileInfoOptions = {
/**
* If this is set to true, the user will not be prompted to log in,
* and an empty profile will be returned if there is no existing session.
*
* This can be used to perform a check whether the user is logged in, or if you don't
* want to force a user to be logged in, but provide functionality if they already are.
*
* @default false
*/
optional?: boolean;
};
/**
* This API provides access to profile information of the user from an auth provider.
*/
export type ProfileInfoApi = {
getProfile(options?: ProfileInfoOptions): Promise<ProfileInfo | undefined>;
/**
* Get profile information for the user as supplied by this auth provider.
*
* If the optional flag is not set, a session is guaranteed to be returned, while if
* the optional flag is set, the session may be undefined. See @AuthRequestOptions for more details.
*/
getProfile(options?: AuthRequestOptions): Promise<ProfileInfo | undefined>;
};
/**
* Profile information of the user from an auth provider.
* This API provides access to the user's identity within Backstage.
*
* An auth provider that implements this interface can be used to sign-in to backstage. It is
* not intended to be used directly from a plugin, but instead serves as a connection between
* this authentication method and the app's @IdentityApi
*/
export type BackstageIdentityApi = {
/**
* Get the user's identity within Backstage. This should normally not be called directly,
* use the @IdentityApi instead.
*
* If the optional flag is not set, a session is guaranteed to be returned, while if
* the optional flag is set, the session may be undefined. See @AuthRequestOptions for more details.
*/
getBackstageIdentity(
options?: AuthRequestOptions,
): Promise<BackstageIdentity | undefined>;
};
export type BackstageIdentity = {
/**
* The backstage user ID.
*/
id: string;
/**
* An ID token that can be used to authenticate the user within Backstage.
*/
idToken: string;
};
/**
* Profile information of the user.
*/
export type ProfileInfo = {
/**
* Email ID.
*/
email: string;
email?: string;
/**
* Display name that can be presented to the user.
*/
name?: string;
displayName?: string;
/**
* URL to an avatar image of the user.
*/
@@ -207,7 +211,11 @@ export type SessionStateApi = {
* email and expiration information. Do not rely on any other fields, as they might not be present.
*/
export const googleAuthApiRef = createApiRef<
OAuthApi & OpenIdConnectApi & ProfileInfoApi & SessionStateApi
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
BackstageIdentityApi &
SessionStateApi
>({
id: 'core.auth.google',
description: 'Provides authentication towards Google APIs and identities',
@@ -219,7 +227,9 @@ export const googleAuthApiRef = createApiRef<
* See https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/
* for a full list of supported scopes.
*/
export const githubAuthApiRef = createApiRef<OAuthApi & SessionStateApi>({
export const githubAuthApiRef = createApiRef<
OAuthApi & ProfileInfoApi & BackstageIdentityApi & SessionStateApi
>({
id: 'core.auth.github',
description: 'Provides authentication towards Github APIs',
});
@@ -20,7 +20,7 @@ describe('GithubAuth', () => {
it('should get access token', async () => {
const getSession = jest
.fn()
.mockResolvedValue({ accessToken: 'access-token' });
.mockResolvedValue({ providerInfo: { accessToken: 'access-token' } });
const githubAuth = new GithubAuth({ getSession } as any);
expect(await githubAuth.getAccessToken()).toBe('access-token');
@@ -19,15 +19,16 @@ import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { GithubSession } from './types';
import {
OAuthApi,
AccessTokenOptions,
SessionStateApi,
SessionState,
ProfileInfo,
BackstageIdentity,
AuthRequestOptions,
} from '../../../definitions/auth';
import { OAuthRequestApi, AuthProvider } from '../../../definitions';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { StaticAuthSessionManager } from '../../../../lib/AuthSessionManager';
import { Observable } from '../../../../types';
import { SessionStateTracker } from '../../../../lib/AuthSessionManager/SessionStateTracker';
type CreateOptions = {
// TODO(Rugvip): These two should be grabbed from global config when available, they're not unique to GithubAuth
@@ -41,10 +42,13 @@ type CreateOptions = {
};
export type GithubAuthResponse = {
accessToken: string;
idToken: string;
scope: string;
expiresInSeconds: number;
providerInfo: {
accessToken: string;
scope: string;
expiresInSeconds: number;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
@@ -69,9 +73,14 @@ class GithubAuth implements OAuthApi, SessionStateApi {
oauthRequestApi: oauthRequestApi,
sessionTransform(res: GithubAuthResponse): GithubSession {
return {
accessToken: res.accessToken,
scopes: GithubAuth.normalizeScope(res.scope),
expiresAt: new Date(Date.now() + res.expiresInSeconds * 1000),
...res,
providerInfo: {
accessToken: res.providerInfo.accessToken,
scopes: GithubAuth.normalizeScope(res.providerInfo.scope),
expiresAt: new Date(
Date.now() + res.providerInfo.expiresInSeconds * 1000,
),
},
};
},
});
@@ -79,36 +88,40 @@ class GithubAuth implements OAuthApi, SessionStateApi {
const sessionManager = new StaticAuthSessionManager({
connector,
defaultScopes: new Set(['user']),
sessionScopes: session => session.scopes,
sessionScopes: (session: GithubSession) => session.providerInfo.scopes,
});
return new GithubAuth(sessionManager);
}
private readonly sessionStateTracker = new SessionStateTracker();
sessionState$(): Observable<SessionState> {
return this.sessionStateTracker.observable;
return this.sessionManager.sessionState$();
}
constructor(private readonly sessionManager: SessionManager<GithubSession>) {}
async getAccessToken(scope?: string, options?: AccessTokenOptions) {
const normalizedScopes = GithubAuth.normalizeScope(scope);
async getAccessToken(scope?: string, options?: AuthRequestOptions) {
const session = await this.sessionManager.getSession({
...options,
scopes: normalizedScopes,
scopes: GithubAuth.normalizeScope(scope),
});
this.sessionStateTracker.setIsSignedId(!!session);
if (session) {
return session.accessToken;
}
return '';
return session?.providerInfo.accessToken ?? '';
}
async getBackstageIdentity(
options: AuthRequestOptions = {},
): Promise<BackstageIdentity | undefined> {
const session = await this.sessionManager.getSession(options);
return session?.backstageIdentity;
}
async getProfile(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.profile;
}
async logout() {
await this.sessionManager.removeSession();
this.sessionStateTracker.setIsSignedId(false);
}
static normalizeScope(scope?: string): Set<string> {
@@ -14,8 +14,15 @@
* limitations under the License.
*/
import { ProfileInfo } from '../../..';
import { BackstageIdentity } from '../../../definitions';
export type GithubSession = {
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
providerInfo: {
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
@@ -23,9 +23,9 @@ const PREFIX = 'https://www.googleapis.com/auth/';
describe('GoogleAuth', () => {
it('should get refreshed access token', async () => {
const getSession = jest
.fn()
.mockResolvedValue({ accessToken: 'access-token', expiresAt: theFuture });
const getSession = jest.fn().mockResolvedValue({
providerInfo: { accessToken: 'access-token', expiresAt: theFuture },
});
const googleAuth = new GoogleAuth({ getSession } as any);
expect(await googleAuth.getAccessToken()).toBe('access-token');
@@ -33,9 +33,9 @@ describe('GoogleAuth', () => {
});
it('should get refreshed id token', async () => {
const getSession = jest
.fn()
.mockResolvedValue({ idToken: 'id-token', expiresAt: theFuture });
const getSession = jest.fn().mockResolvedValue({
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
});
const googleAuth = new GoogleAuth({ getSession } as any);
expect(await googleAuth.getIdToken()).toBe('id-token');
@@ -43,9 +43,9 @@ describe('GoogleAuth', () => {
});
it('should get optional id token', async () => {
const getSession = jest
.fn()
.mockResolvedValue({ idToken: 'id-token', expiresAt: theFuture });
const getSession = jest.fn().mockResolvedValue({
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
});
const googleAuth = new GoogleAuth({ getSession } as any);
expect(await googleAuth.getIdToken({ optional: true })).toBe('id-token');
@@ -58,9 +58,11 @@ describe('GoogleAuth', () => {
const getSession = jest
.fn()
.mockResolvedValueOnce({
accessToken: 'access-token',
expiresAt: theFuture,
scopes: new Set([`${PREFIX}not-enough`]),
providerInfo: {
accessToken: 'access-token',
expiresAt: theFuture,
scopes: new Set([`${PREFIX}not-enough`]),
},
})
.mockRejectedValue(error);
const googleAuth = new GoogleAuth({ getSession } as any);
@@ -77,17 +79,21 @@ describe('GoogleAuth', () => {
it('should wait for all session refreshes', async () => {
const initialSession = {
idToken: 'token1',
expiresAt: theFuture,
scopes: new Set(),
providerInfo: {
idToken: 'token1',
expiresAt: theFuture,
scopes: new Set(),
},
};
const getSession = jest
.fn()
.mockResolvedValueOnce(initialSession)
.mockResolvedValue({
idToken: 'token2',
expiresAt: theFuture,
scopes: new Set(),
providerInfo: {
idToken: 'token2',
expiresAt: theFuture,
scopes: new Set(),
},
});
const googleAuth = new GoogleAuth({ getSession } as any);
@@ -95,7 +101,7 @@ describe('GoogleAuth', () => {
await expect(googleAuth.getIdToken()).resolves.toBe('token1');
expect(getSession).toBeCalledTimes(1);
initialSession.expiresAt = thePast;
initialSession.providerInfo.expiresAt = thePast;
const promise1 = googleAuth.getIdToken();
const promise2 = googleAuth.getIdToken();
@@ -20,19 +20,18 @@ import { GoogleSession } from './types';
import {
OAuthApi,
OpenIdConnectApi,
IdTokenOptions,
AccessTokenOptions,
ProfileInfoApi,
ProfileInfoOptions,
ProfileInfo,
SessionStateApi,
SessionState,
BackstageIdentityApi,
AuthRequestOptions,
BackstageIdentity,
} from '../../../definitions/auth';
import { OAuthRequestApi, AuthProvider } from '../../../definitions';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager';
import { Observable } from '../../../../types';
import { SessionStateTracker } from '../../../../lib/AuthSessionManager/SessionStateTracker';
type CreateOptions = {
// TODO(Rugvip): These two should be grabbed from global config when available, they're not unique to GoogleAuth
@@ -46,11 +45,14 @@ type CreateOptions = {
};
export type GoogleAuthResponse = {
providerInfo: {
accessToken: string;
idToken: string;
scope: string;
expiresInSeconds: number;
};
profile: ProfileInfo;
accessToken: string;
idToken: string;
scope: string;
expiresInSeconds: number;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
@@ -62,7 +64,12 @@ const DEFAULT_PROVIDER = {
const SCOPE_PREFIX = 'https://www.googleapis.com/auth/';
class GoogleAuth
implements OAuthApi, OpenIdConnectApi, ProfileInfoApi, SessionStateApi {
implements
OAuthApi,
OpenIdConnectApi,
ProfileInfoApi,
BackstageIdentityApi,
SessionStateApi {
static create({
apiOrigin,
basePath,
@@ -78,11 +85,15 @@ class GoogleAuth
oauthRequestApi: oauthRequestApi,
sessionTransform(res: GoogleAuthResponse): GoogleSession {
return {
profile: res.profile,
idToken: res.idToken,
accessToken: res.accessToken,
scopes: GoogleAuth.normalizeScopes(res.scope),
expiresAt: new Date(Date.now() + res.expiresInSeconds * 1000),
...res,
providerInfo: {
idToken: res.providerInfo.idToken,
accessToken: res.providerInfo.accessToken,
scopes: GoogleAuth.normalizeScopes(res.providerInfo.scope),
expiresAt: new Date(
Date.now() + res.providerInfo.expiresInSeconds * 1000,
),
},
};
},
});
@@ -94,9 +105,10 @@ class GoogleAuth
`${SCOPE_PREFIX}userinfo.email`,
`${SCOPE_PREFIX}userinfo.profile`,
]),
sessionScopes: session => session.scopes,
sessionShouldRefresh: session => {
const expiresInSec = (session.expiresAt.getTime() - Date.now()) / 1000;
sessionScopes: (session: GoogleSession) => session.providerInfo.scopes,
sessionShouldRefresh: (session: GoogleSession) => {
const expiresInSec =
(session.providerInfo.expiresAt.getTime() - Date.now()) / 1000;
return expiresInSec < 60 * 5;
},
});
@@ -104,51 +116,42 @@ class GoogleAuth
return new GoogleAuth(sessionManager);
}
private readonly sessionStateTracker = new SessionStateTracker();
sessionState$(): Observable<SessionState> {
return this.sessionStateTracker.observable;
return this.sessionManager.sessionState$();
}
constructor(private readonly sessionManager: SessionManager<GoogleSession>) {}
async getAccessToken(
scope?: string | string[],
options?: AccessTokenOptions,
options?: AuthRequestOptions,
) {
const normalizedScopes = GoogleAuth.normalizeScopes(scope);
const session = await this.sessionManager.getSession({
...options,
scopes: normalizedScopes,
scopes: GoogleAuth.normalizeScopes(scope),
});
this.sessionStateTracker.setIsSignedId(!!session);
if (session) {
return session.accessToken;
}
return '';
return session?.providerInfo.accessToken ?? '';
}
async getIdToken(options: IdTokenOptions = {}) {
async getIdToken(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
this.sessionStateTracker.setIsSignedId(!!session);
if (session) {
return session.idToken;
}
return '';
return session?.providerInfo.idToken ?? '';
}
async logout() {
await this.sessionManager.removeSession();
this.sessionStateTracker.setIsSignedId(false);
}
async getProfile(options: ProfileInfoOptions = {}) {
async getBackstageIdentity(
options: AuthRequestOptions = {},
): Promise<BackstageIdentity | undefined> {
const session = await this.sessionManager.getSession(options);
this.sessionStateTracker.setIsSignedId(!!session);
if (!session) {
return undefined;
}
return session.profile;
return session?.backstageIdentity;
}
async getProfile(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.profile;
}
static normalizeScopes(scopes?: string | string[]): Set<string> {
@@ -14,12 +14,15 @@
* limitations under the License.
*/
import { ProfileInfo } from '../../../definitions';
import { ProfileInfo, BackstageIdentity } from '../../../definitions';
export type GoogleSession = {
providerInfo: {
idToken: string;
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
profile: ProfileInfo;
idToken: string;
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
backstageIdentity: BackstageIdentity;
};
+8 -17
View File
@@ -17,7 +17,6 @@ import React, {
ComponentType,
FC,
useMemo,
useCallback,
useState,
ReactElement,
} from 'react';
@@ -258,24 +257,14 @@ export class PrivateAppImpl implements BackstageApp {
component: ComponentType<SignInPageProps>;
children: ReactElement;
}> = ({ component: Component, children }) => {
const [done, setDone] = useState(false);
const [result, setResult] = useState<SignInResult>();
const onResult = useCallback(
(result: SignInResult) => {
if (done) {
throw new Error('Identity result callback was called twice');
}
this.identityApi.setSignInResult(result);
setDone(true);
},
[done],
);
if (done) {
if (result) {
this.identityApi.setSignInResult(result);
return children;
}
return <Component onResult={onResult} />;
return <Component onResult={setResult} />;
};
const AppRouter: FC<{}> = ({ children }) => {
@@ -293,8 +282,10 @@ export class PrivateAppImpl implements BackstageApp {
if (!SignInPageComponent) {
this.identityApi.setSignInResult({
userId: 'guest',
idToken: undefined,
logout: async () => {},
profile: {
email: 'guest@example.com',
displayName: 'Guest',
},
});
return (
+20 -5
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { IdentityApi } from '../apis';
import { IdentityApi, ProfileInfo } from '../apis';
import { SignInResult } from './types';
/**
@@ -24,7 +24,8 @@ import { SignInResult } from './types';
export class AppIdentity implements IdentityApi {
private hasIdentity = false;
private userId?: string;
private idToken?: string;
private profile?: ProfileInfo;
private idTokenFunc?: () => Promise<string>;
private logoutFunc?: () => Promise<void>;
getUserId(): string {
@@ -36,13 +37,22 @@ export class AppIdentity implements IdentityApi {
return this.userId!;
}
getIdToken(): string | undefined {
getProfile(): ProfileInfo {
if (!this.hasIdentity) {
throw new Error(
'Tried to access IdentityApi profile before app was loaded',
);
}
return this.profile!;
}
async getIdToken(): Promise<string | undefined> {
if (!this.hasIdentity) {
throw new Error(
'Tried to access IdentityApi idToken before app was loaded',
);
}
return this.idToken;
return this.idTokenFunc?.();
}
async logout(): Promise<void> {
@@ -55,6 +65,7 @@ export class AppIdentity implements IdentityApi {
location.reload();
}
// This is indirectly called by the sign-in page to continue into the app.
setSignInResult(result: SignInResult) {
if (this.hasIdentity) {
return;
@@ -62,9 +73,13 @@ export class AppIdentity implements IdentityApi {
if (!result.userId) {
throw new Error('Invalid sign-in result, userId not set');
}
if (!result.profile) {
throw new Error('Invalid sign-in result, profile not set');
}
this.hasIdentity = true;
this.userId = result.userId;
this.idToken = result.idToken;
this.profile = result.profile;
this.idTokenFunc = result.getIdToken;
this.logoutFunc = result.logout;
}
}
+6 -3
View File
@@ -18,7 +18,7 @@ import { ComponentType } from 'react';
import { IconComponent, SystemIconKey, SystemIcons } from '../icons';
import { BackstagePlugin } from '../plugin';
import { ApiHolder } from '../apis';
import { AppTheme, ConfigApi } from '../apis/definitions';
import { AppTheme, ConfigApi, ProfileInfo } from '../apis/definitions';
import { AppConfig } from '@backstage/config';
export type BootErrorPageProps = {
@@ -31,10 +31,13 @@ export type SignInResult = {
* User ID that will be returned by the IdentityApi
*/
userId: string;
profile: ProfileInfo;
/**
* ID token that will be returned by the IdentityApi
* Function used to retrieve an ID token for the signed in user.
*/
idToken?: string;
getIdToken?: () => Promise<string>;
/**
* Logout handler that will be called if the user requests a logout.
*/
@@ -38,6 +38,7 @@ class LocalStorage {
class MockManager implements SessionManager<string> {
getSession = jest.fn();
removeSession = jest.fn();
sessionState$ = jest.fn();
}
describe('GheAuth AuthSessionStore', () => {
@@ -119,4 +120,11 @@ describe('GheAuth AuthSessionStore', () => {
expect(localStorage.getItem('my-key')).toBe(null);
});
it('should forward sessionState calls', () => {
const manager = new MockManager();
const store = new AuthSessionStore({ manager, ...defaultOptions });
store.sessionState$();
expect(manager.sessionState$).toHaveBeenCalled();
});
});
@@ -82,6 +82,10 @@ export class AuthSessionStore<T> implements SessionManager<T> {
await this.manager.removeSession();
}
sessionState$() {
return this.manager.sessionState$();
}
private loadSession(): T | undefined {
try {
const sessionJson = localStorage.getItem(this.storageKey);
@@ -15,6 +15,7 @@
*/
import { RefreshingAuthSessionManager } from './RefreshingAuthSessionManager';
import { SessionState } from '../../apis';
const defaultOptions = {
sessionScopes: (session: { scopes: Set<string> }) => session.scopes,
@@ -22,21 +23,44 @@ const defaultOptions = {
};
describe('RefreshingAuthSessionManager', () => {
it('should save result form createSession', async () => {
it('should save result from createSession', async () => {
const createSession = jest.fn().mockResolvedValue({ expired: false });
const refreshSession = jest.fn().mockRejectedValue(new Error('NOPE'));
const removeSession = jest.fn();
const manager = new RefreshingAuthSessionManager({
connector: { createSession, refreshSession },
connector: { createSession, refreshSession, removeSession },
...defaultOptions,
} as any);
const stateSubscriber = jest.fn();
manager.sessionState$().subscribe(stateSubscriber);
await Promise.resolve(); // Wait a tick for observer to post a value
expect(stateSubscriber.mock.calls).toEqual([[SessionState.SignedOut]]);
await manager.getSession({});
expect(createSession).toBeCalledTimes(1);
expect(stateSubscriber.mock.calls).toEqual([
[SessionState.SignedOut],
[SessionState.SignedIn],
]);
await manager.getSession({});
expect(createSession).toBeCalledTimes(1);
expect(refreshSession).toBeCalledTimes(1);
expect(stateSubscriber.mock.calls).toEqual([
[SessionState.SignedOut],
[SessionState.SignedIn],
]);
expect(removeSession).toHaveBeenCalledTimes(0);
await manager.removeSession();
expect(removeSession).toHaveBeenCalledTimes(1);
expect(stateSubscriber.mock.calls).toEqual([
[SessionState.SignedOut],
[SessionState.SignedIn],
[SessionState.SignedOut],
]);
});
it('should ask consent only if scopes have changed', async () => {
@@ -130,7 +154,7 @@ describe('RefreshingAuthSessionManager', () => {
expect(refreshSession).toBeCalledTimes(1);
});
it('should remove session and reload', async () => {
it('should remove session straight away', async () => {
const removeSession = jest.fn();
const manager = new RefreshingAuthSessionManager({
connector: { removeSession },
@@ -22,6 +22,7 @@ import {
} from './types';
import { AuthConnector } from '../AuthConnector';
import { SessionScopeHelper, hasScopes } from './common';
import { SessionStateTracker } from './SessionStateTracker';
type Options<T> = {
/** The connector used for acting on the auth session */
@@ -43,6 +44,7 @@ export class RefreshingAuthSessionManager<T> implements SessionManager<T> {
private readonly helper: SessionScopeHelper<T>;
private readonly sessionScopesFunc: SessionScopesFunc<T>;
private readonly sessionShouldRefreshFunc: SessionShouldRefreshFunc<T>;
private readonly stateTracker = new SessionStateTracker();
private refreshPromise?: Promise<T>;
private currentSession: T | undefined;
@@ -109,16 +111,18 @@ export class RefreshingAuthSessionManager<T> implements SessionManager<T> {
...options,
scopes: this.helper.getExtendedScope(this.currentSession, options.scopes),
});
this.stateTracker.setIsSignedIn(true);
return this.currentSession;
}
async removeSession() {
this.currentSession = undefined;
await this.connector.removeSession();
this.stateTracker.setIsSignedIn(false);
}
async getCurrentSession() {
return this.currentSession;
sessionState$() {
return this.stateTracker.sessionState$();
}
private async collapsedSessionRefresh(): Promise<T> {
@@ -129,7 +133,9 @@ export class RefreshingAuthSessionManager<T> implements SessionManager<T> {
this.refreshPromise = this.connector.refreshSession();
try {
return await this.refreshPromise;
const session = await this.refreshPromise;
this.stateTracker.setIsSignedIn(true);
return session;
} finally {
delete this.refreshPromise;
}
@@ -16,17 +16,25 @@
import { BehaviorSubject } from '..';
import { SessionState } from '../../apis';
import { Observable } from '../../types';
export class SessionStateTracker {
private signedIn: boolean = false;
observable = new BehaviorSubject<SessionState>(SessionState.SignedOut);
private readonly subject = new BehaviorSubject<SessionState>(
SessionState.SignedOut,
);
setIsSignedId(isSignedIn: boolean) {
private signedIn: boolean = false;
setIsSignedIn(isSignedIn: boolean) {
if (this.signedIn !== isSignedIn) {
this.signedIn = isSignedIn;
this.observable.next(
this.subject.next(
this.signedIn ? SessionState.SignedIn : SessionState.SignedOut,
);
}
}
sessionState$(): Observable<SessionState> {
return this.subject;
}
}
@@ -17,6 +17,7 @@
import { SessionManager, GetSessionOptions } from './types';
import { AuthConnector } from '../AuthConnector';
import { SessionScopeHelper } from './common';
import { SessionStateTracker } from './SessionStateTracker';
type Options<T> = {
/** The connector used for acting on the auth session */
@@ -33,6 +34,7 @@ type Options<T> = {
export class StaticAuthSessionManager<T> implements SessionManager<T> {
private readonly connector: AuthConnector<T>;
private readonly helper: SessionScopeHelper<T>;
private readonly stateTracker = new SessionStateTracker();
private currentSession: T | undefined;
@@ -60,11 +62,17 @@ export class StaticAuthSessionManager<T> implements SessionManager<T> {
...options,
scopes: this.helper.getExtendedScope(this.currentSession, options.scopes),
});
this.stateTracker.setIsSignedIn(true);
return this.currentSession;
}
async removeSession() {
this.currentSession = undefined;
await this.connector.removeSession();
this.stateTracker.setIsSignedIn(false);
}
sessionState$() {
return this.stateTracker.sessionState$();
}
}
@@ -14,6 +14,9 @@
* limitations under the License.
*/
import { Observable } from '../../types';
import { SessionState } from '../../apis';
export type GetSessionOptions = {
optional?: boolean;
instantPopup?: boolean;
@@ -29,6 +32,8 @@ export type SessionManager<T> = {
getSession(options: GetSessionOptions): Promise<T | undefined>;
removeSession(): Promise<void>;
sessionState$(): Observable<SessionState>;
};
/**
+7 -7
View File
@@ -59,7 +59,7 @@ describe('showLoginPopup', () => {
// None of these should be accepted
listener({ source: popupMock } as MessageEvent);
listener({ origin: 'my-origin' } as MessageEvent);
listener({ data: { type: 'auth-result' } } as MessageEvent);
listener({ data: { type: 'authorization_response' } } as MessageEvent);
listener({
source: popupMock,
origin: 'my-origin',
@@ -68,26 +68,26 @@ describe('showLoginPopup', () => {
listener({
source: popupMock,
origin: 'my-origin',
data: { type: 'not-auth-result', payload: {} },
data: { type: 'not-auth-result', response: {} },
} as MessageEvent);
await expect(Promise.race([payloadPromise, 'waiting'])).resolves.toBe(
'waiting',
);
const myPayload = {};
const myResponse = {};
// This should be accepted as a valid sessions response
listener({
source: popupMock,
origin: 'my-origin',
data: {
type: 'auth-result',
payload: myPayload,
type: 'authorization_response',
response: myResponse,
},
} as MessageEvent);
await expect(payloadPromise).resolves.toBe(myPayload);
await expect(payloadPromise).resolves.toBe(myResponse);
expect(openSpy).toBeCalledTimes(1);
expect(addEventListenerSpy).toBeCalledTimes(1);
@@ -118,7 +118,7 @@ describe('showLoginPopup', () => {
source: popupMock,
origin: 'my-origin',
data: {
type: 'auth-result',
type: 'authorization_response',
error: {
message: 'NOPE',
name: 'NopeError',
+8 -7
View File
@@ -46,11 +46,11 @@ export type LoginPopupOptions = {
type AuthResult =
| {
type: 'auth-result';
payload: any;
type: 'authorization_response';
response: unknown;
}
| {
type: 'auth-result';
type: 'authorization_response';
error: {
name: string;
message: string;
@@ -58,12 +58,13 @@ type AuthResult =
};
/**
* Show a popup pointing to a URL that starts an auth flow.
* Show a popup pointing to a URL that starts an auth flow. Implementing the receiving
* end of the postMessage mechanism outlined in https://tools.ietf.org/html/draft-sakimura-oauth-wmrm-00
*
* The redirect handler of the flow should use postMessage to communicate back
* to the app window. The message posted to the app must match the AuthResult type.
*
* The returned promise resolves to the contents of the message that was posted from the auth popup.
* The returned promise resolves to the response of the message that was posted from the auth popup.
*/
export function showLoginPopup(options: LoginPopupOptions): Promise<any> {
return new Promise((resolve, reject) => {
@@ -91,7 +92,7 @@ export function showLoginPopup(options: LoginPopupOptions): Promise<any> {
return;
}
const { data } = event;
if (data.type !== 'auth-result') {
if (data.type !== 'authorization_response') {
return;
}
const authResult = data as AuthResult;
@@ -103,7 +104,7 @@ export function showLoginPopup(options: LoginPopupOptions): Promise<any> {
// error.extra = authResult.error.extra;
reject(error);
} else {
resolve(authResult.payload);
resolve(authResult.response);
}
done();
};
+10 -10
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/core",
"description": "Core API used by Backstage plugins and apps",
"version": "0.1.1-alpha.9",
"version": "0.1.1-alpha.12",
"private": false,
"publishConfig": {
"access": "public",
@@ -29,9 +29,9 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/config": "0.1.1-alpha.9",
"@backstage/core-api": "^0.1.1-alpha.9",
"@backstage/theme": "^0.1.1-alpha.9",
"@backstage/config": "^0.1.1-alpha.12",
"@backstage/core-api": "^0.1.1-alpha.12",
"@backstage/theme": "^0.1.1-alpha.12",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -54,11 +54,11 @@
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.9",
"@backstage/test-utils": "^0.1.1-alpha.9",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
"@backstage/cli": "^0.1.1-alpha.12",
"@backstage/test-utils": "^0.1.1-alpha.12",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
"@types/classnames": "^2.2.9",
"@types/google-protobuf": "^3.7.2",
"@types/jest": "^25.2.2",
@@ -68,6 +68,6 @@
"jest-fetch-mock": "^3.0.3"
},
"files": [
"dist/**/*.{js,d.ts}"
"dist"
]
}
+1 -1
View File
@@ -25,7 +25,7 @@ import privateExports, {
import { BrowserRouter, MemoryRouter } from 'react-router-dom';
import { ErrorPage } from '../layout/ErrorPage';
import Progress from '../components/Progress';
import { Progress } from '../components/Progress';
import { lightTheme, darkTheme } from '@backstage/theme';
import { AppConfig, JsonObject } from '@backstage/config';
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export * from './AlertDisplay';
export { AlertDisplay } from './AlertDisplay';
@@ -15,7 +15,7 @@
*/
import React from 'react';
import CodeSnippet from './CodeSnippet';
import { CodeSnippet } from './CodeSnippet';
import { InfoCard } from '../../layout/InfoCard';
export default {
@@ -18,7 +18,7 @@ import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import CodeSnippet from './CodeSnippet';
import { CodeSnippet } from './CodeSnippet';
const JAVASCRIPT = `const greeting = "Hello";
const world = "World";
@@ -31,7 +31,7 @@ const defaultProps = {
showLineNumbers: false,
};
const CodeSnippet: FC<Props> = props => {
export const CodeSnippet: FC<Props> = props => {
const { text, language, showLineNumbers } = {
...defaultProps,
...props,
@@ -57,5 +57,3 @@ CodeSnippet.propTypes = {
language: PropTypes.string.isRequired,
showLineNumbers: PropTypes.bool,
};
export default CodeSnippet;
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { default } from './CodeSnippet';
export { CodeSnippet } from './CodeSnippet';
@@ -15,7 +15,7 @@
*/
import React from 'react';
import CopyTextButton from '.';
import { CopyTextButton } from '.';
export default {
title: 'CopyTextButton',
@@ -17,7 +17,7 @@
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import CopyTextButton from './CopyTextButton';
import { CopyTextButton } from './CopyTextButton';
import {
ApiRegistry,
errorApiRef,
@@ -56,7 +56,7 @@ const defaultProps = {
tooltipText: 'Text copied to clipboard',
};
const CopyTextButton: FC<Props> = props => {
export const CopyTextButton: FC<Props> = props => {
const { text, tooltipDelay, tooltipText } = {
...defaultProps,
...props,
@@ -110,5 +110,3 @@ CopyTextButton.propTypes = {
tooltipDelay: PropTypes.number,
tooltipText: PropTypes.string,
};
export default CopyTextButton;
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { default } from './CopyTextButton';
export { CopyTextButton } from './CopyTextButton';
@@ -15,7 +15,7 @@
*/
import React from 'react';
import DismissableBanner from './DismissableBanner';
import { DismissableBanner } from './DismissableBanner';
import { Link, Typography } from '@material-ui/core';
import {
ApiProvider,
@@ -17,7 +17,7 @@
import React from 'react';
import { fireEvent } from '@testing-library/react';
import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
import DismissableBanner from './DismissableBanner';
import { DismissableBanner } from './DismissableBanner';
import {
ApiRegistry,
ApiProvider,
@@ -59,7 +59,7 @@ type Props = {
id: string;
};
const DismissableBanner: FC<Props> = ({ variant, message, id }) => {
export const DismissableBanner: FC<Props> = ({ variant, message, id }) => {
const classes = useStyles();
const storageApi = useApi(storageApiRef);
const notificationsStore = storageApi.forBucket('notifications');
@@ -111,5 +111,3 @@ const DismissableBanner: FC<Props> = ({ variant, message, id }) => {
</Snackbar>
);
};
export default DismissableBanner;
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { default } from './DismissableBanner';
export { DismissableBanner } from './DismissableBanner';
@@ -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 { FeatureCalloutCircular } from './FeatureCalloutCircular';
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React from 'react';
import HorizontalScrollGrid from './HorizontalScrollGrid';
import { HorizontalScrollGrid } from './HorizontalScrollGrid';
const cardContentStyle = { height: 0, padding: 150, margin: 20 };
const containerStyle = { width: 800, height: 400, margin: 20 };
@@ -17,7 +17,7 @@
import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils';
import HorizontalScrollGrid from './HorizontalScrollGrid';
import { HorizontalScrollGrid } from './HorizontalScrollGrid';
import { Grid } from '@material-ui/core';
describe('<HorizontalScrollGrid />', () => {
@@ -181,7 +181,7 @@ function useSmoothScroll(
return setScrollTarget;
}
const HorizontalScrollGrid: FC<Props> = props => {
export const HorizontalScrollGrid: FC<Props> = props => {
const {
scrollStep = 100,
scrollSpeed = 50,
@@ -245,5 +245,3 @@ const HorizontalScrollGrid: FC<Props> = props => {
</div>
);
};
export default HorizontalScrollGrid;
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { default } from './HorizontalScrollGrid';
export { HorizontalScrollGrid } from './HorizontalScrollGrid';
@@ -15,7 +15,7 @@
*/
import React from 'react';
import Progress from '.';
import { Progress } from '.';
export default {
title: 'Progress',
@@ -17,7 +17,7 @@
import React, { FC, useState, useEffect } from 'react';
import { LinearProgress, LinearProgressProps } from '@material-ui/core';
const Progress: FC<LinearProgressProps> = props => {
export const Progress: FC<LinearProgressProps> = props => {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
@@ -31,5 +31,3 @@ const Progress: FC<LinearProgressProps> = props => {
<div style={{ display: 'none' }} data-testid="progress" />
);
};
export default Progress;

Some files were not shown because too many files have changed in this diff Show More