Merge branch 'master' of github.com:spotify/backstage into shmidt-i/backend-hmr-2
This commit is contained in:
@@ -61,5 +61,5 @@ jobs:
|
||||
run: yarn test:e2e:ci
|
||||
working-directory: ${{ runner.temp }}/test-app/packages/app
|
||||
env:
|
||||
PORT: 3001
|
||||
APP_CONFIG_app_baseUrl: '"http://localhost:3001"'
|
||||
BACKSTAGE_E2E_CLI_TEST: true
|
||||
|
||||
@@ -64,5 +64,5 @@ jobs:
|
||||
run: yarn test:e2e:ci
|
||||
working-directory: ${{ runner.temp }}/test-app/packages/app
|
||||
env:
|
||||
PORT: 3001
|
||||
APP_CONFIG_app_baseUrl: '"http://localhost:3001"'
|
||||
BACKSTAGE_E2E_CLI_TEST: true
|
||||
|
||||
+1
-1
@@ -68,4 +68,4 @@ This project adheres to the [Spotify FOSS Code of Conduct][code-of-conduct]. By
|
||||
|
||||
# Security Issues?
|
||||
|
||||
Please report sensitive security issues via Spotify's [bug-bounty program](https://hackerone.com/spotify) rather than GitHub.
|
||||
See [SECURITY](SECURITY.md).
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 0.x | :white_check_mark: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report sensitive security issues via Spotify's [bug-bounty program](https://hackerone.com/spotify) rather than GitHub.
|
||||
+1
-1
@@ -22,7 +22,7 @@ function inject_config() {
|
||||
>&2 echo "Runtime app config: $config"
|
||||
|
||||
local main_js
|
||||
main_js="$(ls /usr/share/nginx/html/main.*.chunk.js)"
|
||||
main_js="$(grep -l __APP_INJECTED_RUNTIME_CONFIG__ /usr/share/nginx/html/*.chunk.js)"
|
||||
echo "Writing runtime config to ${main_js}"
|
||||
|
||||
# escape ' and " twice, for both sed and json
|
||||
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
# Documentation
|
||||
|
||||
Check out <https://backstage.io> or see the table of content below.
|
||||
Check out <https://backstage.io> or see the table of contents below.
|
||||
|
||||
- [Architecture and terminology](architecture-terminology.md)
|
||||
- [Getting started](getting-started/README.md)
|
||||
- [Architecture and Terminology](architecture-terminology.md)
|
||||
- [Getting Started](getting-started/README.md)
|
||||
- [References](reference/README.md)
|
||||
- [Publishing](publishing.md)
|
||||
- [Designing for Backstage](design.md)
|
||||
- [How to add an auth provider](auth/add-auth-provider.md)
|
||||
- [How to Add an Auth Provider](auth/add-auth-provider.md)
|
||||
|
||||
@@ -1,5 +1,60 @@
|
||||
# Getting started with Backstage
|
||||
|
||||
## Running Backstage Locally
|
||||
|
||||
To get up and running with a local Backstage to evaluate it, let's clone it off
|
||||
of GitHub and run an initial build. First make sure that you have at least node
|
||||
version 12 installed locally.
|
||||
|
||||
```bash
|
||||
# Start from your local development folder
|
||||
git clone git@github.com:spotify/backstage.git
|
||||
cd backstage
|
||||
|
||||
# Fetch our dependencies and run an initial build
|
||||
yarn install
|
||||
yarn tsc
|
||||
yarn build
|
||||
```
|
||||
|
||||
Phew! Now you have a local repository that's ready to run and to add any open
|
||||
source contributions into.
|
||||
|
||||
We are now going to launch two things: an example Backstage frontend app, and an
|
||||
example Backstage backend that the frontend talks to. You are going to need two
|
||||
terminal windows, both starting from the Backstage project root.
|
||||
|
||||
In the first window, run
|
||||
|
||||
```bash
|
||||
cd packages/backend
|
||||
yarn start
|
||||
```
|
||||
|
||||
That starts up a backend instance on port 7000.
|
||||
|
||||
In the other window, we will first populate the catalog with some nice mock data
|
||||
to look at, and then launch the frontend. These commands are run from the
|
||||
project root, not inside the backend directory.
|
||||
|
||||
```bash
|
||||
yarn lerna run mock-catalog-data
|
||||
yarn start
|
||||
```
|
||||
|
||||
That starts up the frontend on port 3000, and should automatically open a
|
||||
browser window showing it.
|
||||
|
||||
Congratulations! That should be it. Let us know how it went
|
||||
[on discord](https://discord.gg/EBHEGzX), file issues for any
|
||||
[feature](https://github.com/spotify/backstage/issues/new?labels=help+wanted&template=feature_template.md)
|
||||
or
|
||||
[plugin suggestions](https://github.com/spotify/backstage/issues/new?labels=plugin&template=plugin_template.md&title=%5BPlugin%5D+THE+PLUGIN+NAME),
|
||||
or
|
||||
[bugs](https://github.com/spotify/backstage/issues/new?labels=bug&template=bug_template.md)
|
||||
you have, and feel free to
|
||||
[contribute](https://github.com/spotify/backstage/blob/master/CONTRIBUTING.md)!
|
||||
|
||||
## Creating a Plugin
|
||||
|
||||
The value of Backstage grows with every new plugin that gets added. Here is a
|
||||
@@ -7,8 +62,8 @@ collection of tutorials that will guide you through setting up and extending an
|
||||
instance of Backstage with your own plugins.
|
||||
|
||||
- [Development Environment](development-environment.md)
|
||||
- [Create a Backstage plugin](create-a-plugin.md)
|
||||
- [Structure of a plugin](structure-of-a-plugin.md)
|
||||
- [Create a Backstage Plugin](create-a-plugin.md)
|
||||
- [Structure of a Plugin](structure-of-a-plugin.md)
|
||||
- [Utility APIs](utility-apis.md)
|
||||
- Using Backstage components (TODO)
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Backstage Plugins
|
||||
# Create a Backstage Plugin
|
||||
|
||||
A Backstage Plugin adds functionality to Backstage.
|
||||
|
||||
## Create a plugin
|
||||
## Create a Plugin
|
||||
|
||||
To create a new plugin, make sure you've run `yarn install` and installed
|
||||
dependencies, then run the following on your command line (invoking the
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Structure of a newly created plugin
|
||||
# Structure of a Plugin
|
||||
|
||||
Nice, you have a new plugin! We'll soon see how we can develop it into doing
|
||||
great things. But first off, let's look at what we get out of the box.
|
||||
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
# Purpose
|
||||
|
||||
This RFC describes a possible journey of a future Backstage plugin developer as
|
||||
they build a plugin that touches many different aspects of a Backstage. The
|
||||
story invents many new things that are not part of Backstage today, but are
|
||||
things that I'm suggesting we should add as long term or north star goals. The
|
||||
idea is to discuss what parts of the story makes sense to aim for, and what we'd
|
||||
want to do differently or not at all. The "chapters" are numbered to make it a
|
||||
bit easier to comment on parts of the story.
|
||||
|
||||
# The Protagonist
|
||||
|
||||
Sam is an experienced developer that has worked with Backstage for a while, and
|
||||
knows the best practices and tools available to build plugins. Sam also likes
|
||||
music and wants to have a theme tune for every service in Backstage.
|
||||
|
||||
# The End
|
||||
|
||||
Sam built a Spotify plugin for Backstage that allows service owners to define a
|
||||
theme tune for their service. The theme tune plays whenever a user visits the
|
||||
service page in Backstage. The plugin is published to NPM and available for any
|
||||
organization to easily install and add to their Backstage installation.
|
||||
|
||||
# 1. A New Plugin
|
||||
|
||||
Sam chooses to develop this plugin in a standalone project and creates a new
|
||||
plugin using `npx @backstage/cli create-plugin`, which detects that it's not
|
||||
being run in an existing project and therefore creates a separate plugin repo.
|
||||
|
||||
Spinning up the frontend with `yarn start`, Sam goes to work with getting the
|
||||
base functionality of a Spotify web player going. By installing a couple of
|
||||
dependencies and whipping together a nice UI, the player is pretty much done.
|
||||
|
||||
# 2. The Auth Menace
|
||||
|
||||
Sam realizes users need to be authenticated towards the Spotify API to be able
|
||||
to play music, and Backstage doesn't support Spotify login yet. Sam adds the
|
||||
`@backstage/plugin-auth-backend` as local development middleware in the project,
|
||||
and provides the necessary wrapping logic and configuration for the
|
||||
`passport-spotify` strategy. The Spotify auth provider is now available in the
|
||||
local development backend, and by adding a frontend `SpotifyAuth` Utility API
|
||||
that implements the `OAuthApi` type, it's now working in the frontend too.
|
||||
|
||||
```ts
|
||||
const spotifyAuthApiRef = createApiRef<OAuthApi>({
|
||||
id: 'core.auth.spotify',
|
||||
description: 'Provides authentication towards Spotify APIs',
|
||||
});
|
||||
```
|
||||
|
||||
Sam realizes that Spotify auth might be useful to others, and that it would be
|
||||
more convenient if it was a part of the Backstage Core. After submitting and
|
||||
merging a Pull Request with the additions to the
|
||||
`@backstage/plugin-auth-backend` and `@backstage/core` packages, Spotify auth is
|
||||
now available for everyone to use. Since the Backstage Core team also adds it to
|
||||
the public demo server, Sam can now get rid of it in the local setup and rely on
|
||||
the shared development auth providers instead.
|
||||
|
||||
The only thing left now is making sure that users of the plugin provide Spotify
|
||||
auth in the app. Sam ensures this by adding `spotifyAuthApiRef` to the plugin's
|
||||
list of required APIs, as well as listing it in the requirements section in the
|
||||
README.
|
||||
|
||||
```md
|
||||
## Requirements
|
||||
|
||||
This plugin requires the following APIs to function:
|
||||
|
||||
- `spotifyAuthApiRef` from `@backstage/core@^1.1.0`
|
||||
```
|
||||
|
||||
# 3. The Catalog Awakens
|
||||
|
||||
Sam now has a working player and a method for users to log in to listen to
|
||||
music, but the goal is to provide theme songs for services. Sam adds this
|
||||
functionality by defining a new metadata annotation called
|
||||
`sam.wise/spotify-track-id`. The annotation's value is a Spotify track ID and
|
||||
can be defined in a component like this:
|
||||
|
||||
```yaml
|
||||
apiVersion: backstage.io/v1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: my-component
|
||||
annotations:
|
||||
sam.wise/spotify-track-id: '4uLU6hMCjMI75M1A2tKUQC'
|
||||
spec:
|
||||
type: service
|
||||
```
|
||||
|
||||
Sam creates a JSON schema that documents the annotation and allows it to be used
|
||||
in validation and documentation for organizations that choose to adopt the
|
||||
plugin.
|
||||
|
||||
```json
|
||||
{
|
||||
"sam.wise/spotify-track-id": {
|
||||
"$id": "https://raw.githubusercontent.com/sam/backstage-spotify-theme/master/annotation.json#/sam.wise/spotify-track-id",
|
||||
"type": "string",
|
||||
"title": "Spotify Track Annotation",
|
||||
"description": "Spotify track ID to associated with the entity",
|
||||
"examples": ["4uLU6hMCjMI75M1A2tKUQC"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
# 4. The Rise of Widgets
|
||||
|
||||
Sam also wraps the music player in an entity page widget. This allows anyone
|
||||
that wants to use the plugin to add the player to any of their entity layout
|
||||
templates, which will make it show up for every entity of that kind.
|
||||
|
||||
```tsx
|
||||
export const PlayerWidget = plugin.createEntityWidget({
|
||||
component: WebPlayer,
|
||||
locations: ['header', 'card', 'footer'],
|
||||
cardSize: [2, 4],
|
||||
});
|
||||
```
|
||||
|
||||
The widget receives information about the entity in whose page it's being
|
||||
embedded, which makes it simple to grab the track id from the annotations and
|
||||
hook up the player.
|
||||
|
||||
Sam also modifies the standalone plugin development setup to include this new
|
||||
widget inside a basic entity page, adding it to a couple of different places
|
||||
where users of the plugin may want to put the player, just to make sure they all
|
||||
work.
|
||||
|
||||
# 5. The First User
|
||||
|
||||
At this point the only things that anyone that wants to use Sam's plugin needs
|
||||
to do is to add
|
||||
https://raw.githubusercontent.com/sam/backstage-spotify-theme/master/annotation.json#/sam.wise/spotify-track-id
|
||||
to their catalog schema, import and add the `PlayerWidget` on the desired entity
|
||||
template pages, and make sure they're providing Spotify auth.
|
||||
|
||||
Sam soon sees the first "Used by" show up on GitHub, and feedback starts rolling
|
||||
in. Users really like the plugin, and some a requesting the possibility to
|
||||
select a theme tune when creating a new component. Sam jumps on the idea and
|
||||
adds a new creation hook that is exported by the plugin. The hook can be
|
||||
installed either in a single, all, or component templates that match a label. It
|
||||
adds a field as a part of the component creation process with a nice search box
|
||||
that allow users to search for a track that they want to use as the theme tune.
|
||||
|
||||
# 6. Return to the Repo
|
||||
|
||||
Sam is pretty content at this point, but would like to make it easier for users
|
||||
to change the track after creation, preferable using the same search box that
|
||||
was made for the creation form. By adding an edit button to the `PlayerWidget`,
|
||||
and a nice empty state, Sam is able to provide the appropriate hooks in the GUI
|
||||
to open up a search dialog.
|
||||
|
||||
To save the selected track, Sam uses the `RepoApi` to suggest a change to the
|
||||
entity definition file. This will create a Pull Request for organizations that
|
||||
use GitHub, a Merge Request for users of GitLab, and so on.
|
||||
|
||||
```ts
|
||||
const repoApi = useApi(repoApiRef);
|
||||
const alertApi = useApi(alertApiRef);
|
||||
|
||||
const onSave = async () => {
|
||||
const { url } = await repoApi.createChangeRequest({
|
||||
title: `Change theme tune to ${track.title} by ${track.artist}`,
|
||||
changes: [
|
||||
{
|
||||
path: entityYamlPath,
|
||||
content: newEntityYamlContent,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
alertApi.post({ message: `Requested change, ${url}` });
|
||||
};
|
||||
```
|
||||
|
||||
Now it's much simpler for users to change the theme tune, as they no longer need
|
||||
to go look up a track ID and edit a yaml file. Instead, they can now stay inside
|
||||
Backstage and search for the track and request the change from there. In
|
||||
addition, the requested change can be reviewed by the regular process of each
|
||||
organization.
|
||||
|
||||
# 7. The User Strikes Back
|
||||
|
||||
Sam's plugin is pretty popular at this point, and has been picked up and used by
|
||||
many organizations. But some users start voicing concerns that they have too
|
||||
many different hand-crafted annotations in their entity descriptions, and would
|
||||
like to be able to avoid some of them. They really like the theme tunes though,
|
||||
and wish they could keep them without having to put them in the entity
|
||||
description, even if that means it won't go through the regular source control
|
||||
review process.
|
||||
|
||||
One day Sam receives a Pull Request for the plugin. It adds an option to use the
|
||||
Database provided by `@backstage/backend-common` to store the track ID. It's all
|
||||
packaged into a new backend plugin that will also extend the catalog backend
|
||||
with schema and functionality to automatically load the value of the
|
||||
`sam.wise/spotify-track-id` annotation from the backend plugin and database. The
|
||||
backend plugin also extends the common GraphQL schema with a mutation that
|
||||
updates the track ID in the database.
|
||||
|
||||
On the frontend the Pull Request doesn't change much. It defines the save action
|
||||
the was previously using the `RepoApi` in it's own API.
|
||||
|
||||
```ts
|
||||
type ThemeTuneStorageApi = {
|
||||
save(entity: Entity, trackId: string): Promise<void>;
|
||||
};
|
||||
```
|
||||
|
||||
The plugin also provides two different implementations of the API, one that uses
|
||||
the old behavior of the `RepoApi`, and a new one that calls the `GraphQL` API.
|
||||
The new API relies on the `IdentityApi` as a mechanism for authorizing changes,
|
||||
instead of source control reviews. The `IdentityApi` provides a token that is
|
||||
included in the request to the backend, which then must match the owner of the
|
||||
component for which the user is trying to change the theme tune.
|
||||
|
||||
> Author breaking the 4th wall here. I actually think every GraphQL request
|
||||
> should include the ID token of the user, but invented a reason to include it
|
||||
> here anyway.
|
||||
|
||||
The API is selected based on a configuration parameter for the plugin, but
|
||||
defaults to the original `RepoApi` behavior.
|
||||
|
||||
```ts
|
||||
if (config.getBoolean('storeTrackInDatabase')) {
|
||||
return new GraphQLThemeTuneStore(graphqlClient, identityApi, alertApi);
|
||||
} else {
|
||||
return new RepoThemeTuneStore(repoApi, alertApi);
|
||||
}
|
||||
```
|
||||
|
||||
Sam is amazed by the pure awesomeness of this change, replies with a "👍" and
|
||||
hits merge.
|
||||
|
||||
# 8. Attack of the Clones
|
||||
|
||||
Sam just released v1.8.4 of the plugin, and at this point it's so popular that a
|
||||
couple of other plugins has started depending on the `sam.wise/spotify-track-id`
|
||||
annotation. One such plugin being the `spotify-album-art` plugin that can
|
||||
display the album art of the theme tune as the background of the entity header.
|
||||
Sam thinks it's all pretty cool, but doesn't like that the annotation that was
|
||||
once an internal concern of the plugin is now becoming a standard in the
|
||||
community.
|
||||
|
||||
In order to standardize the annotation in Backstage, Sam submits a Pull Request
|
||||
to the Backstage Core repo. The request suggest a new well-known metadata
|
||||
annotation called `spotify.com/track-id`, with the same schema definition as
|
||||
Sam's label, and refers to Sam's own plugin and the `spotify-album-art` plugin
|
||||
as existing usages. The Backstage maintainers merge the Pull Request, after
|
||||
checking with the folks over at Spotify that they're cool with the annotation,
|
||||
and faffing about over some minor grammar mistake in the annotation description.
|
||||
|
||||
With the annotation now available inside Backstage Core, Sam releases v2 of the
|
||||
plugin, which uses the new annotation. It can still consume the old annotation
|
||||
for backwards compatibility, but new users of the plugin no longer need to add
|
||||
the
|
||||
https://raw.githubusercontent.com/sam/backstage-spotify-theme/master/annotation.json#/sam.wise/spotify-track-id
|
||||
extension to their catalog schema, as it's now part of the core schema. The new
|
||||
release of Sam's plugin specifies a dependency on Backstage with a minimum
|
||||
version set to the same release as the one were the annotation was added to the
|
||||
core schema.
|
||||
|
||||
<details>
|
||||
<summary></summary>
|
||||
|
||||
# 9. Revenge of the Sam
|
||||
|
||||
Sam, now in full control of all theme tunes in Backstage, releases v2.0.1, which
|
||||
switches all tracks to 4uLU6hMCjMI75M1A2tKUQC. Sam wanted to do something more
|
||||
nefarious, but since Backstage sandboxes sensitive actions and is mostly
|
||||
read-only with strict CSP, Sam's hands were tied.
|
||||
|
||||
</details>
|
||||
+1
-1
@@ -2,5 +2,5 @@
|
||||
"packages": ["packages/*", "plugins/*"],
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "0.1.1-alpha.7"
|
||||
"version": "0.1.1-alpha.8"
|
||||
}
|
||||
|
||||
+16
-16
@@ -1,28 +1,29 @@
|
||||
{
|
||||
"name": "example-app",
|
||||
"version": "0.1.1-alpha.7",
|
||||
"version": "0.1.1-alpha.8",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.7",
|
||||
"@backstage/core": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-catalog": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-circleci": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-explore": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-home-page": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-lighthouse": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-register-component": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-scaffolder": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-sentry": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-tech-radar": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-welcome": "^0.1.1-alpha.7",
|
||||
"@backstage/theme": "^0.1.1-alpha.7",
|
||||
"@backstage/cli": "^0.1.1-alpha.8",
|
||||
"@backstage/core": "^0.1.1-alpha.8",
|
||||
"@backstage/plugin-catalog": "^0.1.1-alpha.8",
|
||||
"@backstage/plugin-circleci": "^0.1.1-alpha.8",
|
||||
"@backstage/plugin-explore": "^0.1.1-alpha.8",
|
||||
"@backstage/plugin-gitops-profiles": "^0.1.1-alpha.8",
|
||||
"@backstage/plugin-home-page": "^0.1.1-alpha.8",
|
||||
"@backstage/plugin-lighthouse": "^0.1.1-alpha.8",
|
||||
"@backstage/plugin-register-component": "^0.1.1-alpha.8",
|
||||
"@backstage/plugin-scaffolder": "^0.1.1-alpha.8",
|
||||
"@backstage/plugin-sentry": "^0.1.1-alpha.8",
|
||||
"@backstage/plugin-tech-radar": "^0.1.1-alpha.8",
|
||||
"@backstage/plugin-welcome": "^0.1.1-alpha.8",
|
||||
"@backstage/theme": "^0.1.1-alpha.8",
|
||||
"@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-dom": "^5.2.0",
|
||||
"react-router-dom": "6.0.0-alpha.5",
|
||||
"react-use": "^14.2.0",
|
||||
"zen-observable": "^0.8.15"
|
||||
},
|
||||
@@ -34,7 +35,6 @@
|
||||
"@types/jest": "^25.2.2",
|
||||
"@types/jquery": "^3.3.34",
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/react-router-dom": "^5.1.3",
|
||||
"@types/zen-observable": "^0.8.0",
|
||||
"cross-env": "^7.0.0",
|
||||
"cypress": "^4.2.0",
|
||||
|
||||
@@ -18,7 +18,7 @@ import { createApp, AlertDisplay, OAuthRequestDialog } from '@backstage/core';
|
||||
import React, { FC } from 'react';
|
||||
import Root from './components/Root';
|
||||
import * as plugins from './plugins';
|
||||
import apis from './apis';
|
||||
import { apis } from './apis';
|
||||
import { hot } from 'react-hot-loader/root';
|
||||
|
||||
const app = createApp({
|
||||
|
||||
+55
-46
@@ -15,11 +15,11 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
ApiHolder,
|
||||
ApiRegistry,
|
||||
alertApiRef,
|
||||
errorApiRef,
|
||||
AlertApiForwarder,
|
||||
ConfigApi,
|
||||
ErrorApiForwarder,
|
||||
ErrorAlerter,
|
||||
featureFlagsApiRef,
|
||||
@@ -44,57 +44,66 @@ import { techRadarApiRef, TechRadar } from '@backstage/plugin-tech-radar';
|
||||
import { CircleCIApi, circleCIApiRef } from '@backstage/plugin-circleci';
|
||||
import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog';
|
||||
|
||||
const builder = ApiRegistry.builder();
|
||||
import { gitOpsApiRef, GitOpsRestApi } from '@backstage/plugin-gitops-profiles';
|
||||
|
||||
const alertApi = builder.add(alertApiRef, new AlertApiForwarder());
|
||||
const errorApi = builder.add(
|
||||
errorApiRef,
|
||||
new ErrorAlerter(alertApi, new ErrorApiForwarder()),
|
||||
);
|
||||
export const apis = (config: ConfigApi) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Creating APIs for ${config.getString('app.title')}`);
|
||||
|
||||
builder.add(storageApiRef, WebStorage.create({ errorApi }));
|
||||
builder.add(circleCIApiRef, new CircleCIApi());
|
||||
builder.add(featureFlagsApiRef, new FeatureFlags());
|
||||
const builder = ApiRegistry.builder();
|
||||
|
||||
builder.add(lighthouseApiRef, new LighthouseRestApi('http://localhost:3003'));
|
||||
const alertApi = builder.add(alertApiRef, new AlertApiForwarder());
|
||||
const errorApi = builder.add(
|
||||
errorApiRef,
|
||||
new ErrorAlerter(alertApi, new ErrorApiForwarder()),
|
||||
);
|
||||
|
||||
const oauthRequestApi = builder.add(
|
||||
oauthRequestApiRef,
|
||||
new OAuthRequestManager(),
|
||||
);
|
||||
builder.add(storageApiRef, WebStorage.create({ errorApi }));
|
||||
builder.add(circleCIApiRef, new CircleCIApi());
|
||||
builder.add(featureFlagsApiRef, new FeatureFlags());
|
||||
|
||||
builder.add(
|
||||
googleAuthApiRef,
|
||||
GoogleAuth.create({
|
||||
apiOrigin: 'http://localhost:7000',
|
||||
basePath: '/auth/',
|
||||
oauthRequestApi,
|
||||
}),
|
||||
);
|
||||
builder.add(lighthouseApiRef, new LighthouseRestApi('http://localhost:3003'));
|
||||
|
||||
builder.add(
|
||||
githubAuthApiRef,
|
||||
GithubAuth.create({
|
||||
apiOrigin: 'http://localhost:7000',
|
||||
basePath: '/auth/',
|
||||
oauthRequestApi,
|
||||
}),
|
||||
);
|
||||
const oauthRequestApi = builder.add(
|
||||
oauthRequestApiRef,
|
||||
new OAuthRequestManager(),
|
||||
);
|
||||
|
||||
builder.add(
|
||||
techRadarApiRef,
|
||||
new TechRadar({
|
||||
width: 1500,
|
||||
height: 800,
|
||||
}),
|
||||
);
|
||||
builder.add(
|
||||
googleAuthApiRef,
|
||||
GoogleAuth.create({
|
||||
apiOrigin: 'http://localhost:7000',
|
||||
basePath: '/auth/',
|
||||
oauthRequestApi,
|
||||
}),
|
||||
);
|
||||
|
||||
builder.add(
|
||||
catalogApiRef,
|
||||
new CatalogClient({
|
||||
apiOrigin: 'http://localhost:3000',
|
||||
basePath: '/catalog/api',
|
||||
}),
|
||||
);
|
||||
builder.add(
|
||||
githubAuthApiRef,
|
||||
GithubAuth.create({
|
||||
apiOrigin: 'http://localhost:7000',
|
||||
basePath: '/auth/',
|
||||
oauthRequestApi,
|
||||
}),
|
||||
);
|
||||
|
||||
export default builder.build() as ApiHolder;
|
||||
builder.add(
|
||||
techRadarApiRef,
|
||||
new TechRadar({
|
||||
width: 1500,
|
||||
height: 800,
|
||||
}),
|
||||
);
|
||||
|
||||
builder.add(
|
||||
catalogApiRef,
|
||||
new CatalogClient({
|
||||
apiOrigin: 'http://localhost:3000',
|
||||
basePath: '/catalog/api',
|
||||
}),
|
||||
);
|
||||
|
||||
builder.add(gitOpsApiRef, new GitOpsRestApi('http://localhost:3008'));
|
||||
|
||||
return builder.build();
|
||||
};
|
||||
|
||||
@@ -19,6 +19,9 @@ import PropTypes from 'prop-types';
|
||||
import { Link, makeStyles } from '@material-ui/core';
|
||||
import HomeIcon from '@material-ui/icons/Home';
|
||||
import ExploreIcon from '@material-ui/icons/Explore';
|
||||
import BuildIcon from '@material-ui/icons/BuildRounded';
|
||||
import RuleIcon from '@material-ui/icons/AssignmentTurnedIn';
|
||||
import MapIcon from '@material-ui/icons/MyLocation';
|
||||
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
|
||||
import LogoFull from './LogoFull';
|
||||
import LogoIcon from './LogoIcon';
|
||||
@@ -88,6 +91,9 @@ const Root: FC<{}> = ({ children }) => (
|
||||
<SidebarItem icon={CreateComponentIcon} to="/create" text="Create..." />
|
||||
{/* End global nav */}
|
||||
<SidebarDivider />
|
||||
<SidebarItem icon={MapIcon} to="/tech-radar" text="Tech Radar" />
|
||||
<SidebarItem icon={RuleIcon} to="/lighthouse" text="Lighthouse" />
|
||||
<SidebarItem icon={BuildIcon} to="/circleci" text="CircleCI" />
|
||||
<SidebarSpace />
|
||||
<SidebarDivider />
|
||||
<SidebarThemeToggle />
|
||||
|
||||
@@ -23,3 +23,4 @@ export { plugin as Explore } from '@backstage/plugin-explore';
|
||||
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';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/backend-common",
|
||||
"description": "Common functionality library for Backstage backends",
|
||||
"version": "0.1.1-alpha.7",
|
||||
"version": "0.1.1-alpha.8",
|
||||
"main": "dist",
|
||||
"types": "src/index.ts",
|
||||
"private": false,
|
||||
@@ -37,7 +37,7 @@
|
||||
"winston": "^3.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.7",
|
||||
"@backstage/cli": "^0.1.1-alpha.8",
|
||||
"@types/compression": "^1.7.0",
|
||||
"@types/cors": "^2.8.6",
|
||||
"@types/express": "^4.17.6",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import winston from 'winston';
|
||||
import * as winston from 'winston';
|
||||
import { getRootLogger, setRootLogger } from './rootLogger';
|
||||
|
||||
describe('rootLogger', () => {
|
||||
|
||||
@@ -14,9 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import winston, { Logger } from 'winston';
|
||||
import * as winston from 'winston';
|
||||
|
||||
let rootLogger: Logger = winston.createLogger({
|
||||
let rootLogger: winston.Logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format:
|
||||
process.env.NODE_ENV === 'production'
|
||||
@@ -35,10 +35,10 @@ let rootLogger: Logger = winston.createLogger({
|
||||
],
|
||||
});
|
||||
|
||||
export function getRootLogger(): Logger {
|
||||
export function getRootLogger(): winston.Logger {
|
||||
return rootLogger;
|
||||
}
|
||||
|
||||
export function setRootLogger(newLogger: Logger) {
|
||||
export function setRootLogger(newLogger: winston.Logger) {
|
||||
rootLogger = newLogger;
|
||||
}
|
||||
|
||||
@@ -15,12 +15,12 @@
|
||||
*/
|
||||
|
||||
import { PassThrough } from 'stream';
|
||||
import winston, { Logger } from 'winston';
|
||||
import * as winston from 'winston';
|
||||
|
||||
/**
|
||||
* A logger that just throws away all messages.
|
||||
*/
|
||||
export function getVoidLogger(): Logger {
|
||||
export function getVoidLogger(): winston.Logger {
|
||||
return winston.createLogger({
|
||||
transports: [new winston.transports.Stream({ stream: new PassThrough() })],
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import winston from 'winston';
|
||||
import * as winston from 'winston';
|
||||
import { requestLoggingHandler } from './requestLoggingHandler';
|
||||
|
||||
describe('requestLoggingHandler', () => {
|
||||
|
||||
@@ -39,19 +39,14 @@ If you want to use the catalog functionality, you need to add so called location
|
||||
to the backend. These are places where the backend can find some entity descriptor
|
||||
data to consume and serve.
|
||||
|
||||
To get started, you can issue the following after starting the backend:
|
||||
To get started, you can issue the following after starting the backend, from inside
|
||||
the `plugins/catalog-backend` directory:
|
||||
|
||||
```bash
|
||||
curl -i \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"github","target":"https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/fixtures/two_components.yaml"}' \
|
||||
localhost:7000/catalog/locations
|
||||
yarn mock-catalog-data
|
||||
```
|
||||
|
||||
After a short while, you should start seeing data on `localhost:7000/catalog/entities`.
|
||||
|
||||
If you changed the `type` to `file` in the command above, and set the `target`
|
||||
to the absolute path of a YAML file on disk, you could consume your own experimental data.
|
||||
You should then start seeing data on `localhost:7000/catalog/entities`.
|
||||
|
||||
The catalog currently runs in-memory only, so feel free to try it out, but it will
|
||||
need to be re-populated on next startup.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "example-backend",
|
||||
"version": "0.1.1-alpha.7",
|
||||
"version": "0.1.1-alpha.8",
|
||||
"main": "dist",
|
||||
"types": "src/index.ts",
|
||||
"private": true,
|
||||
@@ -17,13 +17,13 @@
|
||||
"migrate:create": "knex migrate:make -x ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.1.1-alpha.7",
|
||||
"@backstage/catalog-model": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-auth-backend": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-catalog-backend": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-identity-backend": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.7",
|
||||
"@backstage/plugin-sentry-backend": "^0.1.1-alpha.7",
|
||||
"@backstage/backend-common": "^0.1.1-alpha.8",
|
||||
"@backstage/catalog-model": "^0.1.1-alpha.8",
|
||||
"@backstage/plugin-auth-backend": "^0.1.1-alpha.8",
|
||||
"@backstage/plugin-catalog-backend": "^0.1.1-alpha.8",
|
||||
"@backstage/plugin-identity-backend": "^0.1.1-alpha.8",
|
||||
"@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.8",
|
||||
"@backstage/plugin-sentry-backend": "^0.1.1-alpha.8",
|
||||
"esm": "^3.2.25",
|
||||
"express": "^4.17.1",
|
||||
"knex": "^0.21.1",
|
||||
@@ -31,7 +31,7 @@
|
||||
"winston": "^3.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.7",
|
||||
"@backstage/cli": "^0.1.1-alpha.8",
|
||||
"@types/express": "^4.17.6",
|
||||
"@types/express-serve-static-core": "^4.17.5",
|
||||
"@types/helmet": "^0.0.47",
|
||||
|
||||
@@ -74,6 +74,8 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
module.hot?.accept();
|
||||
main().catch(error => {
|
||||
console.error(`Backend failed to start up, ${error}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ export default async function createPlugin({
|
||||
}: PluginEnvironment) {
|
||||
const locationReader = new LocationReaders(logger);
|
||||
|
||||
const db = await DatabaseManager.createDatabase(database, logger);
|
||||
const db = await DatabaseManager.createDatabase(database, { logger });
|
||||
const entitiesCatalog = new DatabaseEntitiesCatalog(db);
|
||||
const locationsCatalog = new DatabaseLocationsCatalog(db);
|
||||
const higherOrderOperation = new HigherOrderOperations(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@backstage/catalog-model",
|
||||
"version": "0.1.1-alpha.7",
|
||||
"version": "0.1.1-alpha.8",
|
||||
"main": "dist/index.cjs.js",
|
||||
"module": "dist/index.esm.js",
|
||||
"main:src": "src/index.ts",
|
||||
@@ -26,7 +26,7 @@
|
||||
"yup": "^0.28.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.7",
|
||||
"@backstage/cli": "^0.1.1-alpha.8",
|
||||
"@types/jest": "^25.2.2",
|
||||
"@types/lodash": "^4.14.151",
|
||||
"@types/yup": "^0.28.2",
|
||||
|
||||
@@ -15,15 +15,18 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
DefaultNamespaceEntityPolicy,
|
||||
Entity,
|
||||
FieldFormatEntityPolicy,
|
||||
NoForeignRootFieldsEntityPolicy,
|
||||
ReservedFieldsEntityPolicy,
|
||||
SchemaValidEntityPolicy,
|
||||
} from './entity';
|
||||
import { ComponentV1beta1Policy } from './kinds';
|
||||
import {
|
||||
ComponentEntityV1beta1Policy,
|
||||
LocationEntityV1beta1Policy,
|
||||
} from './kinds';
|
||||
import { EntityPolicy } from './types';
|
||||
import { DefaultNamespaceEntityPolicy } from './entity/policies/DefaultNamespaceEntityPolicy';
|
||||
|
||||
// Helper that requires that all of a set of policies can be successfully
|
||||
// applied
|
||||
@@ -68,7 +71,10 @@ export class EntityPolicies implements EntityPolicy {
|
||||
new FieldFormatEntityPolicy(),
|
||||
new ReservedFieldsEntityPolicy(),
|
||||
]),
|
||||
EntityPolicies.anyOf([new ComponentV1beta1Policy()]),
|
||||
EntityPolicies.anyOf([
|
||||
new ComponentEntityV1beta1Policy(),
|
||||
new LocationEntityV1beta1Policy(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { DefaultNamespaceEntityPolicy } from './DefaultNamespaceEntityPolicy';
|
||||
export { FieldFormatEntityPolicy } from './FieldFormatEntityPolicy';
|
||||
export { NoForeignRootFieldsEntityPolicy } from './NoForeignRootFieldsEntityPolicy';
|
||||
export { ReservedFieldsEntityPolicy } from './ReservedFieldsEntityPolicy';
|
||||
|
||||
+5
-16
@@ -15,33 +15,25 @@
|
||||
*/
|
||||
|
||||
import * as yup from 'yup';
|
||||
import type { Entity, EntityMeta } from '../entity/Entity';
|
||||
import type { Entity } from '../entity/Entity';
|
||||
import type { EntityPolicy } from '../types';
|
||||
|
||||
const API_VERSION = 'backstage.io/v1beta1';
|
||||
const KIND = 'Component';
|
||||
|
||||
export interface ComponentV1beta1 extends Entity {
|
||||
export interface ComponentEntityV1beta1 extends Entity {
|
||||
apiVersion: typeof API_VERSION;
|
||||
kind: typeof KIND;
|
||||
metadata: EntityMeta & {
|
||||
name: string;
|
||||
};
|
||||
spec: {
|
||||
type: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class ComponentV1beta1Policy implements EntityPolicy {
|
||||
export class ComponentEntityV1beta1Policy implements EntityPolicy {
|
||||
private schema: yup.Schema<any>;
|
||||
|
||||
constructor() {
|
||||
this.schema = yup.object<Partial<ComponentV1beta1>>({
|
||||
metadata: yup
|
||||
.object({
|
||||
name: yup.string().required(),
|
||||
})
|
||||
.required(),
|
||||
this.schema = yup.object<Partial<ComponentEntityV1beta1>>({
|
||||
spec: yup
|
||||
.object({
|
||||
type: yup.string().required(),
|
||||
@@ -51,10 +43,7 @@ export class ComponentV1beta1Policy implements EntityPolicy {
|
||||
}
|
||||
|
||||
async enforce(envelope: Entity): Promise<Entity> {
|
||||
if (
|
||||
envelope.apiVersion !== 'backstage.io/v1beta1' ||
|
||||
envelope.kind !== 'Component'
|
||||
) {
|
||||
if (envelope.apiVersion !== API_VERSION || envelope.kind !== KIND) {
|
||||
throw new Error('Unsupported apiVersion / kind');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* 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 * as yup from 'yup';
|
||||
import type { Entity } from '../entity/Entity';
|
||||
import type { EntityPolicy } from '../types';
|
||||
|
||||
const API_VERSION = 'backstage.io/v1beta1';
|
||||
const KIND = 'Location';
|
||||
|
||||
export interface LocationEntityV1beta1 extends Entity {
|
||||
apiVersion: typeof API_VERSION;
|
||||
kind: typeof KIND;
|
||||
spec: {
|
||||
type: string;
|
||||
target?: string;
|
||||
targets?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export class LocationEntityV1beta1Policy implements EntityPolicy {
|
||||
private schema: yup.Schema<any>;
|
||||
|
||||
constructor() {
|
||||
this.schema = yup.object<Partial<LocationEntityV1beta1>>({
|
||||
spec: yup
|
||||
.object({
|
||||
type: yup.string().required(),
|
||||
target: yup.string().notRequired(),
|
||||
targets: yup.array(yup.string()).notRequired(),
|
||||
})
|
||||
.required(),
|
||||
});
|
||||
}
|
||||
|
||||
async enforce(envelope: Entity): Promise<Entity> {
|
||||
if (envelope.apiVersion !== API_VERSION || envelope.kind !== KIND) {
|
||||
throw new Error('Unsupported apiVersion / kind');
|
||||
}
|
||||
|
||||
return await this.schema.validate(envelope, { strict: true });
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,13 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { ComponentEntityV1beta1Policy } from './ComponentEntityV1beta1';
|
||||
export type {
|
||||
ComponentV1beta1,
|
||||
ComponentV1beta1 as Component,
|
||||
} from './ComponentV1beta1';
|
||||
export { ComponentV1beta1Policy } from './ComponentV1beta1';
|
||||
ComponentEntityV1beta1 as ComponentEntity,
|
||||
ComponentEntityV1beta1,
|
||||
} from './ComponentEntityV1beta1';
|
||||
export { LocationEntityV1beta1Policy } from './LocationEntityV1beta1';
|
||||
export type {
|
||||
LocationEntityV1beta1 as LocationEntity,
|
||||
LocationEntityV1beta1,
|
||||
} from './LocationEntityV1beta1';
|
||||
|
||||
@@ -52,6 +52,8 @@ module.exports = {
|
||||
'warn',
|
||||
{ vars: 'all', args: 'after-used', ignoreRestSiblings: true },
|
||||
],
|
||||
// Avoid cross-package imports
|
||||
'no-restricted-imports': [2, { patterns: ['**/../../**/*/src/**'] }],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
|
||||
@@ -57,18 +57,19 @@ module.exports = {
|
||||
'warn',
|
||||
{ vars: 'all', args: 'after-used', ignoreRestSiblings: true },
|
||||
],
|
||||
|
||||
// Importing the entire MUI icons packages kills build performance as the list of icons is huge.
|
||||
'no-restricted-imports': [
|
||||
2,
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
// Importing the entire MUI icons packages kills build performance as the list of icons is huge.
|
||||
name: '@material-ui/icons',
|
||||
message: "Please import '@material-ui/icons/<Icon>' instead.",
|
||||
},
|
||||
...require('module').builtinModules,
|
||||
],
|
||||
// Avoid cross-package imports
|
||||
patterns: ['**/../../**/*/src/**'],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -41,7 +41,7 @@ async function getConfig() {
|
||||
for (const pkg of packages) {
|
||||
const mainSrc = pkg.get('main:src');
|
||||
if (mainSrc) {
|
||||
moduleNameMapper[pkg.name] = path.resolve(pkg.location, mainSrc);
|
||||
moduleNameMapper[`^${pkg.name}$`] = path.resolve(pkg.location, mainSrc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/cli",
|
||||
"description": "CLI for developing Backstage plugins and apps",
|
||||
"version": "0.1.1-alpha.7",
|
||||
"version": "0.1.1-alpha.8",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
@@ -69,7 +69,7 @@
|
||||
"replace-in-file": "^6.0.0",
|
||||
"rollup": "2.10.x",
|
||||
"rollup-plugin-dts": "^1.4.6",
|
||||
"rollup-plugin-esbuild": "^1.4.1",
|
||||
"rollup-plugin-esbuild": "^2.0.0",
|
||||
"rollup-plugin-image-files": "^1.4.2",
|
||||
"rollup-plugin-peer-deps-external": "^2.2.2",
|
||||
"rollup-plugin-postcss": "^3.1.1",
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
printFileSizesAfterBuild,
|
||||
} from 'react-dev-utils/FileSizeReporter';
|
||||
import formatWebpackMessages from 'react-dev-utils/formatWebpackMessages';
|
||||
import { createConfig } from './config';
|
||||
import { createConfig, resolveBaseUrl } from './config';
|
||||
import { BuildOptions } from './types';
|
||||
import { resolveBundlingPaths } from './paths';
|
||||
import chalk from 'chalk';
|
||||
@@ -41,6 +41,7 @@ export async function buildBundle(options: BuildOptions) {
|
||||
checksEnabled: false,
|
||||
isDev: false,
|
||||
isBackend: false,
|
||||
baseUrl: resolveBaseUrl(options.config),
|
||||
});
|
||||
const compiler = webpack(config);
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import StartServerPlugin from 'start-server-webpack-plugin';
|
||||
import webpack from 'webpack';
|
||||
import nodeExternals from 'webpack-node-externals';
|
||||
import { optimization } from './optimization';
|
||||
import { Config } from '@backstage/config';
|
||||
import { BundlingPaths } from './paths';
|
||||
import { transforms } from './transforms';
|
||||
import { BundlingOptions } from './types';
|
||||
@@ -30,6 +31,18 @@ import { BundlingOptions } from './types';
|
||||
// import evalSourceMapMiddleware from 'react-dev-utils/evalSourceMapMiddleware';
|
||||
// import WatchMissingNodeModulesPlugin from 'react-dev-utils/WatchMissingNodeModulesPlugin';
|
||||
|
||||
export function resolveBaseUrl(config: Config): URL {
|
||||
const baseUrl = config.getString('app.baseUrl');
|
||||
if (!baseUrl) {
|
||||
throw new Error('app.baseUrl must be set in config');
|
||||
}
|
||||
try {
|
||||
return new URL(baseUrl, 'http://localhost:3000');
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid app.baseUrl, ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function createConfig(
|
||||
paths: BundlingPaths,
|
||||
options: BundlingOptions,
|
||||
|
||||
@@ -15,25 +15,17 @@
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import yn from 'yn';
|
||||
import webpack from 'webpack';
|
||||
import WebpackDevServer from 'webpack-dev-server';
|
||||
import openBrowser from 'react-dev-utils/openBrowser';
|
||||
import { choosePort, prepareUrls } from 'react-dev-utils/WebpackDevServerUtils';
|
||||
import { createConfig } from './config';
|
||||
import { createConfig, resolveBaseUrl } from './config';
|
||||
import { ServeOptions } from './types';
|
||||
import { resolveBundlingPaths } from './paths';
|
||||
|
||||
export async function serveBundle(options: ServeOptions) {
|
||||
const host = process.env.HOST ?? '0.0.0.0';
|
||||
const defaultPort = parseInt(process.env.PORT ?? '', 10) || 3000;
|
||||
const url = resolveBaseUrl(options.config);
|
||||
|
||||
const port = await choosePort(host, defaultPort);
|
||||
if (!port) {
|
||||
throw new Error(`Invalid or no port set: '${port}'`);
|
||||
}
|
||||
|
||||
const protocol = yn(process.env.HTTPS, { default: false }) ? 'https' : 'http';
|
||||
const port = Number(url.port) || (url.protocol === 'https:' ? 443 : 80);
|
||||
|
||||
const paths = resolveBundlingPaths(options);
|
||||
const pkgPath = paths.targetPackageJson;
|
||||
@@ -42,6 +34,7 @@ export async function serveBundle(options: ServeOptions) {
|
||||
...options,
|
||||
isDev: true,
|
||||
isBackend: false,
|
||||
baseUrl: url,
|
||||
});
|
||||
const compiler = webpack(config);
|
||||
|
||||
@@ -53,33 +46,20 @@ export async function serveBundle(options: ServeOptions) {
|
||||
historyApiFallback: true,
|
||||
clientLogLevel: 'warning',
|
||||
stats: 'errors-warnings',
|
||||
https: protocol === 'https',
|
||||
host,
|
||||
https: url.protocol === 'https:',
|
||||
host: url.hostname,
|
||||
port,
|
||||
proxy: pkg.proxy,
|
||||
});
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
server.listen(port, host, (err?: Error) => {
|
||||
server.listen(port, url.hostname, (err?: Error) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: This signature is available in 10.2.1 but doesn't have types published yet
|
||||
const latestPrepareUrls = prepareUrls as (
|
||||
protocol: string,
|
||||
host: string,
|
||||
port: number,
|
||||
path?: string,
|
||||
) => ReturnType<typeof prepareUrls>;
|
||||
const urls = latestPrepareUrls(
|
||||
protocol,
|
||||
host,
|
||||
port,
|
||||
config.output?.publicPath,
|
||||
);
|
||||
openBrowser(urls.localUrlForBrowser);
|
||||
openBrowser(url.href);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ export type BundlingOptions = {
|
||||
config: Config;
|
||||
appConfigs: AppConfig[];
|
||||
isBackend: boolean;
|
||||
baseUrl: URL;
|
||||
};
|
||||
|
||||
export type ServeOptions = BundlingPathsOptions & {
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import chalk from 'chalk';
|
||||
import fs from 'fs-extra';
|
||||
import { relative as relativePath } from 'path';
|
||||
import peerDepsExternal from 'rollup-plugin-peer-deps-external';
|
||||
@@ -42,7 +43,9 @@ export const makeConfigs = async (
|
||||
if (!declarationsExist) {
|
||||
const path = relativePath(paths.targetDir, typesInput);
|
||||
throw new Error(
|
||||
`No declaration files found at ${path}, be sure to run tsc to generate .d.ts files before packaging`,
|
||||
`No declaration files found at ${path}, be sure to run ${chalk.bgRed.white(
|
||||
'yarn tsc',
|
||||
)} to generate .d.ts files before packaging`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,6 +53,7 @@ export const makeConfigs = async (
|
||||
|
||||
if (options.outputs.has(Output.cjs) || options.outputs.has(Output.esm)) {
|
||||
const output = new Array<OutputOptions>();
|
||||
const mainFields = ['module', 'main'];
|
||||
|
||||
if (options.outputs.has(Output.cjs)) {
|
||||
output.push({
|
||||
@@ -66,6 +70,8 @@ export const makeConfigs = async (
|
||||
chunkFileNames: 'esm/[name]-[hash].js',
|
||||
format: 'module',
|
||||
});
|
||||
// Assume we're building for the browser if ESM output is included
|
||||
mainFields.unshift('browser');
|
||||
}
|
||||
|
||||
configs.push({
|
||||
@@ -77,9 +83,7 @@ export const makeConfigs = async (
|
||||
peerDepsExternal({
|
||||
includeDependencies: true,
|
||||
}),
|
||||
resolve({
|
||||
mainFields: ['browser', 'module', 'main'],
|
||||
}),
|
||||
resolve({ mainFields }),
|
||||
commonjs({
|
||||
include: ['node_modules/**', '../../node_modules/**'],
|
||||
exclude: ['**/*.stories.*', '**/*.test.*'],
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"plugin-welcome": "0.0.0",
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-router-dom": "6.0.0-alpha.5",
|
||||
"react-use": "^14.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -21,7 +21,6 @@
|
||||
"@testing-library/user-event": "^10.2.4",
|
||||
"@types/jest": "^25.2.2",
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/react-router-dom": "^5.1.3",
|
||||
"@types/testing-library__jest-dom": "^5.0.4",
|
||||
"cross-env": "^7.0.0",
|
||||
"cypress": "^4.2.0",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"react": "^16.13.1",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-use": "^14.2.0",
|
||||
"react-router-dom": "^5.2.0"
|
||||
"react-router-dom": "6.0.0-alpha.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^{{version}}",
|
||||
|
||||
@@ -32,11 +32,13 @@
|
||||
"dependencies": {
|
||||
"@backstage/config": "^0.1.1-alpha.7",
|
||||
"fs-extra": "^9.0.0",
|
||||
"yaml": "^1.9.2"
|
||||
"yaml": "^1.9.2",
|
||||
"yup": "^0.28.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^25.2.2",
|
||||
"@types/node": "^12.0.0"
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/yup": "^0.28.2"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*.{js,d.ts}"
|
||||
|
||||
@@ -15,4 +15,4 @@
|
||||
*/
|
||||
|
||||
export { loadConfig } from './loader';
|
||||
export type { LoadConfigOptions } from './types';
|
||||
export type { LoadConfigOptions } from './loader';
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { readEnv } from './loader';
|
||||
import { readEnv } from './env';
|
||||
|
||||
describe('readEnv', () => {
|
||||
it('should return empty config for empty env', () => {
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* 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 { AppConfig, JsonObject } from '@backstage/config';
|
||||
|
||||
const ENV_PREFIX = 'APP_CONFIG_';
|
||||
|
||||
// Update the same pattern in config package if this is changed
|
||||
const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i;
|
||||
|
||||
/**
|
||||
* Read runtime configuration from the environment.
|
||||
*
|
||||
* Only environment variables prefixed with APP_CONFIG_ will be considered.
|
||||
*
|
||||
* For each variable, the prefix will be removed, and rest of the key will
|
||||
* be split by '_'. Each part will then be used as keys to build up a nested
|
||||
* config object structure. The treatment of the entire environment variable
|
||||
* is case-sensitive.
|
||||
*
|
||||
* The value of the variable should be JSON serialized, as it will be parsed
|
||||
* and the type will be kept intact. For example "true" and true are treated
|
||||
* differently, as well as "42" and 42.
|
||||
*
|
||||
* For example, to set the config app.title to "My Title", use the following:
|
||||
*
|
||||
* APP_CONFIG_app_title='"My Title"'
|
||||
*/
|
||||
export function readEnv(env: {
|
||||
[name: string]: string | undefined;
|
||||
}): AppConfig[] {
|
||||
let config: JsonObject | undefined = undefined;
|
||||
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
if (name.startsWith(ENV_PREFIX)) {
|
||||
const key = name.replace(ENV_PREFIX, '');
|
||||
const keyParts = key.split('_');
|
||||
|
||||
let obj = (config = config ?? {});
|
||||
for (const [index, part] of keyParts.entries()) {
|
||||
if (!CONFIG_KEY_PART_PATTERN.test(part)) {
|
||||
throw new TypeError(`Invalid env config key '${key}'`);
|
||||
}
|
||||
if (index < keyParts.length - 1) {
|
||||
obj = (obj[part] = obj[part] ?? {}) as JsonObject;
|
||||
if (typeof obj !== 'object' || Array.isArray(obj)) {
|
||||
const subKey = keyParts.slice(0, index + 1).join('_');
|
||||
throw new TypeError(
|
||||
`Could not nest config for key '${key}' under existing value '${subKey}'`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (part in obj) {
|
||||
throw new TypeError(
|
||||
`Refusing to override existing config at key '${key}'`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const parsedValue = JSON.parse(value);
|
||||
if (parsedValue === null) {
|
||||
throw new Error('value may not be null');
|
||||
}
|
||||
obj[part] = parsedValue;
|
||||
} catch (error) {
|
||||
throw new TypeError(
|
||||
`Failed to parse JSON-serialized config value for key '${key}', ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config ? [config] : [];
|
||||
}
|
||||
@@ -13,13 +13,8 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { EntityMeta } from '@backstage/catalog-model';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export type Component = {
|
||||
name: string;
|
||||
namespace?: string;
|
||||
kind: string;
|
||||
metadata: EntityMeta;
|
||||
description: ReactNode;
|
||||
};
|
||||
export { resolveStaticConfig } from './resolver';
|
||||
export { readConfigFile } from './reader';
|
||||
export { readEnv } from './env';
|
||||
export { readSecret } from './secrets';
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* 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 { readConfigFile } from './reader';
|
||||
import { ReaderContext, ReadSecretFunc } from './types';
|
||||
|
||||
function memoryFiles(files: { [path: string]: string }) {
|
||||
return async (path: string) => {
|
||||
if (path in files) {
|
||||
return files[path];
|
||||
}
|
||||
throw new Error(`File not found, ${path}`);
|
||||
};
|
||||
}
|
||||
|
||||
describe('readConfigFile', () => {
|
||||
it('should read a plain config file', async () => {
|
||||
const readFile = memoryFiles({
|
||||
'./app-config.yaml':
|
||||
'app: { title: "Test", x: 1, y: [null, true], z: null }',
|
||||
});
|
||||
|
||||
const config = readConfigFile('./app-config.yaml', {
|
||||
readFile,
|
||||
} as ReaderContext);
|
||||
|
||||
await expect(config).resolves.toEqual({
|
||||
app: {
|
||||
title: 'Test',
|
||||
x: 1,
|
||||
y: [true],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should error out if the config file has invalid syntax', async () => {
|
||||
const readFile = memoryFiles({
|
||||
'./app-config.yaml': 'app: { title: ]',
|
||||
});
|
||||
|
||||
const config = readConfigFile('./app-config.yaml', {
|
||||
readFile,
|
||||
} as ReaderContext);
|
||||
|
||||
await expect(config).rejects.toThrow('Flow map contains an unexpected ]');
|
||||
});
|
||||
|
||||
it('should error out if config is not an object', async () => {
|
||||
const readFile = memoryFiles({
|
||||
'./app-config.yaml': '[]',
|
||||
});
|
||||
|
||||
const config = readConfigFile('./app-config.yaml', {
|
||||
readFile,
|
||||
} as ReaderContext);
|
||||
|
||||
await expect(config).rejects.toThrow('Expected object at config root');
|
||||
});
|
||||
|
||||
it('should read secrets', async () => {
|
||||
const readFile = memoryFiles({
|
||||
'./app-config.yaml': 'app: { $secret: { file: "./my-secret" } }',
|
||||
});
|
||||
const readSecret = jest.fn().mockResolvedValue('secret');
|
||||
|
||||
const config = readConfigFile('./app-config.yaml', {
|
||||
env: {},
|
||||
readFile,
|
||||
readSecret: readSecret as ReadSecretFunc,
|
||||
});
|
||||
|
||||
await expect(config).resolves.toEqual({
|
||||
app: 'secret',
|
||||
});
|
||||
expect(readSecret).toHaveBeenCalledWith({ file: './my-secret' });
|
||||
});
|
||||
|
||||
it('should require secrets to be objects', async () => {
|
||||
const readFile = memoryFiles({
|
||||
'./app-config.yaml': 'app: { $secret: ["wrong-type"] }',
|
||||
});
|
||||
const readSecret = jest.fn().mockResolvedValue('secret');
|
||||
|
||||
const config = readConfigFile('./app-config.yaml', {
|
||||
env: {},
|
||||
readFile,
|
||||
readSecret: readSecret as ReadSecretFunc,
|
||||
});
|
||||
|
||||
expect(readSecret).not.toHaveBeenCalled();
|
||||
await expect(config).rejects.toThrow(
|
||||
'Expected object at secret .app.$secret',
|
||||
);
|
||||
});
|
||||
|
||||
it('should forward secret reading errors', async () => {
|
||||
const readFile = memoryFiles({
|
||||
'./app-config.yaml': 'app: { $secret: {} }',
|
||||
});
|
||||
const readSecret = jest.fn().mockRejectedValue(new Error('NOPE'));
|
||||
|
||||
const config = readConfigFile('./app-config.yaml', {
|
||||
env: {},
|
||||
readFile,
|
||||
readSecret: readSecret as ReadSecretFunc,
|
||||
});
|
||||
|
||||
await expect(config).rejects.toThrow('Invalid secret at .app: NOPE');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* 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 yaml from 'yaml';
|
||||
import { isObject } from './utils';
|
||||
import { JsonValue, JsonObject } from '@backstage/config';
|
||||
import { ReaderContext } from './types';
|
||||
|
||||
/**
|
||||
* Reads and parses, and validates, and transforms a single config file.
|
||||
* The transformation rewrites any special values, like the $secret key.
|
||||
*/
|
||||
export async function readConfigFile(filePath: string, ctx: ReaderContext) {
|
||||
const configYaml = await ctx.readFile(filePath);
|
||||
const config = yaml.parse(configYaml);
|
||||
|
||||
async function transform(
|
||||
obj: JsonValue,
|
||||
path: string,
|
||||
): Promise<JsonValue | undefined> {
|
||||
if (typeof obj !== 'object') {
|
||||
return obj;
|
||||
} else if (obj === null) {
|
||||
return undefined;
|
||||
} else if (Array.isArray(obj)) {
|
||||
const arr = new Array<JsonValue>();
|
||||
|
||||
for (const [index, value] of obj.entries()) {
|
||||
const out = await transform(value, `${path}[${index}]`);
|
||||
if (out !== undefined) {
|
||||
arr.push(out);
|
||||
}
|
||||
}
|
||||
|
||||
return arr;
|
||||
}
|
||||
|
||||
if ('$secret' in obj) {
|
||||
if (!isObject(obj.$secret)) {
|
||||
throw TypeError(`Expected object at secret ${path}.$secret`);
|
||||
}
|
||||
|
||||
try {
|
||||
return await ctx.readSecret(obj.$secret);
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid secret at ${path}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const out: JsonObject = {};
|
||||
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const result = await transform(value, `${path}.${key}`);
|
||||
if (result !== undefined) {
|
||||
out[key] = result;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
const finalConfig = await transform(config, '');
|
||||
if (!isObject(finalConfig)) {
|
||||
throw new TypeError('Expected object at config root');
|
||||
}
|
||||
return finalConfig;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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 fs from 'fs-extra';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { findRootPath } from './paths';
|
||||
|
||||
type ResolveOptions = {
|
||||
// Same as configPath in LoadConfigOptions
|
||||
configPath?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolves all configuration files that should be loaded in the given environment.
|
||||
*/
|
||||
export async function resolveStaticConfig(
|
||||
options: ResolveOptions,
|
||||
): Promise<string[]> {
|
||||
// TODO: We'll want this to be a bit more elaborate, probably adding configs for
|
||||
// specific env, and maybe local config for plugins.
|
||||
let { configPath } = options;
|
||||
if (!configPath) {
|
||||
configPath = resolvePath(
|
||||
findRootPath(fs.realpathSync(process.cwd())),
|
||||
'app-config.yaml',
|
||||
);
|
||||
}
|
||||
|
||||
return [configPath];
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* 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 { readSecret } from './secrets';
|
||||
import { ReaderContext } from './types';
|
||||
|
||||
const ctx: ReaderContext = {
|
||||
env: {
|
||||
SECRET: 'my-secret',
|
||||
},
|
||||
readSecret: jest.fn(),
|
||||
async readFile(path) {
|
||||
const content = ({
|
||||
'my-secret': 'secret',
|
||||
'my-data.json': '{"a":{"b":{"c":42}}}',
|
||||
'my-data.yaml': 'some:\n yaml:\n key: 7',
|
||||
'my-data.yml': 'different: { key: hello }',
|
||||
} as { [key: string]: string })[path];
|
||||
|
||||
if (!content) {
|
||||
throw new Error('File not found!');
|
||||
}
|
||||
return content;
|
||||
},
|
||||
};
|
||||
|
||||
describe('readSecret', () => {
|
||||
it('should read file secrets', async () => {
|
||||
await expect(readSecret({ file: 'my-secret' }, ctx)).resolves.toBe(
|
||||
'secret',
|
||||
);
|
||||
await expect(readSecret({ file: 'no-secret' }, ctx)).rejects.toThrow(
|
||||
'File not found!',
|
||||
);
|
||||
});
|
||||
|
||||
it('should read present env secrets', async () => {
|
||||
await expect(readSecret({ env: 'SECRET' }, ctx)).resolves.toBe('my-secret');
|
||||
await expect(readSecret({ env: 'NO_SECRET' }, ctx)).resolves.toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should read data secrets', async () => {
|
||||
await expect(
|
||||
readSecret({ data: 'my-data.json', path: 'a.b.c' }, ctx),
|
||||
).resolves.toBe('42');
|
||||
|
||||
await expect(
|
||||
readSecret({ data: 'my-data.yaml', path: 'some.yaml.key' }, ctx),
|
||||
).resolves.toBe('7');
|
||||
|
||||
await expect(
|
||||
readSecret({ data: 'my-data.yml', path: 'different.key' }, ctx),
|
||||
).resolves.toBe('hello');
|
||||
|
||||
await expect(
|
||||
readSecret({ data: 'no-data.yml', path: 'different.key' }, ctx),
|
||||
).rejects.toThrow('File not found!');
|
||||
});
|
||||
|
||||
it('should reject invalid secrets', async () => {
|
||||
await expect(readSecret('hello' as any, ctx)).rejects.toThrow(
|
||||
'secret must be a `object` type, but the final value was: `"hello"`.',
|
||||
);
|
||||
await expect(readSecret({}, ctx)).rejects.toThrow(
|
||||
"Secret must contain one of 'file', 'env', 'data'",
|
||||
);
|
||||
await expect(readSecret({ unknown: 'derp' }, ctx)).rejects.toThrow(
|
||||
"Secret must contain one of 'file', 'env', 'data'",
|
||||
);
|
||||
await expect(readSecret({ data: 'no-data.yml' }, ctx)).rejects.toThrow(
|
||||
'path is a required field',
|
||||
);
|
||||
await expect(
|
||||
readSecret({ data: 'no-parser.js', path: '.' }, ctx),
|
||||
).rejects.toThrow('No data secret parser available for extension .js');
|
||||
await expect(
|
||||
readSecret({ data: 'my-data.yaml', path: 'some.wrong.yaml.key' }, ctx),
|
||||
).rejects.toThrow('Value is not an object at some.wrong in my-data.yaml');
|
||||
});
|
||||
|
||||
it('should have 100% test coverage', async () => {
|
||||
let firstVisit = true;
|
||||
const secret = {};
|
||||
const proto = {
|
||||
get file() {
|
||||
if (!firstVisit) {
|
||||
Object.setPrototypeOf(secret, {});
|
||||
}
|
||||
firstVisit = false;
|
||||
return 'a-file';
|
||||
},
|
||||
};
|
||||
Object.setPrototypeOf(secret, proto);
|
||||
|
||||
await expect(readSecret(secret, ctx)).rejects.toThrow(
|
||||
'Secret was left unhandled',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* 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 * as yup from 'yup';
|
||||
import yaml from 'yaml';
|
||||
import { extname } from 'path';
|
||||
import { JsonObject, JsonValue } from '@backstage/config';
|
||||
import { isObject, isNever } from './utils';
|
||||
import { ReaderContext } from './types';
|
||||
|
||||
// Reads a file and forwards the contents as is, assuming ut8 encoding
|
||||
type FileSecret = {
|
||||
// Path to the secret file, relative to the config file.
|
||||
file: string;
|
||||
};
|
||||
|
||||
// Reads the secret from an environment variable.
|
||||
type EnvSecret = {
|
||||
// The name of the environment file.
|
||||
env: string;
|
||||
};
|
||||
|
||||
// Reads a secret from a json-like file and extracts a value at a path.
|
||||
// The supported extensions are define in dataSecretParser below.
|
||||
type DataSecret = {
|
||||
// Path to the data secret file, relative to the config file.
|
||||
data: string;
|
||||
// The path to the value inside the data file.
|
||||
// Either a '.' separated list, or an array of path segments.
|
||||
path: string | string[];
|
||||
};
|
||||
|
||||
type Secret = FileSecret | EnvSecret | DataSecret;
|
||||
|
||||
// Schema for each type of secret description
|
||||
const secretLoaderSchemas = {
|
||||
file: yup.object({
|
||||
file: yup.string().required(),
|
||||
}),
|
||||
env: yup.object({
|
||||
env: yup.string().required(),
|
||||
}),
|
||||
data: yup.object({
|
||||
data: yup.string().required(),
|
||||
path: yup.lazy(value => {
|
||||
if (typeof value === 'string') {
|
||||
return yup.string().required();
|
||||
}
|
||||
return yup.array().of(yup.string().required()).required();
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
// The top-level secret schema, which figures out what type of secret it is.
|
||||
const secretSchema = yup.lazy<object>(value => {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return yup.object().required().label('secret');
|
||||
}
|
||||
|
||||
const loaderTypes = Object.keys(
|
||||
secretLoaderSchemas,
|
||||
) as (keyof typeof secretLoaderSchemas)[];
|
||||
|
||||
for (const key of loaderTypes) {
|
||||
if (key in value) {
|
||||
return secretLoaderSchemas[key];
|
||||
}
|
||||
}
|
||||
throw new yup.ValidationError(
|
||||
`Secret must contain one of '${loaderTypes.join("', '")}'`,
|
||||
value,
|
||||
'$secret',
|
||||
);
|
||||
});
|
||||
|
||||
// Parsers for each type of data secret file.
|
||||
const dataSecretParser: {
|
||||
[ext in string]: (content: string) => Promise<JsonObject>;
|
||||
} = {
|
||||
'.json': async content => JSON.parse(content),
|
||||
'.yaml': async content => yaml.parse(content),
|
||||
'.yml': async content => yaml.parse(content),
|
||||
};
|
||||
|
||||
/**
|
||||
* Transforms a secret description into the actual secret value.
|
||||
*/
|
||||
export async function readSecret(
|
||||
data: JsonObject,
|
||||
ctx: ReaderContext,
|
||||
): Promise<string | undefined> {
|
||||
const secret = secretSchema.validateSync(data, { strict: true }) as Secret;
|
||||
|
||||
if ('file' in secret) {
|
||||
return ctx.readFile(secret.file);
|
||||
}
|
||||
if ('env' in secret) {
|
||||
return ctx.env[secret.env];
|
||||
}
|
||||
if ('data' in secret) {
|
||||
const ext = extname(secret.data);
|
||||
const parser = dataSecretParser[ext];
|
||||
if (!parser) {
|
||||
throw new Error(`No data secret parser available for extension ${ext}`);
|
||||
}
|
||||
|
||||
const content = await ctx.readFile(secret.data);
|
||||
|
||||
const { path } = secret;
|
||||
const parts = typeof path === 'string' ? path.split('.') : path;
|
||||
|
||||
let value: JsonValue = await parser(content);
|
||||
for (const [index, part] of parts.entries()) {
|
||||
if (!isObject(value)) {
|
||||
const errPath = parts.slice(0, index).join('.');
|
||||
throw new Error(
|
||||
`Value is not an object at ${errPath} in ${secret.data}`,
|
||||
);
|
||||
}
|
||||
value = value[part];
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
isNever<typeof secret>();
|
||||
throw new Error('Secret was left unhandled');
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import { JsonObject } from '@backstage/config';
|
||||
|
||||
export type ReadFileFunc = (path: string) => Promise<string>;
|
||||
export type ReadSecretFunc = (desc: JsonObject) => Promise<string | undefined>;
|
||||
|
||||
/**
|
||||
* Common context that provides all the necessary hooks for reading configuration files.
|
||||
*/
|
||||
export type ReaderContext = {
|
||||
env: { [name in string]?: string };
|
||||
readFile: ReadFileFunc;
|
||||
readSecret: ReadSecretFunc;
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
* 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 { JsonValue, JsonObject } from '@backstage/config';
|
||||
|
||||
export function isObject(obj: JsonValue | undefined): obj is JsonObject {
|
||||
if (typeof obj !== 'object') {
|
||||
return false;
|
||||
} else if (Array.isArray(obj)) {
|
||||
return false;
|
||||
}
|
||||
return obj !== null;
|
||||
}
|
||||
|
||||
// A thing to make sure we've narrowed the type down to never
|
||||
export function isNever<T extends never>() {
|
||||
return void 0 as T;
|
||||
}
|
||||
@@ -15,87 +15,46 @@
|
||||
*/
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import yaml from 'yaml';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { resolve as resolvePath, dirname } from 'path';
|
||||
import { AppConfig, JsonObject } from '@backstage/config';
|
||||
import { findRootPath } from './paths';
|
||||
import { LoadConfigOptions } from './types';
|
||||
import {
|
||||
resolveStaticConfig,
|
||||
readConfigFile,
|
||||
readEnv,
|
||||
readSecret,
|
||||
} from './lib';
|
||||
|
||||
const ENV_PREFIX = 'APP_CONFIG_';
|
||||
export type LoadConfigOptions = {
|
||||
// Config path, defaults to app-config.yaml in project root
|
||||
configPath?: string;
|
||||
|
||||
// Update the same pattern in config package if this is changed
|
||||
const CONFIG_KEY_PART_PATTERN = /^[a-z][a-z0-9]*(?:[-_][a-z][a-z0-9]*)*$/i;
|
||||
// Whether to read secrets or omit them, defaults to false.
|
||||
shouldReadSecrets?: boolean;
|
||||
};
|
||||
|
||||
export function readEnv(env: {
|
||||
[name: string]: string | undefined;
|
||||
}): AppConfig[] {
|
||||
let config: JsonObject | undefined = undefined;
|
||||
class Context {
|
||||
constructor(
|
||||
private readonly options: {
|
||||
env: { [name in string]?: string };
|
||||
rootPath: string;
|
||||
shouldReadSecrets: boolean;
|
||||
},
|
||||
) {}
|
||||
|
||||
for (const [name, value] of Object.entries(env)) {
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
if (name.startsWith(ENV_PREFIX)) {
|
||||
const key = name.replace(ENV_PREFIX, '');
|
||||
const keyParts = key.split('_');
|
||||
|
||||
let obj = (config = config ?? {});
|
||||
for (const [index, part] of keyParts.entries()) {
|
||||
if (!CONFIG_KEY_PART_PATTERN.test(part)) {
|
||||
throw new TypeError(`Invalid env config key '${key}'`);
|
||||
}
|
||||
if (index < keyParts.length - 1) {
|
||||
obj = (obj[part] = obj[part] ?? {}) as JsonObject;
|
||||
if (typeof obj !== 'object' || Array.isArray(obj)) {
|
||||
const subKey = keyParts.slice(0, index + 1).join('_');
|
||||
throw new TypeError(
|
||||
`Could not nest config for key '${key}' under existing value '${subKey}'`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (part in obj) {
|
||||
throw new TypeError(
|
||||
`Refusing to override existing config at key '${key}'`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const parsedValue = JSON.parse(value);
|
||||
if (parsedValue === null) {
|
||||
throw new Error('value may not be null');
|
||||
}
|
||||
obj[part] = parsedValue;
|
||||
} catch (error) {
|
||||
throw new TypeError(
|
||||
`Failed to parse JSON-serialized config value for key '${key}', ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
get env() {
|
||||
return this.options.env;
|
||||
}
|
||||
|
||||
return config ? [config] : [];
|
||||
}
|
||||
|
||||
export async function readStaticConfig(
|
||||
options: LoadConfigOptions,
|
||||
): Promise<AppConfig[]> {
|
||||
// TODO: We'll want this to be a bit more elaborate, probably adding configs for
|
||||
// specific env, and maybe local config for plugins.
|
||||
let { configPath } = options;
|
||||
if (!configPath) {
|
||||
configPath = resolvePath(
|
||||
findRootPath(fs.realpathSync(process.cwd())),
|
||||
'app-config.yaml',
|
||||
);
|
||||
async readFile(path: string): Promise<string> {
|
||||
return fs.readFile(resolvePath(this.options.rootPath, path), 'utf8');
|
||||
}
|
||||
|
||||
try {
|
||||
const configYaml = await fs.readFile(configPath, 'utf8');
|
||||
const config = yaml.parse(configYaml);
|
||||
return [config];
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to read static configuration file, ${error}`);
|
||||
async readSecret(desc: JsonObject): Promise<string | undefined> {
|
||||
if (!this.options.shouldReadSecrets) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return readSecret(desc, this);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +64,27 @@ export async function loadConfig(
|
||||
const configs = [];
|
||||
|
||||
configs.push(...readEnv(process.env));
|
||||
configs.push(...(await readStaticConfig(options)));
|
||||
|
||||
const configPaths = await resolveStaticConfig(options);
|
||||
|
||||
try {
|
||||
for (const configPath of configPaths) {
|
||||
const config = await readConfigFile(
|
||||
configPath,
|
||||
new Context({
|
||||
env: process.env,
|
||||
rootPath: dirname(configPath),
|
||||
shouldReadSecrets: Boolean(options.shouldReadSecrets),
|
||||
}),
|
||||
);
|
||||
|
||||
configs.push(config);
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to read static configuration file: ${error.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
return configs;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/core-api",
|
||||
"description": "Internal Core API used by Backstage plugins and apps",
|
||||
"version": "0.1.1-alpha.7",
|
||||
"version": "0.1.1-alpha.8",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
@@ -31,18 +31,18 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/config": "^0.1.1-alpha.7",
|
||||
"@backstage/theme": "^0.1.1-alpha.7",
|
||||
"@backstage/theme": "^0.1.1-alpha.8",
|
||||
"@material-ui/core": "^4.9.1",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@types/react": "^16.9",
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^16.12.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-router-dom": "6.0.0-alpha.5",
|
||||
"react-use": "^14.2.0",
|
||||
"zen-observable": "^0.8.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.7",
|
||||
"@backstage/cli": "^0.1.1-alpha.8",
|
||||
"@backstage/test-utils-core": "^0.1.1-alpha.7",
|
||||
"@testing-library/jest-dom": "^5.7.0",
|
||||
"@testing-library/react": "^9.3.2",
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { createApiRef } from '../ApiRef';
|
||||
|
||||
/**
|
||||
* The Identity API used to identify and get information about the signed in user.
|
||||
*/
|
||||
export type IdentityApi = {
|
||||
/**
|
||||
* The ID of the signed in user. This ID is not meant to be presented to the user, but used
|
||||
* as an opaque string to pass on to backends or use in frontend logic.
|
||||
*
|
||||
* TODO: The intention of the user ID is to be able to tie the user to an identity
|
||||
* that is known by the catalog and/or identity backend. It should for example
|
||||
* be possible to fetch all owned components using this ID.
|
||||
*/
|
||||
getUserId(): string;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// TODO: getProfile(): Promise<Profile> - We want this to be async when added, but needs more work.
|
||||
};
|
||||
|
||||
export const identifyApiRef = createApiRef<IdentityApi>({
|
||||
id: 'core.identity',
|
||||
description: 'Provides access to the identity of the signed in user',
|
||||
});
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { createApiRef } from '../ApiRef';
|
||||
import { Observable } from '../..';
|
||||
|
||||
/**
|
||||
* This file contains declarations for common interfaces of auth-related APIs.
|
||||
@@ -167,6 +168,14 @@ export type ProfileInfo = {
|
||||
picture?: string;
|
||||
};
|
||||
|
||||
export enum SessionState {
|
||||
SignedIn = 'SignedIn',
|
||||
SignedOut = 'SignedOut',
|
||||
}
|
||||
|
||||
export type SessionStateApi = {
|
||||
sessionState$(): Observable<SessionState>;
|
||||
};
|
||||
/**
|
||||
* Provides authentication towards Google APIs and identities.
|
||||
*
|
||||
@@ -176,7 +185,7 @@ export type ProfileInfo = {
|
||||
* email and expiration information. Do not rely on any other fields, as they might not be present.
|
||||
*/
|
||||
export const googleAuthApiRef = createApiRef<
|
||||
OAuthApi & OpenIdConnectApi & ProfileInfoApi
|
||||
OAuthApi & OpenIdConnectApi & ProfileInfoApi & SessionStateApi
|
||||
>({
|
||||
id: 'core.auth.google',
|
||||
description: 'Provides authentication towards Google APIs and identities',
|
||||
@@ -188,7 +197,7 @@ 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>({
|
||||
export const githubAuthApiRef = createApiRef<OAuthApi & SessionStateApi>({
|
||||
id: 'core.auth.github',
|
||||
description: 'Provides authentication towards Github APIs',
|
||||
});
|
||||
|
||||
@@ -27,5 +27,6 @@ export * from './AppThemeApi';
|
||||
export * from './ConfigApi';
|
||||
export * from './ErrorApi';
|
||||
export * from './FeatureFlagsApi';
|
||||
export * from './IdentityApi';
|
||||
export * from './OAuthRequestApi';
|
||||
export * from './StorageApi';
|
||||
|
||||
@@ -17,10 +17,17 @@
|
||||
import GithubIcon from '@material-ui/icons/AcUnit';
|
||||
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
|
||||
import { GithubSession } from './types';
|
||||
import { OAuthApi, AccessTokenOptions } from '../../../definitions/auth';
|
||||
import {
|
||||
OAuthApi,
|
||||
AccessTokenOptions,
|
||||
SessionStateApi,
|
||||
SessionState,
|
||||
} 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
|
||||
@@ -46,11 +53,11 @@ const DEFAULT_PROVIDER = {
|
||||
icon: GithubIcon,
|
||||
};
|
||||
|
||||
class GithubAuth implements OAuthApi {
|
||||
class GithubAuth implements OAuthApi, SessionStateApi {
|
||||
static create({
|
||||
apiOrigin,
|
||||
basePath,
|
||||
environment = 'dev',
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
oauthRequestApi,
|
||||
}: CreateOptions) {
|
||||
@@ -78,6 +85,12 @@ class GithubAuth implements OAuthApi {
|
||||
return new GithubAuth(sessionManager);
|
||||
}
|
||||
|
||||
private readonly sessionStateTracker = new SessionStateTracker();
|
||||
|
||||
sessionState$(): Observable<SessionState> {
|
||||
return this.sessionStateTracker.observable;
|
||||
}
|
||||
|
||||
constructor(private readonly sessionManager: SessionManager<GithubSession>) {}
|
||||
|
||||
async getAccessToken(scope?: string, options?: AccessTokenOptions) {
|
||||
@@ -86,6 +99,7 @@ class GithubAuth implements OAuthApi {
|
||||
...options,
|
||||
scopes: normalizedScopes,
|
||||
});
|
||||
this.sessionStateTracker.setIsSignedId(!!session);
|
||||
if (session) {
|
||||
return session.accessToken;
|
||||
}
|
||||
@@ -94,6 +108,7 @@ class GithubAuth implements OAuthApi {
|
||||
|
||||
async logout() {
|
||||
await this.sessionManager.removeSession();
|
||||
this.sessionStateTracker.setIsSignedId(false);
|
||||
}
|
||||
|
||||
static normalizeScope(scope?: string): Set<string> {
|
||||
|
||||
@@ -25,10 +25,14 @@ import {
|
||||
ProfileInfoApi,
|
||||
ProfileInfoOptions,
|
||||
ProfileInfo,
|
||||
SessionStateApi,
|
||||
SessionState,
|
||||
} 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
|
||||
@@ -57,11 +61,12 @@ const DEFAULT_PROVIDER = {
|
||||
|
||||
const SCOPE_PREFIX = 'https://www.googleapis.com/auth/';
|
||||
|
||||
class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi {
|
||||
class GoogleAuth
|
||||
implements OAuthApi, OpenIdConnectApi, ProfileInfoApi, SessionStateApi {
|
||||
static create({
|
||||
apiOrigin,
|
||||
basePath,
|
||||
environment = 'dev',
|
||||
environment = 'development',
|
||||
provider = DEFAULT_PROVIDER,
|
||||
oauthRequestApi,
|
||||
}: CreateOptions) {
|
||||
@@ -99,6 +104,12 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi {
|
||||
return new GoogleAuth(sessionManager);
|
||||
}
|
||||
|
||||
private readonly sessionStateTracker = new SessionStateTracker();
|
||||
|
||||
sessionState$(): Observable<SessionState> {
|
||||
return this.sessionStateTracker.observable;
|
||||
}
|
||||
|
||||
constructor(private readonly sessionManager: SessionManager<GoogleSession>) {}
|
||||
|
||||
async getAccessToken(
|
||||
@@ -110,6 +121,7 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi {
|
||||
...options,
|
||||
scopes: normalizedScopes,
|
||||
});
|
||||
this.sessionStateTracker.setIsSignedId(!!session);
|
||||
if (session) {
|
||||
return session.accessToken;
|
||||
}
|
||||
@@ -118,6 +130,7 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi {
|
||||
|
||||
async getIdToken(options: IdTokenOptions = {}) {
|
||||
const session = await this.sessionManager.getSession(options);
|
||||
this.sessionStateTracker.setIsSignedId(!!session);
|
||||
if (session) {
|
||||
return session.idToken;
|
||||
}
|
||||
@@ -126,10 +139,12 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi {
|
||||
|
||||
async logout() {
|
||||
await this.sessionManager.removeSession();
|
||||
this.sessionStateTracker.setIsSignedId(false);
|
||||
}
|
||||
|
||||
async getProfile(options: ProfileInfoOptions = {}) {
|
||||
const session = await this.sessionManager.getSession(options);
|
||||
this.sessionStateTracker.setIsSignedId(!!session);
|
||||
if (!session) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -13,11 +13,10 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ComponentType, FC, useMemo } from 'react';
|
||||
import { Route, Switch, Redirect } from 'react-router-dom';
|
||||
import { Route, Routes, Navigate } from 'react-router-dom';
|
||||
import { AppContextProvider } from './AppContext';
|
||||
import { BackstageApp, AppComponents, AppConfigLoader } from './types';
|
||||
import { BackstageApp, AppComponents, AppConfigLoader, Apis } from './types';
|
||||
import { BackstagePlugin } from '../plugin';
|
||||
import { FeatureFlagsRegistryItem } from './FeatureFlags';
|
||||
import { featureFlagsApiRef } from '../apis/definitions';
|
||||
@@ -38,7 +37,7 @@ import { ApiAggregator } from '../apis/ApiAggregator';
|
||||
import { useAsync } from 'react-use';
|
||||
|
||||
type FullAppOptions = {
|
||||
apis: ApiHolder;
|
||||
apis: Apis;
|
||||
icons: SystemIcons;
|
||||
plugins: BackstagePlugin[];
|
||||
components: AppComponents;
|
||||
@@ -47,15 +46,17 @@ type FullAppOptions = {
|
||||
};
|
||||
|
||||
export class PrivateAppImpl implements BackstageApp {
|
||||
private readonly apis: ApiHolder;
|
||||
private apis?: ApiHolder = undefined;
|
||||
private readonly icons: SystemIcons;
|
||||
private readonly plugins: BackstagePlugin[];
|
||||
private readonly components: AppComponents;
|
||||
private readonly themes: AppTheme[];
|
||||
private readonly configLoader?: AppConfigLoader;
|
||||
|
||||
private apisOrFactory: Apis;
|
||||
|
||||
constructor(options: FullAppOptions) {
|
||||
this.apis = options.apis;
|
||||
this.apisOrFactory = options.apis;
|
||||
this.icons = options.icons;
|
||||
this.plugins = options.plugins;
|
||||
this.components = options.components;
|
||||
@@ -64,6 +65,9 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
}
|
||||
|
||||
getApis(): ApiHolder {
|
||||
if (!this.apis) {
|
||||
throw new Error('Tried to access APIs before app was loaded');
|
||||
}
|
||||
return this.apis;
|
||||
}
|
||||
|
||||
@@ -85,50 +89,31 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
for (const output of plugin.output()) {
|
||||
switch (output.type) {
|
||||
case 'legacy-route': {
|
||||
const { path, component, options = {} } = output;
|
||||
const { exact = true } = options;
|
||||
const { path, component: Component } = output;
|
||||
routes.push(
|
||||
<Route
|
||||
key={path}
|
||||
path={path}
|
||||
component={component}
|
||||
exact={exact}
|
||||
/>,
|
||||
<Route key={path} path={path} element={<Component />} />,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'route': {
|
||||
const { target, component, options = {} } = output;
|
||||
const { exact = true } = options;
|
||||
const { target, component: Component } = output;
|
||||
routes.push(
|
||||
<Route
|
||||
key={`${plugin.getId()}-${target.path}`}
|
||||
path={target.path}
|
||||
component={component}
|
||||
exact={exact}
|
||||
element={<Component />}
|
||||
/>,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'legacy-redirect-route': {
|
||||
const { path, target, options = {} } = output;
|
||||
const { exact = true } = options;
|
||||
routes.push(
|
||||
<Redirect key={path} path={path} to={target} exact={exact} />,
|
||||
);
|
||||
const { path, target } = output;
|
||||
routes.push(<Navigate key={path} to={target} />);
|
||||
break;
|
||||
}
|
||||
case 'redirect-route': {
|
||||
const { from, to, options = {} } = output;
|
||||
const { exact = true } = options;
|
||||
routes.push(
|
||||
<Redirect
|
||||
key={from.path}
|
||||
path={from.path}
|
||||
to={to.path}
|
||||
exact={exact}
|
||||
/>,
|
||||
);
|
||||
const { from, to } = output;
|
||||
routes.push(<Navigate key={from.path} to={to.path} />);
|
||||
break;
|
||||
}
|
||||
case 'feature-flag': {
|
||||
@@ -150,10 +135,10 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
}
|
||||
|
||||
const rendered = (
|
||||
<Switch>
|
||||
<Routes>
|
||||
{routes}
|
||||
<Route component={NotFoundErrorPage} />
|
||||
</Switch>
|
||||
<Route element={<NotFoundErrorPage />} />
|
||||
</Routes>
|
||||
);
|
||||
|
||||
return () => rendered;
|
||||
@@ -196,6 +181,15 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
[appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)],
|
||||
[configApiRef, configReader],
|
||||
]);
|
||||
|
||||
if (!this.apis) {
|
||||
if ('get' in this.apisOrFactory) {
|
||||
this.apis = this.apisOrFactory;
|
||||
} else {
|
||||
this.apis = this.apisOrFactory(configReader);
|
||||
}
|
||||
}
|
||||
|
||||
const apis = new ApiAggregator(this.apis, appApis);
|
||||
|
||||
const { Router } = this.components;
|
||||
@@ -211,7 +205,7 @@ export class PrivateAppImpl implements BackstageApp {
|
||||
<ApiProvider apis={apis}>
|
||||
<AppContextProvider app={this}>
|
||||
<AppThemeProvider>
|
||||
<Router basename={pathname}>{children}</Router>
|
||||
<Router>{children}</Router>
|
||||
</AppThemeProvider>
|
||||
</AppContextProvider>
|
||||
</ApiProvider>
|
||||
|
||||
@@ -18,19 +18,18 @@ import { ComponentType } from 'react';
|
||||
import { IconComponent, SystemIconKey, SystemIcons } from '../icons';
|
||||
import { BackstagePlugin } from '../plugin';
|
||||
import { ApiHolder } from '../apis';
|
||||
import { AppTheme } from '../apis/definitions';
|
||||
import { AppTheme, ConfigApi } from '../apis/definitions';
|
||||
import { AppConfig } from '@backstage/config';
|
||||
|
||||
export type BootErrorPageProps = {
|
||||
step: 'load-config';
|
||||
error: Error;
|
||||
};
|
||||
|
||||
export type AppComponents = {
|
||||
NotFoundErrorPage: ComponentType<{}>;
|
||||
BootErrorPage: ComponentType<BootErrorPageProps>;
|
||||
Progress: ComponentType<{}>;
|
||||
Router: ComponentType<{ basename?: string }>;
|
||||
Router: ComponentType<{}>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -41,13 +40,16 @@ export type AppComponents = {
|
||||
*/
|
||||
export type AppConfigLoader = () => Promise<AppConfig[]>;
|
||||
|
||||
// TODO(Rugvip): Temporary workaround for accessing config when instantiating APIs, we might want to do this differently
|
||||
export type Apis = ApiHolder | ((config: ConfigApi) => ApiHolder);
|
||||
|
||||
export type AppOptions = {
|
||||
/**
|
||||
* A holder of all APIs available in the app.
|
||||
*
|
||||
* Use for example ApiRegistry or ApiTestRegistry.
|
||||
*/
|
||||
apis?: ApiHolder;
|
||||
apis?: Apis;
|
||||
|
||||
/**
|
||||
* Supply icons to override the default ones.
|
||||
|
||||
@@ -131,15 +131,6 @@ describe('RefreshingAuthSessionManager', () => {
|
||||
});
|
||||
|
||||
it('should remove session and reload', async () => {
|
||||
// This is a workaround that is used by Facebook and the Jest core team
|
||||
// It is a limitation with the newest versions of JSDOM, and newer browser standards
|
||||
// where window.location and all of its properties are read-only. So we re-construct it!
|
||||
// See https://github.com/facebook/jest/issues/890#issuecomment-209698782
|
||||
const location = { ...window.location };
|
||||
delete window.location;
|
||||
window.location = location;
|
||||
jest.spyOn(window.location, 'reload').mockImplementation();
|
||||
|
||||
const removeSession = jest.fn();
|
||||
const manager = new RefreshingAuthSessionManager({
|
||||
connector: { removeSession },
|
||||
@@ -147,7 +138,7 @@ describe('RefreshingAuthSessionManager', () => {
|
||||
} as any);
|
||||
|
||||
await manager.removeSession();
|
||||
expect(window.location.reload).toHaveBeenCalled();
|
||||
expect(removeSession).toHaveBeenCalled();
|
||||
expect(await manager.getSession({ optional: true })).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -113,8 +113,8 @@ export class RefreshingAuthSessionManager<T> implements SessionManager<T> {
|
||||
}
|
||||
|
||||
async removeSession() {
|
||||
this.currentSession = undefined;
|
||||
await this.connector.removeSession();
|
||||
window.location.reload(); // TODO(Rugvip): make this work without reload?
|
||||
}
|
||||
|
||||
async getCurrentSession() {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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 { BehaviorSubject } from '..';
|
||||
import { SessionState } from '../../apis';
|
||||
|
||||
export class SessionStateTracker {
|
||||
private signedIn: boolean = false;
|
||||
observable = new BehaviorSubject<SessionState>(SessionState.SignedOut);
|
||||
|
||||
setIsSignedId(isSignedIn: boolean) {
|
||||
if (this.signedIn !== isSignedIn) {
|
||||
this.signedIn = isSignedIn;
|
||||
this.observable.next(
|
||||
this.signedIn ? SessionState.SignedIn : SessionState.SignedOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,11 +84,6 @@ describe('StaticAuthSessionManager', () => {
|
||||
});
|
||||
|
||||
it('should remove session and reload', async () => {
|
||||
const location = { ...window.location };
|
||||
delete window.location;
|
||||
window.location = location;
|
||||
jest.spyOn(window.location, 'reload').mockImplementation();
|
||||
|
||||
const removeSession = jest.fn();
|
||||
const manager = new StaticAuthSessionManager({
|
||||
connector: { removeSession },
|
||||
@@ -96,7 +91,7 @@ describe('StaticAuthSessionManager', () => {
|
||||
} as any);
|
||||
|
||||
await manager.removeSession();
|
||||
expect(window.location.reload).toHaveBeenCalled();
|
||||
expect(removeSession).toHaveBeenCalled();
|
||||
expect(await manager.getSession({ optional: true })).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,7 +64,7 @@ export class StaticAuthSessionManager<T> implements SessionManager<T> {
|
||||
}
|
||||
|
||||
async removeSession() {
|
||||
this.currentSession = undefined;
|
||||
await this.connector.removeSession();
|
||||
window.location.reload(); // TODO(Rugvip): make this work without reload?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/core",
|
||||
"description": "Core API used by Backstage plugins and apps",
|
||||
"version": "0.1.1-alpha.7",
|
||||
"version": "0.1.1-alpha.8",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
@@ -31,13 +31,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/config": "^0.1.1-alpha.7",
|
||||
"@backstage/core-api": "^0.1.1-alpha.7",
|
||||
"@backstage/theme": "^0.1.1-alpha.7",
|
||||
"@backstage/core-api": "^0.1.1-alpha.8",
|
||||
"@backstage/theme": "^0.1.1-alpha.8",
|
||||
"@material-ui/core": "^4.9.1",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.45",
|
||||
"@types/react": "^16.9",
|
||||
"@types/react-router-dom": "^5.1.5",
|
||||
"@types/react-sparklines": "^1.7.0",
|
||||
"classnames": "^2.2.6",
|
||||
"clsx": "^1.1.0",
|
||||
@@ -48,15 +47,15 @@
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
"react-helmet": "6.0.0",
|
||||
"react-router": "^5.2.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-router": "6.0.0-alpha.5",
|
||||
"react-router-dom": "6.0.0-alpha.5",
|
||||
"react-sparklines": "^1.7.0",
|
||||
"react-syntax-highlighter": "^12.2.1",
|
||||
"react-use": "^14.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.7",
|
||||
"@backstage/test-utils": "^0.1.1-alpha.7",
|
||||
"@backstage/cli": "^0.1.1-alpha.8",
|
||||
"@backstage/test-utils": "^0.1.1-alpha.8",
|
||||
"@testing-library/jest-dom": "^5.7.0",
|
||||
"@testing-library/react": "^9.3.2",
|
||||
"@testing-library/user-event": "^10.2.4",
|
||||
|
||||
@@ -15,12 +15,7 @@
|
||||
*/
|
||||
import React, { FunctionComponentFactory } from 'react';
|
||||
import { Button } from './Button';
|
||||
import {
|
||||
MemoryRouter,
|
||||
Route,
|
||||
useLocation,
|
||||
Link as RouterLink,
|
||||
} from 'react-router-dom';
|
||||
import { MemoryRouter, Route, useLocation } from 'react-router-dom';
|
||||
import { createRouteRef } from '@backstage/core-api';
|
||||
|
||||
const Location = () => {
|
||||
@@ -70,14 +65,7 @@ export const PassProps = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
to={routeRef.path}
|
||||
/** react-router-dom related prop */
|
||||
component={RouterLink}
|
||||
/** material-ui related prop */
|
||||
color="secondary"
|
||||
variant="outlined"
|
||||
>
|
||||
<Button to={routeRef.path} color="secondary" variant="outlined">
|
||||
This link
|
||||
</Button>
|
||||
has props for both material-ui's component as well as for
|
||||
|
||||
@@ -15,11 +15,10 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { render, fireEvent, act } from '@testing-library/react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { Button } from './Button';
|
||||
import { MemoryRouter, Route } from 'react-router';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { Route, Routes } from 'react-router';
|
||||
|
||||
describe('<Button />', () => {
|
||||
it('navigates using react-router', async () => {
|
||||
@@ -27,12 +26,13 @@ describe('<Button />', () => {
|
||||
const buttonLabel = 'Navigate!';
|
||||
const { getByText } = render(
|
||||
wrapInTestApp(
|
||||
<MemoryRouter>
|
||||
<Routes>
|
||||
<Route path="/test" element={<p>{testString}</p>} />
|
||||
<Button to="/test">{buttonLabel}</Button>
|
||||
<Route path="/test">{testString}</Route>{' '}
|
||||
</MemoryRouter>,
|
||||
</Routes>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(() => getByText(testString)).toThrow();
|
||||
await act(async () => fireEvent.click(getByText(buttonLabel)));
|
||||
expect(getByText(testString)).toBeInTheDocument();
|
||||
|
||||
@@ -18,7 +18,7 @@ import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { Link } from './Link';
|
||||
import { MemoryRouter, Route } from 'react-router';
|
||||
import { Route, Routes } from 'react-router';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
|
||||
describe('<Link />', () => {
|
||||
@@ -27,10 +27,10 @@ describe('<Link />', () => {
|
||||
const linkText = 'Navigate!';
|
||||
const { getByText } = render(
|
||||
wrapInTestApp(
|
||||
<MemoryRouter>
|
||||
<Routes>
|
||||
<Link to="/test">{linkText}</Link>
|
||||
<Route path="/test">{testString}</Route>
|
||||
</MemoryRouter>,
|
||||
<Route path="/test" element={<p>{testString}</p>} />
|
||||
</Routes>,
|
||||
),
|
||||
);
|
||||
expect(() => getByText(testString)).toThrow();
|
||||
|
||||
@@ -19,7 +19,7 @@ import { Link as MaterialLink } from '@material-ui/core';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
type Props = ComponentProps<typeof MaterialLink> &
|
||||
ComponentProps<typeof RouterLink>;
|
||||
ComponentProps<typeof RouterLink> & { component?: React.FC<any> };
|
||||
|
||||
/**
|
||||
* Thin wrapper on top of material-ui's Link component
|
||||
|
||||
@@ -19,7 +19,7 @@ import { Typography, Link, Grid } from '@material-ui/core';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import { BackstageTheme } from '@backstage/theme';
|
||||
import { MicDrop } from './MicDrop';
|
||||
import { useHistory } from 'react-router';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
interface IErrorPageProps {
|
||||
status: string;
|
||||
@@ -40,7 +40,7 @@ const useStyles = makeStyles<BackstageTheme>(theme => ({
|
||||
|
||||
export const ErrorPage = ({ status, statusMessage }: IErrorPageProps) => {
|
||||
const classes = useStyles();
|
||||
const history = useHistory();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Grid container className={classes.container}>
|
||||
@@ -53,7 +53,7 @@ export const ErrorPage = ({ status, statusMessage }: IErrorPageProps) => {
|
||||
Looks like someone dropped the mic!
|
||||
</Typography>
|
||||
<Typography variant="h6">
|
||||
<Link data-testid="go-back-link" onClick={history.goBack}>
|
||||
<Link data-testid="go-back-link" onClick={() => navigate(-1)}>
|
||||
Go back
|
||||
</Link>
|
||||
... or if you think this is a bug, please file an{' '}
|
||||
|
||||
@@ -58,8 +58,11 @@ const useStyles = makeStyles<Theme>(theme => {
|
||||
// XXX (@koroeskohr): I can't seem to achieve the desired font-weight from the designs
|
||||
fontWeight: 'bold',
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: 1.0,
|
||||
lineHeight: 'auto',
|
||||
flex: '3 1 auto',
|
||||
width: '110px',
|
||||
overflow: 'hidden',
|
||||
'text-overflow': 'ellipsis',
|
||||
},
|
||||
iconContainer: {
|
||||
boxSizing: 'border-box',
|
||||
@@ -118,7 +121,8 @@ export const SidebarItem: FC<SidebarItemProps> = ({
|
||||
icon: Icon,
|
||||
text,
|
||||
to = '#',
|
||||
disableSelected = false,
|
||||
// TODO: isActive is not in v6
|
||||
// disableSelected = false,
|
||||
hasNotifications = false,
|
||||
onClick,
|
||||
children,
|
||||
@@ -145,8 +149,7 @@ export const SidebarItem: FC<SidebarItemProps> = ({
|
||||
<NavLink
|
||||
className={clsx(classes.root, classes.closed)}
|
||||
activeClassName={classes.selected}
|
||||
isActive={match => Boolean(match && !disableSelected)}
|
||||
exact
|
||||
end
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
>
|
||||
@@ -158,8 +161,7 @@ export const SidebarItem: FC<SidebarItemProps> = ({
|
||||
<NavLink
|
||||
className={clsx(classes.root, classes.open)}
|
||||
activeClassName={classes.selected}
|
||||
isActive={match => Boolean(match && !disableSelected)}
|
||||
exact
|
||||
end
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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 {
|
||||
ApiRef,
|
||||
OAuthApi,
|
||||
SessionStateApi,
|
||||
useApi,
|
||||
Subscription,
|
||||
IconComponent,
|
||||
SessionState,
|
||||
} from '@backstage/core-api';
|
||||
import React, { FC, useState, useEffect } from 'react';
|
||||
import { ProviderSettingsItem } from './ProviderSettingsItem';
|
||||
|
||||
type OAuthProviderSidebarProps = {
|
||||
title: string;
|
||||
icon: IconComponent;
|
||||
apiRef: ApiRef<OAuthApi & SessionStateApi>;
|
||||
};
|
||||
|
||||
export const OAuthProviderSettings: FC<OAuthProviderSidebarProps> = ({
|
||||
title,
|
||||
icon,
|
||||
apiRef,
|
||||
}) => {
|
||||
const api = useApi(apiRef);
|
||||
const [signedIn, setSignedIn] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkSession = async () => {
|
||||
const session = await api.getAccessToken('', { optional: true });
|
||||
setSignedIn(!!session);
|
||||
};
|
||||
let subscription: Subscription;
|
||||
const observeSession = () => {
|
||||
subscription = api
|
||||
.sessionState$()
|
||||
.subscribe((sessionState: SessionState) => {
|
||||
setSignedIn(sessionState === SessionState.SignedIn);
|
||||
});
|
||||
};
|
||||
|
||||
checkSession();
|
||||
observeSession();
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<ProviderSettingsItem
|
||||
title={title}
|
||||
icon={icon}
|
||||
signedIn={signedIn}
|
||||
api={api}
|
||||
signInHandler={() => api.getAccessToken()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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 {
|
||||
ApiRef,
|
||||
OpenIdConnectApi,
|
||||
SessionStateApi,
|
||||
useApi,
|
||||
Subscription,
|
||||
IconComponent,
|
||||
SessionState,
|
||||
} from '@backstage/core-api';
|
||||
import React, { FC, useState, useEffect } from 'react';
|
||||
import { ProviderSettingsItem } from './ProviderSettingsItem';
|
||||
|
||||
export type OIDCProviderSidebarProps = {
|
||||
title: string;
|
||||
icon: IconComponent;
|
||||
apiRef: ApiRef<OpenIdConnectApi & SessionStateApi>;
|
||||
};
|
||||
|
||||
export const OIDCProviderSettings: FC<OIDCProviderSidebarProps> = ({
|
||||
title,
|
||||
icon,
|
||||
apiRef,
|
||||
}) => {
|
||||
const api = useApi(apiRef);
|
||||
const [signedIn, setSignedIn] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkSession = async () => {
|
||||
const session = await api.getIdToken({ optional: true });
|
||||
setSignedIn(!!session);
|
||||
};
|
||||
|
||||
let subscription: Subscription;
|
||||
const observeSession = () => {
|
||||
subscription = api
|
||||
.sessionState$()
|
||||
.subscribe((sessionState: SessionState) => {
|
||||
setSignedIn(sessionState === SessionState.SignedIn);
|
||||
});
|
||||
};
|
||||
|
||||
checkSession();
|
||||
observeSession();
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<ProviderSettingsItem
|
||||
title={title}
|
||||
icon={icon}
|
||||
signedIn={signedIn}
|
||||
api={api}
|
||||
signInHandler={() => api.getIdToken()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* 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 React, { FC } from 'react';
|
||||
import { OAuthApi, OpenIdConnectApi, IconComponent } from '@backstage/core-api';
|
||||
import { SidebarItem } from '../Items';
|
||||
import { IconButton, Tooltip } from '@material-ui/core';
|
||||
import StarBorder from '@material-ui/icons/StarBorder';
|
||||
import PowerButton from '@material-ui/icons/PowerSettingsNew';
|
||||
|
||||
export const ProviderSettingsItem: FC<{
|
||||
title: string;
|
||||
icon: IconComponent;
|
||||
signedIn: boolean;
|
||||
api: OAuthApi | OpenIdConnectApi;
|
||||
signInHandler: Function;
|
||||
}> = ({ title, icon, signedIn, api, signInHandler }) => {
|
||||
return (
|
||||
<SidebarItem
|
||||
key={title}
|
||||
text={title}
|
||||
icon={icon ?? StarBorder}
|
||||
disableSelected
|
||||
>
|
||||
<IconButton onClick={() => (signedIn ? api.logout() : signInHandler())}>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
arrow
|
||||
title={signedIn ? `Sign out from ${title}` : `Sign in to ${title}`}
|
||||
>
|
||||
<PowerButton color={signedIn ? 'secondary' : 'primary'} />
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</SidebarItem>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* 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 React, { FC, useState, useRef, useEffect } from 'react';
|
||||
import { makeStyles, Avatar, Divider } from '@material-ui/core';
|
||||
import {
|
||||
ProfileInfo,
|
||||
useApi,
|
||||
googleAuthApiRef,
|
||||
Subscription,
|
||||
SessionState,
|
||||
} from '@backstage/core-api';
|
||||
import { SidebarItem } from '../Items';
|
||||
import ExpandLess from '@material-ui/icons/ExpandLess';
|
||||
import ExpandMore from '@material-ui/icons/ExpandMore';
|
||||
import AccountCircleIcon from '@material-ui/icons/AccountCircle';
|
||||
|
||||
const useStyles = makeStyles({
|
||||
avatar: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
});
|
||||
|
||||
export const UserProfile: FC<{ open: boolean; setOpen: Function }> = ({
|
||||
open,
|
||||
setOpen,
|
||||
}) => {
|
||||
const [profile, setProfile] = useState<ProfileInfo>();
|
||||
const ref = useRef<Element>(); // for scrolling down when collapse item opens
|
||||
const googleAuth = useApi(googleAuthApiRef);
|
||||
const classes = useStyles();
|
||||
|
||||
const handleClick = () => {
|
||||
setOpen(!open);
|
||||
setTimeout(() => ref.current?.scrollIntoView({ behavior: 'smooth' }), 300);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProfile = async () => {
|
||||
await googleAuth
|
||||
.getProfile({ optional: true })
|
||||
.then((userProfile?: ProfileInfo) => {
|
||||
setProfile(userProfile);
|
||||
});
|
||||
};
|
||||
|
||||
let subscription: Subscription;
|
||||
const observeSession = () => {
|
||||
subscription = googleAuth
|
||||
.sessionState$()
|
||||
.subscribe(async (sessionState: SessionState) => {
|
||||
if (sessionState === SessionState.SignedIn) {
|
||||
await fetchProfile();
|
||||
} else {
|
||||
setProfile(undefined);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
fetchProfile();
|
||||
observeSession();
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [googleAuth]);
|
||||
|
||||
// Handle main auth info that is shown on the collapsible SidebarItem
|
||||
let avatar;
|
||||
let displayName = 'Guest';
|
||||
if (profile) {
|
||||
const email = profile.email;
|
||||
const name = profile.name;
|
||||
const imageUrl = profile.picture;
|
||||
const emailTrimmed = email.split('@')[0];
|
||||
const displayEmail =
|
||||
emailTrimmed.charAt(0).toUpperCase() + emailTrimmed.slice(1);
|
||||
displayName = name ?? displayEmail;
|
||||
avatar = imageUrl
|
||||
? () => (
|
||||
<Avatar alt={displayName} src={imageUrl} className={classes.avatar} />
|
||||
)
|
||||
: () => <Avatar alt={displayName} className={classes.avatar} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider innerRef={ref} />
|
||||
<SidebarItem
|
||||
text={displayName}
|
||||
onClick={handleClick}
|
||||
icon={avatar || AccountCircleIcon}
|
||||
disableSelected
|
||||
>
|
||||
{open ? <ExpandLess /> : <ExpandMore />}
|
||||
</SidebarItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* 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 { ProviderSettingsItem } from './ProviderSettingsItem';
|
||||
export { OAuthProviderSettings } from './OAuthProviderSettings';
|
||||
export { OIDCProviderSettings } from './OIDCProviderSettings';
|
||||
export { UserProfile } from './UserProfile';
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
SidebarDivider,
|
||||
SidebarSearchField,
|
||||
SidebarSpace,
|
||||
SidebarUserSettings,
|
||||
} from '.';
|
||||
import HomeOutlinedIcon from '@material-ui/icons/HomeOutlined';
|
||||
import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline';
|
||||
@@ -54,5 +55,6 @@ export const SampleSidebar = () => (
|
||||
<SidebarIntro />
|
||||
<SidebarSpace />
|
||||
<SidebarDivider />
|
||||
<SidebarUserSettings />
|
||||
</Sidebar>
|
||||
);
|
||||
|
||||
@@ -14,174 +14,40 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useState, useContext, useEffect, useRef } from 'react';
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import Collapse from '@material-ui/core/Collapse';
|
||||
import ExpandLess from '@material-ui/icons/ExpandLess';
|
||||
import ExpandMore from '@material-ui/icons/ExpandMore';
|
||||
import StarBorder from '@material-ui/icons/StarBorder';
|
||||
import Star from '@material-ui/icons/Star';
|
||||
import { SidebarContext } from './config';
|
||||
import { SidebarItem } from './Items';
|
||||
import AccountCircleIcon from '@material-ui/icons/AccountCircle';
|
||||
import Divider from '@material-ui/core/Divider';
|
||||
import { googleAuthApiRef, githubAuthApiRef } from '@backstage/core-api';
|
||||
import {
|
||||
useApi,
|
||||
googleAuthApiRef,
|
||||
githubAuthApiRef,
|
||||
ProfileInfo,
|
||||
} from '@backstage/core-api';
|
||||
import { Avatar, IconButton, makeStyles, Tooltip } from '@material-ui/core';
|
||||
import PowerButton from '@material-ui/icons/PowerSettingsNew';
|
||||
|
||||
type Provider = {
|
||||
title: string;
|
||||
api: any;
|
||||
identity?: boolean;
|
||||
isSignedIn: boolean;
|
||||
icon: any;
|
||||
};
|
||||
|
||||
const useProviders = () => {
|
||||
const googleAuth = useApi(googleAuthApiRef);
|
||||
const githubAuth = useApi(githubAuthApiRef);
|
||||
const [providers, setProviders] = useState<Provider[]>([
|
||||
{
|
||||
title: 'Google',
|
||||
api: googleAuth,
|
||||
identity: true,
|
||||
isSignedIn: false,
|
||||
icon: Star,
|
||||
},
|
||||
{
|
||||
title: 'Github',
|
||||
api: githubAuth,
|
||||
isSignedIn: false,
|
||||
icon: StarBorder,
|
||||
},
|
||||
]);
|
||||
|
||||
const setIsSignedIn = async () => {
|
||||
const signInChecks = await Promise.all(
|
||||
providers.map(provider =>
|
||||
provider.identity
|
||||
? provider.api.getIdToken({ optional: true })
|
||||
: provider.api.getAccessToken('', { optional: true }),
|
||||
),
|
||||
);
|
||||
|
||||
signInChecks.map((result, i) => {
|
||||
providers[i].isSignedIn = !!result;
|
||||
});
|
||||
|
||||
setProviders(providers);
|
||||
};
|
||||
|
||||
setIsSignedIn();
|
||||
|
||||
return providers;
|
||||
};
|
||||
|
||||
const useStyles = makeStyles({
|
||||
avatar: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
});
|
||||
OAuthProviderSettings,
|
||||
OIDCProviderSettings,
|
||||
UserProfile as SidebarUserProfile,
|
||||
} from './Settings';
|
||||
|
||||
export function SidebarUserSettings() {
|
||||
const { isOpen: sidebarOpen } = useContext(SidebarContext);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const ref = useRef<Element>(); // for scrolling down when collapse item opens
|
||||
const providers = useProviders();
|
||||
const [profile, setProfile] = useState<ProfileInfo>();
|
||||
const classes = useStyles();
|
||||
|
||||
// TODO(soapraj): List all the providers supported by the app and let user log in from here
|
||||
// TODO(soapraj): How to observe if the user is logged in
|
||||
useEffect(() => {
|
||||
const identityProvider = providers.find(
|
||||
(provider: Provider) => provider.identity,
|
||||
);
|
||||
identityProvider?.api
|
||||
.getProfile({ optional: true })
|
||||
.then((userProfile: ProfileInfo) => {
|
||||
setProfile(userProfile);
|
||||
});
|
||||
}, [providers, open]);
|
||||
|
||||
const handleClick = () => {
|
||||
setOpen(!open);
|
||||
setTimeout(() => ref.current?.scrollIntoView({ behavior: 'smooth' }), 300);
|
||||
};
|
||||
|
||||
// Close the provider list when sidebar collapse
|
||||
useEffect(() => {
|
||||
if (!sidebarOpen && open) setOpen(false);
|
||||
}, [open, sidebarOpen]);
|
||||
|
||||
// Handle main auth info that is shown on the collapsible SidebarItem
|
||||
let avatar;
|
||||
let displayName;
|
||||
if (profile) {
|
||||
const email = profile.email;
|
||||
const name = profile.name;
|
||||
const imageUrl = profile.picture;
|
||||
const avatarFallback = email.charAt(0).toUpperCase() + email.slice(1);
|
||||
const emailTrimmed = email.split('@')[0];
|
||||
const displayEmail =
|
||||
emailTrimmed.charAt(0).toUpperCase() + emailTrimmed.slice(1);
|
||||
displayName = name ?? displayEmail;
|
||||
avatar = imageUrl
|
||||
? () => <Avatar alt={name} src={imageUrl} className={classes.avatar} />
|
||||
: () => (
|
||||
<Avatar alt={name} className={classes.avatar}>
|
||||
{avatarFallback[0]}
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider innerRef={ref} />
|
||||
<SidebarItem
|
||||
text={displayName || 'Guest'}
|
||||
onClick={handleClick}
|
||||
icon={avatar || AccountCircleIcon}
|
||||
disableSelected
|
||||
>
|
||||
{open ? <ExpandLess /> : <ExpandMore />}
|
||||
</SidebarItem>
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
{providers.map((provider: Provider) => (
|
||||
<SidebarItem
|
||||
key={provider.title}
|
||||
text={provider.title}
|
||||
icon={provider.icon ?? StarBorder}
|
||||
disableSelected
|
||||
>
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
provider.isSignedIn
|
||||
? provider.api.logout()
|
||||
: provider.api.getAccessToken()
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
arrow
|
||||
title={
|
||||
provider.isSignedIn
|
||||
? `Logout from ${provider.title}`
|
||||
: `Sign in to ${provider.title}`
|
||||
}
|
||||
>
|
||||
<PowerButton
|
||||
color={provider.isSignedIn ? 'secondary' : 'primary'}
|
||||
/>
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</SidebarItem>
|
||||
))}
|
||||
<SidebarUserProfile open={open} setOpen={setOpen} />
|
||||
<Collapse in={open} timeout="auto">
|
||||
<OIDCProviderSettings
|
||||
title="Google"
|
||||
apiRef={googleAuthApiRef}
|
||||
icon={Star}
|
||||
/>
|
||||
<OAuthProviderSettings
|
||||
title="Github"
|
||||
apiRef={githubAuthApiRef}
|
||||
icon={Star}
|
||||
/>
|
||||
</Collapse>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -34,3 +34,4 @@ export {
|
||||
export type { SidebarContextType } from './config';
|
||||
export { SidebarThemeToggle } from './SidebarThemeToggle';
|
||||
export { SidebarUserSettings } from './UserSettings';
|
||||
export * from './Settings';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/dev-utils",
|
||||
"description": "Utilities for developing Backstage plugins.",
|
||||
"version": "0.1.1-alpha.7",
|
||||
"version": "0.1.1-alpha.8",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
@@ -30,10 +30,10 @@
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.7",
|
||||
"@backstage/core": "^0.1.1-alpha.7",
|
||||
"@backstage/test-utils": "^0.1.1-alpha.7",
|
||||
"@backstage/theme": "^0.1.1-alpha.7",
|
||||
"@backstage/cli": "^0.1.1-alpha.8",
|
||||
"@backstage/core": "^0.1.1-alpha.8",
|
||||
"@backstage/test-utils": "^0.1.1-alpha.8",
|
||||
"@backstage/theme": "^0.1.1-alpha.8",
|
||||
"@material-ui/core": "^4.9.1",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@testing-library/jest-dom": "^5.7.0",
|
||||
@@ -43,8 +43,8 @@
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
"react-hot-loader": "^4.12.21",
|
||||
"react-router": "^5.2.0",
|
||||
"react-router-dom": "^5.2.0"
|
||||
"react-router": "^6.0.0-alpha.5",
|
||||
"react-router-dom": "^6.0.0-alpha.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^25.2.2",
|
||||
|
||||
@@ -5,10 +5,12 @@ import {
|
||||
oauthRequestApiRef,
|
||||
OAuthRequestManager,
|
||||
googleAuthApiRef,
|
||||
githubAuthApiRef,
|
||||
AlertApiForwarder,
|
||||
ErrorApiForwarder,
|
||||
ErrorAlerter,
|
||||
GoogleAuth,
|
||||
GithubAuth,
|
||||
} from '@backstage/core';
|
||||
|
||||
const builder = ApiRegistry.builder();
|
||||
@@ -31,4 +33,13 @@ builder.add(
|
||||
}),
|
||||
);
|
||||
|
||||
builder.add(
|
||||
githubAuthApiRef,
|
||||
GithubAuth.create({
|
||||
apiOrigin: 'http://localhost:7000',
|
||||
basePath: '/auth/',
|
||||
oauthRequestApi,
|
||||
}),
|
||||
);
|
||||
|
||||
export const apis = builder.build();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "storybook",
|
||||
"version": "0.1.1-alpha.7",
|
||||
"version": "0.1.1-alpha.8",
|
||||
"description": "Storybook build for core package",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
@@ -14,7 +14,7 @@
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/theme": "^0.1.1-alpha.7"
|
||||
"@backstage/theme": "^0.1.1-alpha.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-actions": "^5.3.17",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/test-utils",
|
||||
"description": "Utilities to test Backstage plugins and apps.",
|
||||
"version": "0.1.1-alpha.7",
|
||||
"version": "0.1.1-alpha.8",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
@@ -30,10 +30,10 @@
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.7",
|
||||
"@backstage/core-api": "^0.1.1-alpha.7",
|
||||
"@backstage/cli": "^0.1.1-alpha.8",
|
||||
"@backstage/core-api": "^0.1.1-alpha.8",
|
||||
"@backstage/test-utils-core": "^0.1.1-alpha.7",
|
||||
"@backstage/theme": "^0.1.1-alpha.7",
|
||||
"@backstage/theme": "^0.1.1-alpha.8",
|
||||
"@material-ui/core": "^4.9.1",
|
||||
"@testing-library/jest-dom": "^5.7.0",
|
||||
"@testing-library/react": "^9.3.2",
|
||||
@@ -41,8 +41,8 @@
|
||||
"@types/react": "^16.9",
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
"react-router": "^5.2.0",
|
||||
"react-router-dom": "^5.2.0"
|
||||
"react-router": "^6.0.0-alpha.5",
|
||||
"react-router-dom": "^6.0.0-alpha.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^25.2.2",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
import React, { FC, useEffect } from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { wrapInTestApp, renderInTestApp } from './appWrappers';
|
||||
import { Route } from 'react-router';
|
||||
import { Route, Routes } from 'react-router';
|
||||
import { withLogCollector } from '@backstage/test-utils-core';
|
||||
import {
|
||||
useApi,
|
||||
@@ -32,15 +32,15 @@ describe('wrapInTestApp', () => {
|
||||
const { error } = await withLogCollector(['error'], async () => {
|
||||
const rendered = render(
|
||||
wrapInTestApp(
|
||||
<>
|
||||
<Route path="/route1">Route 1</Route>
|
||||
<Route path="/route2">Route 2</Route>
|
||||
</>,
|
||||
<Routes>
|
||||
<Route path="/route1" element={<p>Route 1</p>} />
|
||||
<Route path="/route2" element={<p>Route 2</p>} />
|
||||
</Routes>,
|
||||
{ routeEntries: ['/route2'] },
|
||||
),
|
||||
);
|
||||
expect(rendered.getByText('Route 2')).toBeInTheDocument();
|
||||
|
||||
expect(rendered.getByText('Route 2')).toBeInTheDocument();
|
||||
// Wait for async actions to trigger the act() warnings that we assert below
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
@@ -92,7 +92,9 @@ export function wrapInTestApp(
|
||||
|
||||
return (
|
||||
<AppProvider>
|
||||
<Route component={Wrapper} />
|
||||
{/* The path of * here is needed to be set as a catch all, so it will render the wrapper element
|
||||
* and work with nested routes if they exist too */}
|
||||
<Route path="*" element={<Wrapper />} />
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@backstage/theme",
|
||||
"description": "material-ui theme for use with Backstage.",
|
||||
"version": "0.1.1-alpha.7",
|
||||
"version": "0.1.1-alpha.8",
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
@@ -32,7 +32,7 @@
|
||||
"@material-ui/core": "^4.9.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.7"
|
||||
"@backstage/cli": "^0.1.1-alpha.8"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*.{js,d.ts}"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@backstage/plugin-auth-backend",
|
||||
"version": "0.1.1-alpha.7",
|
||||
"version": "0.1.1-alpha.8",
|
||||
"main": "dist",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
@@ -15,7 +15,7 @@
|
||||
"clean": "backstage-cli clean"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.1.1-alpha.7",
|
||||
"@backstage/backend-common": "^0.1.1-alpha.8",
|
||||
"@types/cookie-parser": "^1.4.2",
|
||||
"@types/jwt-decode": "2.2.1",
|
||||
"@types/passport": "^1.0.3",
|
||||
@@ -39,7 +39,7 @@
|
||||
"yn": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.7",
|
||||
"@backstage/cli": "^0.1.1-alpha.8",
|
||||
"@types/body-parser": "^1.19.0",
|
||||
"@types/passport-saml": "^1.1.2",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* 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 express from 'express';
|
||||
import { AuthProviderRouteHandlers } from '../providers/types';
|
||||
import { NotFoundError } from '@backstage/backend-common';
|
||||
|
||||
export type EnvironmentHandlers = {
|
||||
[key: string]: AuthProviderRouteHandlers;
|
||||
};
|
||||
|
||||
export class EnvironmentHandler implements AuthProviderRouteHandlers {
|
||||
constructor(private readonly providers: EnvironmentHandlers) {}
|
||||
|
||||
private getProviderForEnv(req: express.Request): AuthProviderRouteHandlers {
|
||||
const env = req.query.env?.toString();
|
||||
if (!this.providers.hasOwnProperty(env)) {
|
||||
throw new NotFoundError(
|
||||
`No environment for ${env} found in this provider`,
|
||||
);
|
||||
}
|
||||
return this.providers[env];
|
||||
}
|
||||
|
||||
async start(req: express.Request, res: express.Response): Promise<void> {
|
||||
const provider = this.getProviderForEnv(req);
|
||||
provider.start(req, res);
|
||||
}
|
||||
|
||||
async frameHandler(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
): Promise<void> {
|
||||
const provider = this.getProviderForEnv(req);
|
||||
provider.frameHandler(req, res);
|
||||
}
|
||||
|
||||
async refresh(req: express.Request, res: express.Response): Promise<void> {
|
||||
const provider = this.getProviderForEnv(req);
|
||||
if (provider.refresh) {
|
||||
provider.refresh(req, res);
|
||||
}
|
||||
}
|
||||
|
||||
async logout(req: express.Request, res: express.Response): Promise<void> {
|
||||
const provider = this.getProviderForEnv(req);
|
||||
provider.logout(req, res);
|
||||
}
|
||||
}
|
||||
+49
-60
@@ -18,15 +18,12 @@ import express from 'express';
|
||||
import {
|
||||
ensuresXRequestedWith,
|
||||
postMessageResponse,
|
||||
removeRefreshTokenCookie,
|
||||
setRefreshTokenCookie,
|
||||
THOUSAND_DAYS_MS,
|
||||
setNonceCookie,
|
||||
TEN_MINUTES_MS,
|
||||
verifyNonce,
|
||||
OAuthProvider,
|
||||
} from './OAuthProvider';
|
||||
import { AuthResponse, OAuthProviderHandlers } from './types';
|
||||
import { AuthResponse, OAuthProviderHandlers } from '../providers/types';
|
||||
|
||||
describe('OAuthProvider Utils', () => {
|
||||
describe('verifyNonce', () => {
|
||||
@@ -80,52 +77,8 @@ describe('OAuthProvider Utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('setNonceCookie', () => {
|
||||
it('should set nonce cookie', () => {
|
||||
const mockResponse = ({
|
||||
cookie: jest.fn().mockReturnThis(),
|
||||
} as unknown) as express.Response;
|
||||
setNonceCookie(mockResponse, 'providera');
|
||||
expect(mockResponse.cookie).toBeCalledTimes(1);
|
||||
expect(mockResponse.cookie).toBeCalledWith(
|
||||
'providera-nonce',
|
||||
expect.any(String),
|
||||
expect.objectContaining({ maxAge: TEN_MINUTES_MS }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setRefreshTokenCookie', () => {
|
||||
it('should set refresh token cookie', () => {
|
||||
const mockResponse = ({
|
||||
cookie: jest.fn().mockReturnThis(),
|
||||
} as unknown) as express.Response;
|
||||
setRefreshTokenCookie(mockResponse, 'providera', 'REFRESH_TOKEN');
|
||||
expect(mockResponse.cookie).toBeCalledTimes(1);
|
||||
expect(mockResponse.cookie).toBeCalledWith(
|
||||
'providera-refresh-token',
|
||||
'REFRESH_TOKEN',
|
||||
expect.objectContaining({ maxAge: THOUSAND_DAYS_MS }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeRefreshTokenCookie', () => {
|
||||
it('should remove refresh token cookie', () => {
|
||||
const mockResponse = ({
|
||||
cookie: jest.fn().mockReturnThis(),
|
||||
} as unknown) as express.Response;
|
||||
removeRefreshTokenCookie(mockResponse, 'providera');
|
||||
expect(mockResponse.cookie).toBeCalledTimes(1);
|
||||
expect(mockResponse.cookie).toBeCalledWith(
|
||||
'providera-refresh-token',
|
||||
'',
|
||||
expect.objectContaining({ maxAge: 0 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('postMessageResponse', () => {
|
||||
const appOrigin = 'http://localhost:3000';
|
||||
it('should post a message back with payload success', () => {
|
||||
const mockResponse = ({
|
||||
end: jest.fn().mockReturnThis(),
|
||||
@@ -144,7 +97,7 @@ describe('OAuthProvider Utils', () => {
|
||||
const jsonData = JSON.stringify(data);
|
||||
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
|
||||
|
||||
postMessageResponse(mockResponse, data);
|
||||
postMessageResponse(mockResponse, appOrigin, data);
|
||||
expect(mockResponse.setHeader).toBeCalledTimes(2);
|
||||
expect(mockResponse.end).toBeCalledTimes(1);
|
||||
expect(mockResponse.end).toBeCalledWith(
|
||||
@@ -165,7 +118,7 @@ describe('OAuthProvider Utils', () => {
|
||||
const jsonData = JSON.stringify(data);
|
||||
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
|
||||
|
||||
postMessageResponse(mockResponse, data);
|
||||
postMessageResponse(mockResponse, appOrigin, data);
|
||||
expect(mockResponse.setHeader).toBeCalledTimes(2);
|
||||
expect(mockResponse.end).toBeCalledTimes(1);
|
||||
expect(mockResponse.end).toBeCalledWith(
|
||||
@@ -221,10 +174,19 @@ describe('OAuthProvider', () => {
|
||||
}
|
||||
}
|
||||
const providerInstance = new MyAuthProvider();
|
||||
const providerId = 'test-provider';
|
||||
const oAuthProviderOptions = {
|
||||
providerId: 'test-provider',
|
||||
secure: false,
|
||||
disableRefresh: true,
|
||||
baseUrl: 'http://localhost:7000/auth',
|
||||
appOrigin: 'http://localhost:3000',
|
||||
};
|
||||
|
||||
it('sets the correct headers in start', async () => {
|
||||
const oauthProvider = new OAuthProvider(providerInstance, providerId);
|
||||
const oauthProvider = new OAuthProvider(
|
||||
providerInstance,
|
||||
oAuthProviderOptions,
|
||||
);
|
||||
const mockRequest = ({
|
||||
query: {
|
||||
scope: 'user',
|
||||
@@ -239,6 +201,14 @@ describe('OAuthProvider', () => {
|
||||
} as unknown) as express.Response;
|
||||
|
||||
await oauthProvider.start(mockRequest, mockResponse);
|
||||
// nonce cookie checks
|
||||
expect(mockResponse.cookie).toBeCalledTimes(1);
|
||||
expect(mockResponse.cookie).toBeCalledWith(
|
||||
`${oAuthProviderOptions.providerId}-nonce`,
|
||||
expect.any(String),
|
||||
expect.objectContaining({ maxAge: TEN_MINUTES_MS }),
|
||||
);
|
||||
// redirect checks
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledTimes(2);
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledWith('Location', '/url');
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledWith('Content-Length', '0');
|
||||
@@ -247,7 +217,10 @@ describe('OAuthProvider', () => {
|
||||
});
|
||||
|
||||
it('sets the refresh cookie if refresh is enabled', async () => {
|
||||
const oauthProvider = new OAuthProvider(providerInstance, providerId);
|
||||
const oauthProvider = new OAuthProvider(providerInstance, {
|
||||
...oAuthProviderOptions,
|
||||
disableRefresh: false,
|
||||
});
|
||||
|
||||
const mockRequest = ({
|
||||
cookies: {
|
||||
@@ -269,12 +242,18 @@ describe('OAuthProvider', () => {
|
||||
expect(mockResponse.cookie).toHaveBeenCalledWith(
|
||||
expect.stringContaining('test-provider-refresh-token'),
|
||||
expect.stringContaining('token'),
|
||||
expect.objectContaining({ path: '/auth/test-provider' }),
|
||||
expect.objectContaining({
|
||||
path: '/auth/test-provider',
|
||||
maxAge: THOUSAND_DAYS_MS,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does no set the refresh cookie if refresh is disabled', async () => {
|
||||
const oauthProvider = new OAuthProvider(providerInstance, providerId, true);
|
||||
it('does not set the refresh cookie if refresh is disabled', async () => {
|
||||
const oauthProvider = new OAuthProvider(providerInstance, {
|
||||
...oAuthProviderOptions,
|
||||
disableRefresh: true,
|
||||
});
|
||||
|
||||
const mockRequest = ({
|
||||
cookies: {
|
||||
@@ -296,7 +275,10 @@ describe('OAuthProvider', () => {
|
||||
});
|
||||
|
||||
it('removes refresh cookie when logging out', async () => {
|
||||
const oauthProvider = new OAuthProvider(providerInstance, providerId);
|
||||
const oauthProvider = new OAuthProvider(providerInstance, {
|
||||
...oAuthProviderOptions,
|
||||
disableRefresh: false,
|
||||
});
|
||||
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
@@ -317,7 +299,11 @@ describe('OAuthProvider', () => {
|
||||
});
|
||||
|
||||
it('gets new access-token when refreshing', async () => {
|
||||
const oauthProvider = new OAuthProvider(providerInstance, providerId);
|
||||
oAuthProviderOptions.disableRefresh = false;
|
||||
const oauthProvider = new OAuthProvider(providerInstance, {
|
||||
...oAuthProviderOptions,
|
||||
disableRefresh: false,
|
||||
});
|
||||
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
@@ -341,7 +327,10 @@ describe('OAuthProvider', () => {
|
||||
});
|
||||
|
||||
it('handles refresh without capabilities', async () => {
|
||||
const oauthProvider = new OAuthProvider(providerInstance, providerId, true);
|
||||
const oauthProvider = new OAuthProvider(providerInstance, {
|
||||
...oAuthProviderOptions,
|
||||
disableRefresh: true,
|
||||
});
|
||||
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
+73
-75
@@ -14,20 +14,29 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import express, { CookieOptions } from 'express';
|
||||
import express from 'express';
|
||||
import crypto from 'crypto';
|
||||
import { URL } from 'url';
|
||||
import {
|
||||
AuthResponse,
|
||||
AuthProviderRouteHandlers,
|
||||
OAuthProviderHandlers,
|
||||
} from './types';
|
||||
} from '../providers/types';
|
||||
import { InputError } from '@backstage/backend-common';
|
||||
|
||||
export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000;
|
||||
export const TEN_MINUTES_MS = 600 * 1000;
|
||||
|
||||
export const verifyNonce = (req: express.Request, provider: string) => {
|
||||
const cookieNonce = req.cookies[`${provider}-nonce`];
|
||||
export type Options = {
|
||||
providerId: string;
|
||||
secure: boolean;
|
||||
disableRefresh?: boolean;
|
||||
baseUrl: string;
|
||||
appOrigin: string;
|
||||
};
|
||||
|
||||
export const verifyNonce = (req: express.Request, providerId: string) => {
|
||||
const cookieNonce = req.cookies[`${providerId}-nonce`];
|
||||
const stateNonce = req.query.state;
|
||||
|
||||
if (!cookieNonce || !stateNonce) {
|
||||
@@ -39,58 +48,9 @@ export const verifyNonce = (req: express.Request, provider: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const setNonceCookie = (res: express.Response, provider: string) => {
|
||||
const nonce = crypto.randomBytes(16).toString('base64');
|
||||
|
||||
const options: CookieOptions = {
|
||||
maxAge: TEN_MINUTES_MS,
|
||||
secure: false,
|
||||
sameSite: 'none',
|
||||
domain: 'localhost',
|
||||
path: `/auth/${provider}/handler`,
|
||||
httpOnly: true,
|
||||
};
|
||||
|
||||
res.cookie(`${provider}-nonce`, nonce, options);
|
||||
|
||||
return nonce;
|
||||
};
|
||||
|
||||
export const setRefreshTokenCookie = (
|
||||
res: express.Response,
|
||||
provider: string,
|
||||
refreshToken: string,
|
||||
) => {
|
||||
const options: CookieOptions = {
|
||||
maxAge: THOUSAND_DAYS_MS,
|
||||
secure: false,
|
||||
sameSite: 'none',
|
||||
domain: 'localhost',
|
||||
path: `/auth/${provider}`,
|
||||
httpOnly: true,
|
||||
};
|
||||
|
||||
res.cookie(`${provider}-refresh-token`, refreshToken, options);
|
||||
};
|
||||
|
||||
export const removeRefreshTokenCookie = (
|
||||
res: express.Response,
|
||||
provider: string,
|
||||
) => {
|
||||
const options: CookieOptions = {
|
||||
maxAge: 0,
|
||||
secure: false,
|
||||
sameSite: 'none',
|
||||
domain: 'localhost',
|
||||
path: `/auth/${provider}`,
|
||||
httpOnly: true,
|
||||
};
|
||||
|
||||
res.cookie(`${provider}-refresh-token`, '', options);
|
||||
};
|
||||
|
||||
export const postMessageResponse = (
|
||||
res: express.Response,
|
||||
appOrigin: string,
|
||||
data: AuthResponse,
|
||||
) => {
|
||||
const jsonData = JSON.stringify(data);
|
||||
@@ -104,7 +64,7 @@ export const postMessageResponse = (
|
||||
<html>
|
||||
<body>
|
||||
<script>
|
||||
(window.opener || window.parent).postMessage(JSON.parse(atob('${base64Data}')), 'http://localhost:3000')
|
||||
(window.opener || window.parent).postMessage(JSON.parse(atob('${base64Data}')), '${appOrigin}')
|
||||
window.close()
|
||||
</script>
|
||||
</body>
|
||||
@@ -122,17 +82,16 @@ export const ensuresXRequestedWith = (req: express.Request) => {
|
||||
};
|
||||
|
||||
export class OAuthProvider implements AuthProviderRouteHandlers {
|
||||
private readonly provider: string;
|
||||
private readonly providerHandlers: OAuthProviderHandlers;
|
||||
private readonly disableRefresh: boolean;
|
||||
private readonly domain: string;
|
||||
private readonly basePath: string;
|
||||
|
||||
constructor(
|
||||
providerHandlers: OAuthProviderHandlers,
|
||||
provider: string,
|
||||
disableRefresh?: boolean,
|
||||
private readonly providerHandlers: OAuthProviderHandlers,
|
||||
private readonly options: Options,
|
||||
) {
|
||||
this.provider = provider;
|
||||
this.providerHandlers = providerHandlers;
|
||||
this.disableRefresh = disableRefresh ?? false;
|
||||
const url = new URL(options.baseUrl);
|
||||
this.domain = url.hostname;
|
||||
this.basePath = url.pathname;
|
||||
}
|
||||
|
||||
async start(req: express.Request, res: express.Response): Promise<any> {
|
||||
@@ -143,8 +102,9 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
|
||||
throw new InputError('missing scope parameter');
|
||||
}
|
||||
|
||||
const nonce = crypto.randomBytes(16).toString('base64');
|
||||
// set a nonce cookie before redirecting to oauth provider
|
||||
const nonce = setNonceCookie(res, this.provider);
|
||||
this.setNonceCookie(res, nonce);
|
||||
|
||||
const options = {
|
||||
scope,
|
||||
@@ -152,6 +112,7 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
|
||||
prompt: 'consent',
|
||||
state: nonce,
|
||||
};
|
||||
|
||||
const { url, status } = await this.providerHandlers.start(req, options);
|
||||
|
||||
res.statusCode = status || 302;
|
||||
@@ -166,11 +127,11 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
|
||||
): Promise<any> {
|
||||
try {
|
||||
// verify nonce cookie and state cookie on callback
|
||||
verifyNonce(req, this.provider);
|
||||
verifyNonce(req, this.options.providerId);
|
||||
|
||||
const { user, info } = await this.providerHandlers.handler(req);
|
||||
|
||||
if (!this.disableRefresh) {
|
||||
if (!this.options.disableRefresh) {
|
||||
// throw error if missing refresh token
|
||||
const { refreshToken } = info;
|
||||
if (!refreshToken) {
|
||||
@@ -178,17 +139,17 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
|
||||
}
|
||||
|
||||
// set new refresh token
|
||||
setRefreshTokenCookie(res, this.provider, refreshToken);
|
||||
this.setRefreshTokenCookie(res, refreshToken);
|
||||
}
|
||||
|
||||
// post message back to popup if successful
|
||||
return postMessageResponse(res, {
|
||||
return postMessageResponse(res, this.options.appOrigin, {
|
||||
type: 'auth-result',
|
||||
payload: user,
|
||||
});
|
||||
} catch (error) {
|
||||
// post error message back to popup if failure
|
||||
return postMessageResponse(res, {
|
||||
return postMessageResponse(res, this.options.appOrigin, {
|
||||
type: 'auth-result',
|
||||
error: {
|
||||
name: error.name,
|
||||
@@ -203,9 +164,9 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
|
||||
return res.status(401).send('Invalid X-Requested-With header');
|
||||
}
|
||||
|
||||
if (!this.disableRefresh) {
|
||||
if (!this.options.disableRefresh) {
|
||||
// remove refresh token cookie before logout
|
||||
removeRefreshTokenCookie(res, this.provider);
|
||||
this.removeRefreshTokenCookie(res);
|
||||
}
|
||||
return res.send('logout!');
|
||||
}
|
||||
@@ -215,14 +176,15 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
|
||||
return res.status(401).send('Invalid X-Requested-With header');
|
||||
}
|
||||
|
||||
if (!this.providerHandlers.refresh || this.disableRefresh) {
|
||||
if (!this.providerHandlers.refresh || this.options.disableRefresh) {
|
||||
return res.send(
|
||||
`Refresh token not supported for provider: ${this.provider}`,
|
||||
`Refresh token not supported for provider: ${this.options.providerId}`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const refreshToken = req.cookies[`${this.provider}-refresh-token`];
|
||||
const refreshToken =
|
||||
req.cookies[`${this.options.providerId}-refresh-token`];
|
||||
|
||||
// throw error if refresh token is missing in the request
|
||||
if (!refreshToken) {
|
||||
@@ -241,4 +203,40 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
|
||||
return res.status(401).send(`${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private setNonceCookie = (res: express.Response, nonce: string) => {
|
||||
res.cookie(`${this.options.providerId}-nonce`, nonce, {
|
||||
maxAge: TEN_MINUTES_MS,
|
||||
secure: this.options.secure,
|
||||
sameSite: 'none',
|
||||
domain: this.domain,
|
||||
path: `${this.basePath}/${this.options.providerId}/handler`,
|
||||
httpOnly: true,
|
||||
});
|
||||
};
|
||||
|
||||
private setRefreshTokenCookie = (
|
||||
res: express.Response,
|
||||
refreshToken: string,
|
||||
) => {
|
||||
res.cookie(`${this.options.providerId}-refresh-token`, refreshToken, {
|
||||
maxAge: THOUSAND_DAYS_MS,
|
||||
secure: this.options.secure,
|
||||
sameSite: 'none',
|
||||
domain: this.domain,
|
||||
path: `${this.basePath}/${this.options.providerId}`,
|
||||
httpOnly: true,
|
||||
});
|
||||
};
|
||||
|
||||
private removeRefreshTokenCookie = (res: express.Response) => {
|
||||
res.cookie(`${this.options.providerId}-refresh-token`, '', {
|
||||
maxAge: 0,
|
||||
secure: false,
|
||||
sameSite: 'none',
|
||||
domain: `${this.domain}`,
|
||||
path: `${this.basePath}/${this.options.providerId}`,
|
||||
httpOnly: true,
|
||||
});
|
||||
};
|
||||
}
|
||||
+5
-1
@@ -17,7 +17,11 @@
|
||||
import express from 'express';
|
||||
import passport from 'passport';
|
||||
import jwtDecoder from 'jwt-decode';
|
||||
import { RedirectInfo, RefreshTokenResponse, ProfileInfo } from './types';
|
||||
import {
|
||||
RedirectInfo,
|
||||
RefreshTokenResponse,
|
||||
ProfileInfo,
|
||||
} from '../providers/types';
|
||||
|
||||
export const makeProfileInfo = (
|
||||
profile: passport.Profile,
|
||||
@@ -1,43 +0,0 @@
|
||||
/*
|
||||
* 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 const providers = [
|
||||
{
|
||||
provider: 'google',
|
||||
options: {
|
||||
clientID: process.env.AUTH_GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.AUTH_GOOGLE_CLIENT_SECRET!,
|
||||
callbackURL: 'http://localhost:7000/auth/google/handler/frame',
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: 'github',
|
||||
options: {
|
||||
clientID: process.env.AUTH_GITHUB_CLIENT_ID!,
|
||||
clientSecret: process.env.AUTH_GITHUB_CLIENT_SECRET!,
|
||||
callbackURL: 'http://localhost:7000/auth/github/handler/frame',
|
||||
},
|
||||
disableRefresh: true,
|
||||
},
|
||||
{
|
||||
provider: 'saml',
|
||||
options: {
|
||||
path: '/auth/saml/handler/frame',
|
||||
entryPoint: 'http://localhost:7001/',
|
||||
issuer: 'passport-saml',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -19,6 +19,7 @@ import { createGithubProvider } from './github';
|
||||
import { createGoogleProvider } from './google';
|
||||
import { createSamlProvider } from './saml';
|
||||
import { AuthProviderFactory, AuthProviderConfig } from './types';
|
||||
import { Logger } from 'winston';
|
||||
|
||||
const factories: { [providerId: string]: AuthProviderFactory } = {
|
||||
google: createGoogleProvider,
|
||||
@@ -26,17 +27,18 @@ const factories: { [providerId: string]: AuthProviderFactory } = {
|
||||
saml: createSamlProvider,
|
||||
};
|
||||
|
||||
export function createAuthProvider(providerId: string, config: any) {
|
||||
export const createAuthProviderRouter = (
|
||||
providerId: string,
|
||||
globalConfig: AuthProviderConfig,
|
||||
providerConfig: any, // TODO: make this a config reader object of sorts
|
||||
logger: Logger,
|
||||
) => {
|
||||
const factory = factories[providerId];
|
||||
if (!factory) {
|
||||
throw Error(`No auth provider available for '${providerId}'`);
|
||||
}
|
||||
return factory(config);
|
||||
}
|
||||
|
||||
export const createAuthProviderRouter = (config: AuthProviderConfig) => {
|
||||
const providerId = config.provider;
|
||||
const provider = createAuthProvider(providerId, config);
|
||||
const provider = factory(globalConfig, providerConfig, logger);
|
||||
|
||||
const router = Router();
|
||||
router.get('/start', provider.start.bind(provider));
|
||||
@@ -46,5 +48,6 @@ export const createAuthProviderRouter = (config: AuthProviderConfig) => {
|
||||
if (provider.refresh) {
|
||||
router.get('/refresh', provider.refresh.bind(provider));
|
||||
}
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user