Merge branch 'master' of github.com:spotify/backstage into shmidt-i/load-scaffolder-templates-from-api
This commit is contained in:
+3
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -116,3 +116,6 @@ dist
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# Temporary change files created by Vim
|
||||
*.swp
|
||||
|
||||
@@ -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.|
|
||||
@@ -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
@@ -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?
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
+46
-27
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -6,4 +6,4 @@ metadata:
|
||||
spec:
|
||||
type: service
|
||||
lifecycle: production
|
||||
owner: tools@example.com
|
||||
owner: guest
|
||||
+1
-1
@@ -6,4 +6,4 @@ metadata:
|
||||
spec:
|
||||
type: service
|
||||
lifecycle: experimental
|
||||
owner: tools@example.com
|
||||
owner: players@example.com
|
||||
+1
-1
@@ -6,4 +6,4 @@ metadata:
|
||||
spec:
|
||||
type: service
|
||||
lifecycle: production
|
||||
owner: tools@example.com
|
||||
owner: guest
|
||||
+1
-1
@@ -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
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
+10
-16
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// NOOP, this is just here to unbreak module resolution
|
||||
// eslint-disable-next-line notice/notice
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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};`;
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
Vendored
+29
@@ -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';
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user