This commit is contained in:
Ryan Vazquez
2020-09-10 11:26:38 -04:00
90 changed files with 6572 additions and 1082 deletions
+9 -1
View File
@@ -5,6 +5,14 @@ updates:
schedule:
interval: daily
time: '04:00'
open-pull-requests-limit: 10
open-pull-requests-limit: 5
labels:
- dependencies
- package-ecosystem: npm
directory: '/microsite/'
schedule:
interval: daily
time: '04:00'
open-pull-requests-limit: 2
labels:
- dependencies
+19
View File
@@ -0,0 +1,19 @@
# Owners
- See [CONTRIBUTING.md](CONTRIBUTING.md) for general contribution guidelines.
## Maintainers 🏓
- Patrik Oldsberg (@Rugvip, Spotify)
- Fredrik Adelöw (@freben, Spotify)
- Raghunandan Balachandran (@soapraj, Spotify)
- Ben Lambert (@benjdlambert, Spotify)
- Marcus Eide (@marcuseide, Spotify)
- Niklas Ek (@nikek, Spotify)
- Stefan Ålund (@stefanalund, Spotify)
- Kat Zhou (@katz95, Spotify)
## Hall of Fame 👏
- Andrew Thauer (@andrewthauer, Wealthsimple)
- Oliver Sand (@Fox32, SDA-SE)
@@ -1,4 +1,7 @@
# Installing in your Backstage App
---
id: installation
title: Installing in your Backstage App
---
The catalog plugin comes in two packages, `@backstage/plugin-catalog` and
`@backstage/plugin-catalog-backend`. Each has their own installation steps,
+8 -5
View File
@@ -15,12 +15,15 @@ like GitHub.
### Getting Started
The Software Templates are available under `/create`. If you've followed
[Installing in your Backstage App](./installation.md) in your separate App or
[Getting Started with Backstage](../../getting-started) for this repo, you
should be able to reach `http://localhost:3000/create`.
> Be sure to have covered [Installing in your Backstage App](./installation.md)
> for your separate App or
> [Getting Started with Backstage](../../getting-started) for this repo before
> proceeding.
You should get something that looks similar to this:
The Software Templates are available under `/create`. For local development you
should be able to reach them at `http://localhost:3000/create`.
Once there, you should see something that looks similar to this:
![Create Image](../../assets/software-templates/create.png)
@@ -1,4 +1,7 @@
# Installing in your Backstage App
---
id: installation
title: Installing in your Backstage App
---
The scaffolder plugin comes in two packages, `@backstage/plugin-scaffolder` and
`@backstage/plugin-scaffolder-backend`. Each has their own installation steps,
@@ -88,5 +88,5 @@ To do this you can run:
```bash
cd ~/<repository-path>/
npx techdocs-cli serve
npx @techdocs/cli serve
```
+29 -33
View File
@@ -37,44 +37,39 @@ app-folder is the name that was provided when prompted.
Inside that directory, it will generate all the files and folder structure
needed for you to run your app.
### Folder structure
### General folder structure
Below is a simplified layout of the files and folders generated when creating an
app.
```
app
├── README.md
├── app-config.yaml
├── lerna.json
├── package.json
── prettier.config.js
├── tsconfig.json
── packages
│ └── app
│ ├── package.json
│ ├── tsconfig.json
│ ├── public
│ │ └── ...
│ └── src
│ ├── App.test.tsx
│ ├── App.tsx
│ ├── index.tsx
│ ├── plugins.ts
│ └── setupTests.ts
└── plugins
└── welcome
├── README.md
├── package.json
├── tsconfig.json
└── src
├── index.ts
├── plugin.test.ts
├── plugin.ts
├── setupTests.ts
└── components
├── Timer
│ └── ...
└── WelcomePage
└── ...
── packages
  ├── app
  ── backend
```
- **app-config.yaml**: Main configuration file for the app. See
[Configuration](https://backstage.io/docs/conf/) for more information.
- **lerna.json**: Contains information about workspaces and other lerna
configuration needed for the monorepo setup.
- **package.json**: Root package.json for the project. _Note: Be sure that you
don't add any npm dependencies here as they probably should be installed in
the intended workspace rather than in the root._
- **packages/**: Lerna leaf packages or "workspaces". Everything here is going
to be a separate package, managed by lerna.
- **packages/app/**: An fully functioning Backstage frontend app, that acts as a
good starting point for you to get to know Backstage.
- **packages/backend/**: We include a backend that helps power features such as
[Authentication](https://backstage.io/docs/auth/),
[Software Catalog](https://backstage.io/docs/features/software-catalog/software-catalog-overview),
[Software Templates](https://backstage.io/docs/features/software-templates/software-templates-index)
and [TechDocs](https://backstage.io/docs/features/techdocs/techdocs-overview)
amongst other things.
## Run the app
When the installation is complete you can open the app folder and start the app.
@@ -90,8 +85,9 @@ app, if not you can navigate to `http://localhost:3000`._
In most cases you will want to start the backend as well, as it is required for
the catalog to work, along with many other plugins.
To start the backend, open a separate terminal session and run the following:
To start the backend, open a separate terminal session and run the following in
the root directory:
```bash
yarn --cwd packages/backend start
yarn workspace backend start
```
+41
View File
@@ -0,0 +1,41 @@
---
id: logos
title: Logos
sidebar_label: Logo assets
---
Guidelines for how to use the Backstage logo and icon can be found
[here](/logo_assets/Backstage_Identity_Assets_Overview.pdf). The assets below
are all in `.svg` format. Other formats are available in the
[repository](https://github.com/spotify/backstage/tree/master/microsite/static/logo_assets).
## Backstage logo
<a href="/logo_assets/svg/Logo_White.svg">
<img src="/logo_assets/svg/Logo_White.svg" width="600" />
</a>
<a href="/logo_assets/svg/Logo_Teal.svg">
<img src="/logo_assets/svg/Logo_Teal.svg" width="600" />
</a>
<a href="/logo_assets/svg/Logo_Black.svg">
<img src="/logo_assets/svg/Logo_Black.svg" width="600" style="background-color:white" />
</a>
## Backstage icon
<div>
<a href="/logo_assets/svg/Icon_White.svg">
<img src="/logo_assets/svg/Icon_White.svg" width="180" height="180" />
</a>
<a href="/logo_assets/svg/Icon_Teal.svg">
<img src="/logo_assets/svg/Icon_Teal.svg" width="180" height="180" />
</a>
<a href="/logo_assets/svg/Icon_Gradient.svg">
<img src="/logo_assets/svg/Icon_Gradient.svg" width="180" height="180" />
</a>
<a href="/logo_assets/svg/Icon_Black.svg">
<img src="/logo_assets/svg/Icon_Black.svg" width="180" height="180" style="background-color:white" />
</a>
</div>
+8 -1
View File
@@ -53,6 +53,11 @@ async function verifyUrl(basePath, url) {
return { url, basePath, problem: 'not-relative' };
}
const staticPath = resolvePath(projectRoot, 'microsite/static', `.${url}`);
if (await fs.pathExists(staticPath)) {
return;
}
path = resolvePath(projectRoot, `.${url}`);
} else {
path = resolvePath(dirname(resolvePath(projectRoot, basePath)), url);
@@ -103,7 +108,9 @@ async function main() {
console.log(`Found ${badUrls.length} bad links within repo`);
for (const { url, basePath, problem } of badUrls) {
if (problem === 'missing') {
console.error(`Unable to reach ${url}, linked from ${basePath}`);
console.error(
`Unable to reach ${url} from root or microsite/static/, linked from ${basePath}`,
);
} else if (problem === 'not-relative') {
console.error('Links to /docs/ must be relative');
console.error(` From: ${basePath}`);
@@ -17,8 +17,6 @@ We are quite sure the main reason for the success of TechDocs is our docs-like-c
![available-templates](assets/announcing-techdocs/docs-in-backstage.png)
Watch this video to see [Spotifys internal version of TechDocs](https://backstage.io/demos) in action.
But this is just one way to do it. Today were most excited for what the open version of TechDocs can become.
## Okay, lets start collaborating
Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 262 KiB

+4 -1
View File
@@ -7,7 +7,8 @@
"overview/roadmap",
"overview/vision",
"overview/background",
"overview/adopting"
"overview/adopting",
"overview/logos"
],
"Getting Started": [
"getting-started/index",
@@ -37,6 +38,7 @@
"label": "Software Catalog",
"ids": [
"features/software-catalog/software-catalog-overview",
"features/software-catalog/installation",
"features/software-catalog/system-model",
"features/software-catalog/descriptor-format",
"features/software-catalog/extending-the-model",
@@ -49,6 +51,7 @@
"label": "Software Templates",
"ids": [
"features/software-templates/software-templates-index",
"features/software-templates/installation",
"features/software-templates/adding-templates",
"features/software-templates/extending/extending-index",
"features/software-templates/extending/extending-templater",
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 299 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

@@ -0,0 +1 @@
<svg id="Assets" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 337.46 428.5"><defs><style>.cls-1{fill:#121212;}</style></defs><title>06 Icon_Black</title><path class="cls-1" d="M303,166.05a80.69,80.69,0,0,0,13.45-10.37c.79-.77,1.55-1.53,2.3-2.3a83.12,83.12,0,0,0,7.93-9.38A63.69,63.69,0,0,0,333,133.23a48.58,48.58,0,0,0,4.35-16.4c1.49-19.39-10-38.67-35.62-54.22L198.56,0,78.3,115.23,0,190.25l108.6,65.91a111.59,111.59,0,0,0,57.76,16.41c24.92,0,48.8-8.8,66.42-25.69,19.16-18.36,25.52-42.12,13.7-61.87a49.22,49.22,0,0,0-6.8-8.87A89.17,89.17,0,0,0,259,178.29h.15a85.08,85.08,0,0,0,31-5.79A80.88,80.88,0,0,0,303,166.05ZM202.45,225.86c-19.32,18.51-50.4,21.23-75.7,5.9L51.61,186.15l67.45-64.64,76.41,46.38C223,184.58,221.49,207.61,202.45,225.86Zm8.93-82.22-70.65-42.89L205.14,39,274.51,81.1c25.94,15.72,29.31,37,10.55,55A60.69,60.69,0,0,1,211.38,143.64Zm29.86,190c-19.57,18.75-46.17,29.09-74.88,29.09a123.73,123.73,0,0,1-64.1-18.2L0,282.52v24.67L108.6,373.1a111.6,111.6,0,0,0,57.76,16.42c24.92,0,48.8-8.81,66.42-25.69,12.88-12.34,20-27.13,19.68-41.49v-1.79A87.27,87.27,0,0,1,241.24,333.68Zm0-39c-19.57,18.75-46.17,29.08-74.88,29.08a123.81,123.81,0,0,1-64.1-18.19L0,243.53v24.68l108.6,65.91a111.6,111.6,0,0,0,57.76,16.42c24.92,0,48.8-8.81,66.42-25.69,12.88-12.34,20-27.13,19.68-41.5v-1.78A87.27,87.27,0,0,1,241.24,294.7Zm0-39c-19.57,18.76-46.17,29.09-74.88,29.09a123.81,123.81,0,0,1-64.1-18.19L0,204.55v24.68l108.6,65.91a111.59,111.59,0,0,0,57.76,16.41c24.92,0,48.8-8.8,66.42-25.68,12.88-12.35,20-27.13,19.68-41.5v-1.82A86.09,86.09,0,0,1,241.24,255.71Zm83.7,25.74a94.15,94.15,0,0,1-60.2,25.86h0V334a81.6,81.6,0,0,0,51.74-22.37c14-13.38,21.14-28.11,21-42.64v-2.19A94.92,94.92,0,0,1,324.94,281.45Zm-83.7,91.21c-19.57,18.76-46.17,29.09-74.88,29.09a123.73,123.73,0,0,1-64.1-18.2L0,321.5v24.68l108.6,65.9a111.6,111.6,0,0,0,57.76,16.42c24.92,0,48.8-8.8,66.42-25.69,12.88-12.34,20-27.13,19.68-41.49v-1.79A86.29,86.29,0,0,1,241.24,372.66ZM327,162.45c-.68.69-1.35,1.38-2.05,2.06a94.37,94.37,0,0,1-10.64,8.65,91.35,91.35,0,0,1-11.6,7,94.53,94.53,0,0,1-26.24,8.71,97.69,97.69,0,0,1-14.16,1.57c.5,1.61.9,3.25,1.25,4.9a53.27,53.27,0,0,1,1.14,12V217h.05a84.41,84.41,0,0,0,25.35-5.55,81,81,0,0,0,26.39-16.82c.8-.77,1.5-1.56,2.26-2.34a82.08,82.08,0,0,0,7.93-9.38A63.76,63.76,0,0,0,333,172.17a48.55,48.55,0,0,0,4.32-16.45c.09-1.23.2-2.47.19-3.7V150q-1.08,1.54-2.25,3.09A96.73,96.73,0,0,1,327,162.45Zm0,77.92c-.69.7-1.31,1.41-2,2.1a94.2,94.2,0,0,1-60.2,25.86h0l0,26.67h0a81.6,81.6,0,0,0,51.74-22.37A73.51,73.51,0,0,0,333,250.13a48.56,48.56,0,0,0,4.32-16.44c.09-1.24.2-2.47.19-3.71v-2.19c-.74,1.07-1.46,2.15-2.27,3.21A95.68,95.68,0,0,1,327,240.37Zm0-39c-.69.7-1.31,1.41-2,2.1a93.18,93.18,0,0,1-10.63,8.65,91.63,91.63,0,0,1-11.63,7,95.47,95.47,0,0,1-37.94,10.18h0V256h0a81.65,81.65,0,0,0,51.74-22.37c.8-.77,1.5-1.56,2.26-2.34a82.08,82.08,0,0,0,7.93-9.38A63.76,63.76,0,0,0,333,211.15a48.56,48.56,0,0,0,4.32-16.44c.09-1.24.2-2.48.19-3.71v-2.2c-.74,1.08-1.46,2.16-2.27,3.22A95.68,95.68,0,0,1,327,201.39Z"/></svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1 @@
<svg id="Assets" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 982.38 1247.39"><defs><style>.cls-1{fill:url(#linear-gradient);}</style><linearGradient id="linear-gradient" x1="392.22" y1="1235.97" x2="609.18" y2="5.5" gradientUnits="userSpaceOnUse"><stop offset="0.07" stop-color="#36baa2"/><stop offset="0.34" stop-color="#61ddc8"/><stop offset="0.53" stop-color="#7df3e1"/></linearGradient></defs><title>07 Large Icon_Gradient</title><path class="cls-1" d="M702.28,857.87c-57,54.61-134.4,84.68-218,84.68-64.94,0-129.45-18.31-186.61-53L0,708.94v71.84L316.14,972.65c53.11,32.19,111.56,47.78,168.15,47.78,72.55,0,142.06-25.62,193.35-74.77,37.49-35.93,58.2-79,57.29-120.8v-5.2A253.66,253.66,0,0,1,702.28,857.87Zm0-113.47c-57,54.6-134.4,84.68-218,84.68-64.94,0-129.45-18.32-186.61-53L0,595.47V667.3L316.14,859.16C369.25,891.36,427.7,907,484.29,907c72.55,0,142.06-25.62,193.35-74.77,37.49-35.93,58.2-79,57.29-120.79v-5.31A252.11,252.11,0,0,1,702.28,744.4Zm0,227c-57,54.6-134.4,84.68-218,84.68-64.94,0-129.45-18.32-186.61-53L0,822.42v71.84l316.14,191.86c53.11,32.2,111.56,47.79,168.15,47.79,72.55,0,142.06-25.63,193.35-74.78,37.49-35.92,58.2-79,57.29-120.78v-5.22A253.79,253.79,0,0,1,702.28,971.36Zm0,113.48c-57,54.6-134.4,84.68-218,84.68-64.94,0-129.45-18.32-186.61-53L0,935.9v71.84L316.14,1199.6c53.11,32.2,111.56,47.79,168.15,47.79,72.55,0,142.06-25.62,193.35-74.78,37.49-35.92,58.2-79,57.29-120.78v-5.21A253.73,253.73,0,0,1,702.28,1084.84ZM951.79,586.27c-2,2-3.79,4.1-5.88,6.09A283.08,283.08,0,0,1,881.13,638a278.45,278.45,0,0,1-110.44,29.63h-.15l0,77.63h.11C825,741.78,878.61,721,921.28,680.14c2.33-2.24,4.38-4.54,6.59-6.8A239.4,239.4,0,0,0,951,646a185,185,0,0,0,18.29-31.37,141.27,141.27,0,0,0,12.57-47.87c.27-3.6.6-7.2.56-10.79v-6.38c-2.17,3.12-4.26,6.26-6.61,9.36A284.07,284.07,0,0,1,951.79,586.27Zm.1-113.36c-2,2-3.92,4-6,6a288.89,288.89,0,0,1-64.72,45.66,276.05,276.05,0,0,1-76.4,25.37,287,287,0,0,1-41.23,4.55c1.48,4.7,2.63,9.47,3.64,14.27a155,155,0,0,1,3.32,34.94v28.1h.16a245.44,245.44,0,0,0,73.78-16.15,235.88,235.88,0,0,0,76.81-49c2.33-2.24,4.38-4.53,6.59-6.8A237.77,237.77,0,0,0,951,532.55a184.13,184.13,0,0,0,18.29-31.36,141.22,141.22,0,0,0,12.57-47.86c.27-3.6.6-7.2.56-10.8v-6c-2.09,3-4.29,6-6.56,9A285.06,285.06,0,0,1,951.89,472.91Zm-.1,226.84c-2,2-3.8,4.09-5.88,6.09C898.8,751,837.08,777.38,770.69,781.11h-.09l0,77.63h.06c54.26-3.48,107.92-24.23,150.59-65.12,21.84-20.92,37.77-43,48-65.47a141.28,141.28,0,0,0,12.57-47.86c.27-3.61.6-7.21.56-10.8V663.1c-2.17,3.13-4.26,6.27-6.61,9.36A283,283,0,0,1,951.79,699.75ZM770.69,894.59h0l0,77.63C825,968.74,878.61,948,921.28,907.1c40.67-39,61.55-81.83,61.09-124.13v-6.4a276.79,276.79,0,0,1-36.46,42.76C898.79,864.47,837.08,890.86,770.69,894.59ZM878.3,182.25,578,0,227.93,335.43,0,553.82,316.14,745.68c53.11,32.2,111.56,47.79,168.15,47.79,72.55,0,142.06-25.62,193.35-74.77,55.78-53.45,74.29-122.64,39.87-180.11a142.23,142.23,0,0,0-19.79-25.82A260,260,0,0,0,753.87,519h.43a247.6,247.6,0,0,0,90.17-16.86,235.71,235.71,0,0,0,76.81-49c2.32-2.21,4.52-4.45,6.7-6.69a238.54,238.54,0,0,0,23.08-27.3,184.08,184.08,0,0,0,18.25-31.34A141.52,141.52,0,0,0,982,340.11C986.32,283.66,953,227.53,878.3,182.25Zm-289,475.23C533.09,711.36,442.61,719.3,369,674.66L150.25,541.89,346.58,353.71,569,488.74C649.19,537.34,644.77,604.36,589.33,657.48Zm240.5-261.25C776,447.8,689,462.79,615.33,418.14L409.67,293.29l187.5-179.81L799.11,236.07C874.62,281.85,884.43,343.9,829.83,396.23Z"/></svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

@@ -0,0 +1 @@
<svg id="Assets" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 337.46 428.5"><defs><style>.cls-1{fill:#7df3e1;}</style></defs><title>04 Icon_Teal</title><path class="cls-1" d="M303,166.05a80.69,80.69,0,0,0,13.45-10.37c.79-.77,1.55-1.53,2.3-2.3a83.12,83.12,0,0,0,7.93-9.38A63.69,63.69,0,0,0,333,133.23a48.58,48.58,0,0,0,4.35-16.4c1.49-19.39-10-38.67-35.62-54.22L198.56,0,78.3,115.23,0,190.25l108.6,65.91a111.59,111.59,0,0,0,57.76,16.41c24.92,0,48.8-8.8,66.42-25.69,19.16-18.36,25.52-42.12,13.7-61.87a49.22,49.22,0,0,0-6.8-8.87A89.17,89.17,0,0,0,259,178.29h.15a85.08,85.08,0,0,0,31-5.79A80.88,80.88,0,0,0,303,166.05ZM202.45,225.86c-19.32,18.51-50.4,21.23-75.7,5.9L51.61,186.15l67.45-64.64,76.41,46.38C223,184.58,221.49,207.61,202.45,225.86Zm8.93-82.22-70.65-42.89L205.14,39,274.51,81.1c25.94,15.72,29.31,37,10.55,55A60.69,60.69,0,0,1,211.38,143.64Zm29.86,190c-19.57,18.75-46.17,29.09-74.88,29.09a123.73,123.73,0,0,1-64.1-18.2L0,282.52v24.67L108.6,373.1a111.6,111.6,0,0,0,57.76,16.42c24.92,0,48.8-8.81,66.42-25.69,12.88-12.34,20-27.13,19.68-41.49v-1.79A87.27,87.27,0,0,1,241.24,333.68Zm0-39c-19.57,18.75-46.17,29.08-74.88,29.08a123.81,123.81,0,0,1-64.1-18.19L0,243.53v24.68l108.6,65.91a111.6,111.6,0,0,0,57.76,16.42c24.92,0,48.8-8.81,66.42-25.69,12.88-12.34,20-27.13,19.68-41.5v-1.78A87.27,87.27,0,0,1,241.24,294.7Zm0-39c-19.57,18.76-46.17,29.09-74.88,29.09a123.81,123.81,0,0,1-64.1-18.19L0,204.55v24.68l108.6,65.91a111.59,111.59,0,0,0,57.76,16.41c24.92,0,48.8-8.8,66.42-25.68,12.88-12.35,20-27.13,19.68-41.5v-1.82A86.09,86.09,0,0,1,241.24,255.71Zm83.7,25.74a94.15,94.15,0,0,1-60.2,25.86h0V334a81.6,81.6,0,0,0,51.74-22.37c14-13.38,21.14-28.11,21-42.64v-2.19A94.92,94.92,0,0,1,324.94,281.45Zm-83.7,91.21c-19.57,18.76-46.17,29.09-74.88,29.09a123.73,123.73,0,0,1-64.1-18.2L0,321.5v24.68l108.6,65.9a111.6,111.6,0,0,0,57.76,16.42c24.92,0,48.8-8.8,66.42-25.69,12.88-12.34,20-27.13,19.68-41.49v-1.79A86.29,86.29,0,0,1,241.24,372.66ZM327,162.45c-.68.69-1.35,1.38-2.05,2.06a94.37,94.37,0,0,1-10.64,8.65,91.35,91.35,0,0,1-11.6,7,94.53,94.53,0,0,1-26.24,8.71,97.69,97.69,0,0,1-14.16,1.57c.5,1.61.9,3.25,1.25,4.9a53.27,53.27,0,0,1,1.14,12V217h.05a84.41,84.41,0,0,0,25.35-5.55,81,81,0,0,0,26.39-16.82c.8-.77,1.5-1.56,2.26-2.34a82.08,82.08,0,0,0,7.93-9.38A63.76,63.76,0,0,0,333,172.17a48.55,48.55,0,0,0,4.32-16.45c.09-1.23.2-2.47.19-3.7V150q-1.08,1.54-2.25,3.09A96.73,96.73,0,0,1,327,162.45Zm0,77.92c-.69.7-1.31,1.41-2,2.1a94.2,94.2,0,0,1-60.2,25.86h0l0,26.67h0a81.6,81.6,0,0,0,51.74-22.37A73.51,73.51,0,0,0,333,250.13a48.56,48.56,0,0,0,4.32-16.44c.09-1.24.2-2.47.19-3.71v-2.19c-.74,1.07-1.46,2.15-2.27,3.21A95.68,95.68,0,0,1,327,240.37Zm0-39c-.69.7-1.31,1.41-2,2.1a93.18,93.18,0,0,1-10.63,8.65,91.63,91.63,0,0,1-11.63,7,95.47,95.47,0,0,1-37.94,10.18h0V256h0a81.65,81.65,0,0,0,51.74-22.37c.8-.77,1.5-1.56,2.26-2.34a82.08,82.08,0,0,0,7.93-9.38A63.76,63.76,0,0,0,333,211.15a48.56,48.56,0,0,0,4.32-16.44c.09-1.24.2-2.48.19-3.71v-2.2c-.74,1.08-1.46,2.16-2.27,3.22A95.68,95.68,0,0,1,327,201.39Z"/></svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1 @@
<svg id="Assets" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 337.46 428.5"><defs><style>.cls-1{fill:#fff;}</style></defs><title>02 Icon_White</title><path class="cls-1" d="M303,166.05a80.69,80.69,0,0,0,13.45-10.37c.79-.77,1.55-1.53,2.3-2.3a83.12,83.12,0,0,0,7.93-9.38A63.69,63.69,0,0,0,333,133.23a48.58,48.58,0,0,0,4.35-16.4c1.49-19.39-10-38.67-35.62-54.22L198.56,0,78.3,115.23,0,190.25l108.6,65.91a111.59,111.59,0,0,0,57.76,16.41c24.92,0,48.8-8.8,66.42-25.69,19.16-18.36,25.52-42.12,13.7-61.87a49.22,49.22,0,0,0-6.8-8.87A89.17,89.17,0,0,0,259,178.29h.15a85.08,85.08,0,0,0,31-5.79A80.88,80.88,0,0,0,303,166.05ZM202.45,225.86c-19.32,18.51-50.4,21.23-75.7,5.9L51.61,186.15l67.45-64.64,76.41,46.38C223,184.58,221.49,207.61,202.45,225.86Zm8.93-82.22-70.65-42.89L205.14,39,274.51,81.1c25.94,15.72,29.31,37,10.55,55A60.69,60.69,0,0,1,211.38,143.64Zm29.86,190c-19.57,18.75-46.17,29.09-74.88,29.09a123.73,123.73,0,0,1-64.1-18.2L0,282.52v24.67L108.6,373.1a111.6,111.6,0,0,0,57.76,16.42c24.92,0,48.8-8.81,66.42-25.69,12.88-12.34,20-27.13,19.68-41.49v-1.79A87.27,87.27,0,0,1,241.24,333.68Zm0-39c-19.57,18.75-46.17,29.08-74.88,29.08a123.81,123.81,0,0,1-64.1-18.19L0,243.53v24.68l108.6,65.91a111.6,111.6,0,0,0,57.76,16.42c24.92,0,48.8-8.81,66.42-25.69,12.88-12.34,20-27.13,19.68-41.5v-1.78A87.27,87.27,0,0,1,241.24,294.7Zm0-39c-19.57,18.76-46.17,29.09-74.88,29.09a123.81,123.81,0,0,1-64.1-18.19L0,204.55v24.68l108.6,65.91a111.59,111.59,0,0,0,57.76,16.41c24.92,0,48.8-8.8,66.42-25.68,12.88-12.35,20-27.13,19.68-41.5v-1.82A86.09,86.09,0,0,1,241.24,255.71Zm83.7,25.74a94.15,94.15,0,0,1-60.2,25.86h0V334a81.6,81.6,0,0,0,51.74-22.37c14-13.38,21.14-28.11,21-42.64v-2.19A94.92,94.92,0,0,1,324.94,281.45Zm-83.7,91.21c-19.57,18.76-46.17,29.09-74.88,29.09a123.73,123.73,0,0,1-64.1-18.2L0,321.5v24.68l108.6,65.9a111.6,111.6,0,0,0,57.76,16.42c24.92,0,48.8-8.8,66.42-25.69,12.88-12.34,20-27.13,19.68-41.49v-1.79A86.29,86.29,0,0,1,241.24,372.66ZM327,162.45c-.68.69-1.35,1.38-2.05,2.06a94.37,94.37,0,0,1-10.64,8.65,91.35,91.35,0,0,1-11.6,7,94.53,94.53,0,0,1-26.24,8.71,97.69,97.69,0,0,1-14.16,1.57c.5,1.61.9,3.25,1.25,4.9a53.27,53.27,0,0,1,1.14,12V217h.05a84.41,84.41,0,0,0,25.35-5.55,81,81,0,0,0,26.39-16.82c.8-.77,1.5-1.56,2.26-2.34a82.08,82.08,0,0,0,7.93-9.38A63.76,63.76,0,0,0,333,172.17a48.55,48.55,0,0,0,4.32-16.45c.09-1.23.2-2.47.19-3.7V150q-1.08,1.54-2.25,3.09A96.73,96.73,0,0,1,327,162.45Zm0,77.92c-.69.7-1.31,1.41-2,2.1a94.2,94.2,0,0,1-60.2,25.86h0l0,26.67h0a81.6,81.6,0,0,0,51.74-22.37A73.51,73.51,0,0,0,333,250.13a48.56,48.56,0,0,0,4.32-16.44c.09-1.24.2-2.47.19-3.71v-2.19c-.74,1.07-1.46,2.15-2.27,3.21A95.68,95.68,0,0,1,327,240.37Zm0-39c-.69.7-1.31,1.41-2,2.1a93.18,93.18,0,0,1-10.63,8.65,91.63,91.63,0,0,1-11.63,7,95.47,95.47,0,0,1-37.94,10.18h0V256h0a81.65,81.65,0,0,0,51.74-22.37c.8-.77,1.5-1.56,2.26-2.34a82.08,82.08,0,0,0,7.93-9.38A63.76,63.76,0,0,0,333,211.15a48.56,48.56,0,0,0,4.32-16.44c.09-1.24.2-2.48.19-3.71v-2.2c-.74,1.08-1.46,2.16-2.27,3.22A95.68,95.68,0,0,1,327,201.39Z"/></svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -10,6 +10,7 @@ nav:
- Vision: 'overview/vision.md'
- The Spotify story: 'overview/background.md'
- Strategies for adopting: 'overview/adopting.md'
- Logo assets: 'overview/logos.md'
- Getting started:
- Running Backstage locally: 'getting-started/index.md'
- Installation: 'getting-started/installation.md'
+2
View File
@@ -31,6 +31,7 @@ import { Router as CatalogRouter } from '@backstage/plugin-catalog';
import { Router as DocsRouter } from '@backstage/plugin-techdocs';
import { Router as GraphiQLRouter } from '@backstage/plugin-graphiql';
import { Router as TechRadarRouter } from '@backstage/plugin-tech-radar';
import { Router as LighthouseRouter } from '@backstage/plugin-lighthouse';
import { Router as RegisterComponentRouter } from '@backstage/plugin-register-component';
import { Route, Routes, Navigate } from 'react-router';
@@ -75,6 +76,7 @@ const AppRoutes = () => (
element={<TechRadarRouter width={1500} height={800} />}
/>
<Route path="/graphiql" element={<GraphiQLRouter />} />
<Route path="/lighthouse/*" element={<LighthouseRouter />} />
<Route
path="/register-component"
element={<RegisterComponentRouter catalogRouteRef={catalogRouteRef} />}
+1 -1
View File
@@ -41,7 +41,7 @@
"@spotify/eslint-config": "^7.0.1",
"@sucrase/webpack-loader": "^2.0.0",
"@svgr/plugin-jsx": "5.4.x",
"@svgr/plugin-svgo": "4.3.x",
"@svgr/plugin-svgo": "5.4.x",
"@svgr/rollup": "5.4.x",
"@svgr/webpack": "5.4.x",
"@types/start-server-webpack-plugin": "^2.2.0",
@@ -299,7 +299,11 @@ export const microsoftAuthApiRef = createApiRef<
* Provides authentication for custom identity providers.
*/
export const oauth2ApiRef = createApiRef<
OAuthApi & OpenIdConnectApi & ProfileInfoApi & SessionStateApi
OAuthApi &
OpenIdConnectApi &
ProfileInfoApi &
SessionStateApi &
BackstageIdentityApi
>({
id: 'core.auth.oauth2',
description: 'Example of how to use oauth2 custom provider',
@@ -15,26 +15,13 @@
*/
import Auth0Icon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { Auth0Session } from './types';
import {
OpenIdConnectApi,
ProfileInfoApi,
ProfileInfo,
SessionStateApi,
SessionState,
BackstageIdentityApi,
AuthRequestOptions,
BackstageIdentity,
} from '../../../definitions/auth';
import { auth0AuthApiRef } from '../../../definitions/auth';
import {
OAuthRequestApi,
AuthProvider,
DiscoveryApi,
} from '../../../definitions';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager';
import { Observable } from '../../../../types';
import { OAuth2 } from '../oauth2';
type CreateOptions = {
discoveryApi: DiscoveryApi;
@@ -44,106 +31,27 @@ type CreateOptions = {
provider?: AuthProvider & { id: string };
};
export type Auth0AuthResponse = {
providerInfo: {
accessToken: string;
idToken: string;
scope: string;
expiresInSeconds: number;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
id: 'auth0',
title: 'Auth0',
icon: Auth0Icon,
};
class Auth0Auth
implements
OpenIdConnectApi,
ProfileInfoApi,
BackstageIdentityApi,
SessionStateApi {
class Auth0Auth {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
}: CreateOptions) {
const connector = new DefaultAuthConnector({
}: CreateOptions): typeof auth0AuthApiRef.T {
return OAuth2.create({
discoveryApi,
environment,
oauthRequestApi,
provider,
oauthRequestApi: oauthRequestApi,
sessionTransform(res: Auth0AuthResponse): Auth0Session {
return {
...res,
providerInfo: {
idToken: res.providerInfo.idToken,
accessToken: res.providerInfo.accessToken,
scopes: Auth0Auth.normalizeScopes(res.providerInfo.scope),
expiresAt: new Date(
Date.now() + res.providerInfo.expiresInSeconds * 1000,
),
},
};
},
environment,
defaultScopes: ['openid', `email`, `profile`],
});
const sessionManager = new RefreshingAuthSessionManager({
connector,
defaultScopes: new Set(['openid', `email`, `profile`]),
sessionScopes: (session: Auth0Session) => session.providerInfo.scopes,
sessionShouldRefresh: (session: Auth0Session) => {
const expiresInSec =
(session.providerInfo.expiresAt.getTime() - Date.now()) / 1000;
return expiresInSec < 60 * 5;
},
});
return new Auth0Auth(sessionManager);
}
sessionState$(): Observable<SessionState> {
return this.sessionManager.sessionState$();
}
constructor(private readonly sessionManager: SessionManager<Auth0Session>) {}
async getIdToken(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.providerInfo.idToken ?? '';
}
async logout() {
await this.sessionManager.removeSession();
}
async getBackstageIdentity(
options: AuthRequestOptions = {},
): Promise<BackstageIdentity | undefined> {
const session = await this.sessionManager.getSession(options);
return session?.backstageIdentity;
}
async getProfile(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.profile;
}
static normalizeScopes(scope?: string | string[]): Set<string> {
if (!scope) {
return new Set();
}
const scopeList = Array.isArray(scope)
? scope
: scope.split(/[\s|,]/).filter(Boolean);
return new Set(scopeList);
}
}
export default Auth0Auth;
@@ -14,5 +14,4 @@
* limitations under the License.
*/
export * from './types';
export { default as Auth0Auth } from './Auth0Auth';
@@ -1,28 +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.
*/
import { ProfileInfo, BackstageIdentity } from '../../../definitions';
export type Auth0Session = {
providerInfo: {
idToken: string;
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
@@ -14,33 +14,37 @@
* limitations under the License.
*/
import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi';
import { UrlPatternDiscovery } from '../../DiscoveryApi';
import GitlabAuth from './GitlabAuth';
describe('GitlabAuth', () => {
it('should get access token', async () => {
const getSession = jest
.fn()
.mockResolvedValue({ providerInfo: { accessToken: 'access-token' } });
const gitlabAuth = new GitlabAuth({ getSession } as any);
const getSession = jest.fn();
expect(await gitlabAuth.getAccessToken()).toBe('access-token');
expect(getSession).toBeCalledTimes(1);
jest.mock('../../../../lib/AuthSessionManager', () => ({
...(jest.requireActual('../../../../lib/AuthSessionManager') as any),
RefreshingAuthSessionManager: class {
getSession = getSession;
},
}));
describe('GitlabAuth', () => {
afterEach(() => {
jest.resetAllMocks();
});
it('should normalize scope', () => {
const tests = [
{
arguments: ['read_user api write_repository'],
expect: new Set(['read_user', 'api', 'write_repository']),
},
{
arguments: ['read_repository sudo'],
expect: new Set(['read_repository', 'sudo']),
},
];
it.each([
[
'read_user api write_repository',
['read_user', 'api', 'write_repository'],
],
['read_repository sudo', ['read_repository', 'sudo']],
])(`should normalize scopes correctly - %p`, (scope, scopes) => {
const googleAuth = GitlabAuth.create({
oauthRequestApi: new MockOAuthApi(),
discoveryApi: UrlPatternDiscovery.compile('http://example.com'),
});
for (const test of tests) {
expect(GitlabAuth.normalizeScope(...test.arguments)).toEqual(test.expect);
}
googleAuth.getAccessToken(scope);
expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) });
});
});
@@ -15,24 +15,13 @@
*/
import GitlabIcon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { GitlabSession } from './types';
import {
OAuthApi,
SessionStateApi,
SessionState,
ProfileInfo,
BackstageIdentity,
AuthRequestOptions,
} from '../../../definitions/auth';
import { gitlabAuthApiRef } from '../../../definitions/auth';
import {
OAuthRequestApi,
AuthProvider,
DiscoveryApi,
} from '../../../definitions';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { StaticAuthSessionManager } from '../../../../lib/AuthSessionManager';
import { Observable } from '../../../../types';
import { OAuth2 } from '../oauth2';
type CreateOptions = {
discoveryApi: DiscoveryApi;
@@ -42,94 +31,26 @@ type CreateOptions = {
provider?: AuthProvider & { id: string };
};
export type GitlabAuthResponse = {
providerInfo: {
accessToken: string;
scope: string;
expiresInSeconds: number;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
id: 'gitlab',
title: 'Gitlab',
icon: GitlabIcon,
};
class GitlabAuth implements OAuthApi, SessionStateApi {
class GitlabAuth {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
}: CreateOptions) {
const connector = new DefaultAuthConnector({
}: CreateOptions): typeof gitlabAuthApiRef.T {
return OAuth2.create({
discoveryApi,
environment,
provider,
oauthRequestApi,
sessionTransform(res: GitlabAuthResponse): GitlabSession {
return {
...res,
providerInfo: {
accessToken: res.providerInfo.accessToken,
scopes: GitlabAuth.normalizeScope(res.providerInfo.scope),
expiresAt: new Date(
Date.now() + res.providerInfo.expiresInSeconds * 1000,
),
},
};
},
provider,
environment,
defaultScopes: ['read_user'],
});
const sessionManager = new StaticAuthSessionManager({
connector,
defaultScopes: new Set(['read_user']),
sessionScopes: (session: GitlabSession) => session.providerInfo.scopes,
});
return new GitlabAuth(sessionManager);
}
sessionState$(): Observable<SessionState> {
return this.sessionManager.sessionState$();
}
constructor(private readonly sessionManager: SessionManager<GitlabSession>) {}
async getAccessToken(scope?: string, options?: AuthRequestOptions) {
const session = await this.sessionManager.getSession({
...options,
scopes: GitlabAuth.normalizeScope(scope),
});
return session?.providerInfo.accessToken ?? '';
}
async getBackstageIdentity(
options: AuthRequestOptions = {},
): Promise<BackstageIdentity | undefined> {
const session = await this.sessionManager.getSession(options);
return session?.backstageIdentity;
}
async getProfile(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.profile;
}
async logout() {
await this.sessionManager.removeSession();
}
static normalizeScope(scope?: string): Set<string> {
if (!scope) {
return new Set();
}
const scopeList = Array.isArray(scope) ? scope : scope.split(' ');
return new Set(scopeList);
}
}
export default GitlabAuth;
@@ -14,5 +14,4 @@
* limitations under the License.
*/
export * from './types';
export { default as GitlabAuth } from './GitlabAuth';
@@ -1,27 +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.
*/
import { ProfileInfo, BackstageIdentity } from '../../../definitions';
export type GitlabSession = {
providerInfo: {
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
@@ -15,101 +15,23 @@
*/
import GoogleAuth from './GoogleAuth';
const theFuture = new Date(Date.now() + 3600000);
const thePast = new Date(Date.now() - 10);
import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi';
import { UrlPatternDiscovery } from '../../DiscoveryApi';
const PREFIX = 'https://www.googleapis.com/auth/';
const getSession = jest.fn();
jest.mock('../../../../lib/AuthSessionManager', () => ({
...(jest.requireActual('../../../../lib/AuthSessionManager') as any),
RefreshingAuthSessionManager: class {
getSession = getSession;
},
}));
describe('GoogleAuth', () => {
it('should get refreshed access token', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { accessToken: 'access-token', expiresAt: theFuture },
});
const googleAuth = new GoogleAuth({ getSession } as any);
expect(await googleAuth.getAccessToken()).toBe('access-token');
expect(getSession).toBeCalledTimes(1);
});
it('should get refreshed id token', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
});
const googleAuth = new GoogleAuth({ getSession } as any);
expect(await googleAuth.getIdToken()).toBe('id-token');
expect(getSession).toBeCalledTimes(1);
});
it('should get optional id token', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
});
const googleAuth = new GoogleAuth({ getSession } as any);
expect(await googleAuth.getIdToken({ optional: true })).toBe('id-token');
expect(getSession).toBeCalledTimes(1);
});
it('should share popup closed errors', async () => {
const error = new Error('NOPE');
error.name = 'RejectedError';
const getSession = jest
.fn()
.mockResolvedValueOnce({
providerInfo: {
accessToken: 'access-token',
expiresAt: theFuture,
scopes: new Set([`${PREFIX}not-enough`]),
},
})
.mockRejectedValue(error);
const googleAuth = new GoogleAuth({ getSession } as any);
// Make sure we have a session before we do the double request, so that we get past the !this.currentSession check
await expect(googleAuth.getAccessToken()).resolves.toBe('access-token');
const promise1 = googleAuth.getAccessToken('more');
const promise2 = googleAuth.getAccessToken('more');
await expect(promise1).rejects.toBe(error);
await expect(promise2).rejects.toBe(error);
expect(getSession).toBeCalledTimes(3);
});
it('should wait for all session refreshes', async () => {
const initialSession = {
providerInfo: {
idToken: 'token1',
expiresAt: theFuture,
scopes: new Set(),
},
};
const getSession = jest
.fn()
.mockResolvedValueOnce(initialSession)
.mockResolvedValue({
providerInfo: {
idToken: 'token2',
expiresAt: theFuture,
scopes: new Set(),
},
});
const googleAuth = new GoogleAuth({ getSession } as any);
// Grab the expired session first
await expect(googleAuth.getIdToken()).resolves.toBe('token1');
expect(getSession).toBeCalledTimes(1);
initialSession.providerInfo.expiresAt = thePast;
const promise1 = googleAuth.getIdToken();
const promise2 = googleAuth.getIdToken();
const promise3 = googleAuth.getIdToken();
await expect(promise1).resolves.toBe('token2');
await expect(promise2).resolves.toBe('token2');
await expect(promise3).resolves.toBe('token2');
expect(getSession).toBeCalledTimes(4); // De-duping of session requests happens in client
afterEach(() => {
jest.resetAllMocks();
});
it.each([
@@ -136,6 +58,12 @@ describe('GoogleAuth', () => {
[`${PREFIX}profile`, [`${PREFIX}profile`]],
[`${PREFIX}openid`, [`${PREFIX}openid`]],
])(`should normalize scopes correctly - %p`, (scope, scopes) => {
expect(GoogleAuth.normalizeScopes(scope)).toEqual(new Set(scopes));
const googleAuth = GoogleAuth.create({
oauthRequestApi: new MockOAuthApi(),
discoveryApi: UrlPatternDiscovery.compile('http://example.com'),
});
googleAuth.getAccessToken(scope);
expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) });
});
});
@@ -15,27 +15,13 @@
*/
import GoogleIcon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { GoogleSession } from './types';
import {
OAuthApi,
OpenIdConnectApi,
ProfileInfoApi,
ProfileInfo,
SessionStateApi,
SessionState,
BackstageIdentityApi,
AuthRequestOptions,
BackstageIdentity,
} from '../../../definitions/auth';
import { googleAuthApiRef } from '../../../definitions/auth';
import {
OAuthRequestApi,
AuthProvider,
DiscoveryApi,
} from '../../../definitions';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager';
import { Observable } from '../../../../types';
import { OAuth2 } from '../oauth2';
type CreateOptions = {
discoveryApi: DiscoveryApi;
@@ -45,140 +31,49 @@ type CreateOptions = {
provider?: AuthProvider & { id: string };
};
export type GoogleAuthResponse = {
providerInfo: {
accessToken: string;
idToken: string;
scope: string;
expiresInSeconds: number;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
id: 'google',
title: 'Google',
icon: GoogleIcon,
};
const SCOPE_PREFIX = 'https://www.googleapis.com/auth/';
class GoogleAuth
implements
OAuthApi,
OpenIdConnectApi,
ProfileInfoApi,
BackstageIdentityApi,
SessionStateApi {
class GoogleAuth {
static create({
discoveryApi,
oauthRequestApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
}: CreateOptions) {
const connector = new DefaultAuthConnector({
discoveryApi,
environment,
provider,
oauthRequestApi: oauthRequestApi,
sessionTransform(res: GoogleAuthResponse): GoogleSession {
return {
...res,
providerInfo: {
idToken: res.providerInfo.idToken,
accessToken: res.providerInfo.accessToken,
scopes: GoogleAuth.normalizeScopes(res.providerInfo.scope),
expiresAt: new Date(
Date.now() + res.providerInfo.expiresInSeconds * 1000,
),
},
};
},
});
}: CreateOptions): typeof googleAuthApiRef.T {
const SCOPE_PREFIX = 'https://www.googleapis.com/auth/';
const sessionManager = new RefreshingAuthSessionManager({
connector,
defaultScopes: new Set([
return OAuth2.create({
discoveryApi,
oauthRequestApi,
provider,
environment,
defaultScopes: [
'openid',
`${SCOPE_PREFIX}userinfo.email`,
`${SCOPE_PREFIX}userinfo.profile`,
]),
sessionScopes: (session: GoogleSession) => session.providerInfo.scopes,
sessionShouldRefresh: (session: GoogleSession) => {
const expiresInSec =
(session.providerInfo.expiresAt.getTime() - Date.now()) / 1000;
return expiresInSec < 60 * 5;
],
scopeTransform(scopes: string[]) {
return scopes.map(scope => {
if (scope === 'openid') {
return scope;
}
if (scope === 'profile' || scope === 'email') {
return `${SCOPE_PREFIX}userinfo.${scope}`;
}
if (scope.startsWith(SCOPE_PREFIX)) {
return scope;
}
return `${SCOPE_PREFIX}${scope}`;
});
},
});
return new GoogleAuth(sessionManager);
}
sessionState$(): Observable<SessionState> {
return this.sessionManager.sessionState$();
}
constructor(private readonly sessionManager: SessionManager<GoogleSession>) {}
async getAccessToken(
scope?: string | string[],
options?: AuthRequestOptions,
) {
const session = await this.sessionManager.getSession({
...options,
scopes: GoogleAuth.normalizeScopes(scope),
});
return session?.providerInfo.accessToken ?? '';
}
async getIdToken(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.providerInfo.idToken ?? '';
}
async logout() {
await this.sessionManager.removeSession();
}
async getBackstageIdentity(
options: AuthRequestOptions = {},
): Promise<BackstageIdentity | undefined> {
const session = await this.sessionManager.getSession(options);
return session?.backstageIdentity;
}
async getProfile(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.profile;
}
static normalizeScopes(scopes?: string | string[]): Set<string> {
if (!scopes) {
return new Set();
}
const scopeList = Array.isArray(scopes)
? scopes
: scopes.split(/[\s]/).filter(Boolean);
const normalizedScopes = scopeList.map(scope => {
if (scope === 'openid') {
return scope;
}
if (scope === 'profile' || scope === 'email') {
return `${SCOPE_PREFIX}userinfo.${scope}`;
}
if (scope.startsWith(SCOPE_PREFIX)) {
return scope;
}
return `${SCOPE_PREFIX}${scope}`;
});
return new Set(normalizedScopes);
}
}
export default GoogleAuth;
@@ -14,5 +14,4 @@
* limitations under the License.
*/
export * from './types';
export { default as GoogleAuth } from './GoogleAuth';
@@ -1,28 +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.
*/
import { ProfileInfo, BackstageIdentity } from '../../../definitions';
export type GoogleSession = {
providerInfo: {
idToken: string;
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
@@ -15,29 +15,14 @@
*/
import MicrosoftIcon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { MicrosoftSession } from './types';
import {
OAuthApi,
OpenIdConnectApi,
ProfileInfoApi,
ProfileInfo,
SessionStateApi,
SessionState,
BackstageIdentityApi,
AuthRequestOptions,
BackstageIdentity,
} from '../../../definitions/auth';
import { microsoftAuthApiRef } from '../../../definitions/auth';
import {
OAuthRequestApi,
AuthProvider,
DiscoveryApi,
} from '../../../definitions';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager';
import { Observable } from '../../../../types';
import { OAuth2 } from '../oauth2';
type CreateOptions = {
discoveryApi: DiscoveryApi;
@@ -47,126 +32,33 @@ type CreateOptions = {
provider?: AuthProvider & { id: string };
};
export type MicrosoftAuthResponse = {
providerInfo: {
accessToken: string;
idToken: string;
scope: string;
expiresInSeconds: number;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
id: 'microsoft',
title: 'Microsoft',
icon: MicrosoftIcon,
};
class MicrosoftAuth
implements
OAuthApi,
OpenIdConnectApi,
ProfileInfoApi,
BackstageIdentityApi,
SessionStateApi {
class MicrosoftAuth {
static create({
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
discoveryApi,
}: CreateOptions) {
const connector = new DefaultAuthConnector({
}: CreateOptions): typeof microsoftAuthApiRef.T {
return OAuth2.create({
discoveryApi,
environment,
oauthRequestApi,
provider,
oauthRequestApi: oauthRequestApi,
sessionTransform(res: MicrosoftAuthResponse): MicrosoftSession {
return {
...res,
providerInfo: {
idToken: res.providerInfo.idToken,
accessToken: res.providerInfo.accessToken,
scopes: MicrosoftAuth.normalizeScopes(res.providerInfo.scope),
expiresAt: new Date(
Date.now() + res.providerInfo.expiresInSeconds * 1000,
),
},
};
},
});
const sessionManager = new RefreshingAuthSessionManager({
connector,
defaultScopes: new Set([
environment,
defaultScopes: [
'openid',
'offline_access',
'profile',
'email',
'User.Read',
]),
sessionScopes: (session: MicrosoftSession) => session.providerInfo.scopes,
sessionShouldRefresh: (session: MicrosoftSession) => {
const expiresInSec =
(session.providerInfo.expiresAt.getTime() - Date.now()) / 1000;
return expiresInSec < 60 * 5;
},
],
});
return new MicrosoftAuth(sessionManager);
}
sessionState$(): Observable<SessionState> {
return this.sessionManager.sessionState$();
}
constructor(
private readonly sessionManager: SessionManager<MicrosoftSession>,
) {}
async getAccessToken(
scope?: string | string[],
options?: AuthRequestOptions,
) {
const session = await this.sessionManager.getSession({
...options,
scopes: MicrosoftAuth.normalizeScopes(scope),
});
return session?.providerInfo.accessToken ?? '';
}
async getIdToken(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.providerInfo.idToken ?? '';
}
async logout() {
await this.sessionManager.removeSession();
}
async getBackstageIdentity(
options: AuthRequestOptions = {},
): Promise<BackstageIdentity | undefined> {
const session = await this.sessionManager.getSession(options);
return session?.backstageIdentity;
}
async getProfile(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.profile;
}
static normalizeScopes(scopes?: string | string[]): Set<string> {
if (!scopes) {
return new Set();
}
const scopeList = Array.isArray(scopes)
? scopes
: scopes.split(/[\s|,]/).filter(Boolean);
return new Set(scopeList);
}
}
export default MicrosoftAuth;
@@ -14,5 +14,4 @@
* limitations under the License.
*/
export * from './types';
export { default as MicrosoftAuth } from './MicrosoftAuth';
@@ -1,28 +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.
*/
import { ProfileInfo, BackstageIdentity } from '../../../definitions';
export type MicrosoftSession = {
providerInfo: {
idToken: string;
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
@@ -0,0 +1,152 @@
/*
* 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 OAuth2 from './OAuth2';
const theFuture = new Date(Date.now() + 3600000);
const thePast = new Date(Date.now() - 10);
const PREFIX = 'https://www.googleapis.com/auth/';
const scopeTransform = (x: string[]) => x;
describe('OAuth2', () => {
it('should get refreshed access token', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { accessToken: 'access-token', expiresAt: theFuture },
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
});
expect(await oauth2.getAccessToken('my-scope my-scope2')).toBe(
'access-token',
);
expect(getSession).toBeCalledTimes(1);
expect(getSession.mock.calls[0][0].scopes).toEqual(
new Set(['my-scope', 'my-scope2']),
);
});
it('should transform scopes', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { accessToken: 'access-token', expiresAt: theFuture },
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform: scopes => scopes.map(scope => `my-prefix/${scope}`),
});
expect(await oauth2.getAccessToken('my-scope')).toBe('access-token');
expect(getSession).toBeCalledTimes(1);
expect(getSession.mock.calls[0][0].scopes).toEqual(
new Set(['my-prefix/my-scope']),
);
});
it('should get refreshed id token', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
});
expect(await oauth2.getIdToken()).toBe('id-token');
expect(getSession).toBeCalledTimes(1);
});
it('should get optional id token', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
});
expect(await oauth2.getIdToken({ optional: true })).toBe('id-token');
expect(getSession).toBeCalledTimes(1);
});
it('should share popup closed errors', async () => {
const error = new Error('NOPE');
error.name = 'RejectedError';
const getSession = jest
.fn()
.mockResolvedValueOnce({
providerInfo: {
accessToken: 'access-token',
expiresAt: theFuture,
scopes: new Set([`${PREFIX}not-enough`]),
},
})
.mockRejectedValue(error);
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
});
// Make sure we have a session before we do the double request, so that we get past the !this.currentSession check
await expect(oauth2.getAccessToken()).resolves.toBe('access-token');
const promise1 = oauth2.getAccessToken('more');
const promise2 = oauth2.getAccessToken('more');
await expect(promise1).rejects.toBe(error);
await expect(promise2).rejects.toBe(error);
expect(getSession).toBeCalledTimes(3);
});
it('should wait for all session refreshes', async () => {
const initialSession = {
providerInfo: {
idToken: 'token1',
expiresAt: theFuture,
scopes: new Set(),
},
};
const getSession = jest
.fn()
.mockResolvedValueOnce(initialSession)
.mockResolvedValue({
providerInfo: {
idToken: 'token2',
expiresAt: theFuture,
scopes: new Set(),
},
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
});
// Grab the expired session first
await expect(oauth2.getIdToken()).resolves.toBe('token1');
expect(getSession).toBeCalledTimes(1);
initialSession.providerInfo.expiresAt = thePast;
const promise1 = oauth2.getIdToken();
const promise2 = oauth2.getIdToken();
const promise3 = oauth2.getIdToken();
await expect(promise1).resolves.toBe('token2');
await expect(promise2).resolves.toBe('token2');
await expect(promise3).resolves.toBe('token2');
expect(getSession).toBeCalledTimes(4); // De-duping of session requests happens in client
});
});
@@ -33,15 +33,23 @@ import {
ProfileInfoApi,
SessionState,
SessionStateApi,
BackstageIdentityApi,
} from '../../../definitions/auth';
import { OAuth2Session } from './types';
type Options = {
sessionManager: SessionManager<OAuth2Session>;
scopeTransform: (scopes: string[]) => string[];
};
type CreateOptions = {
discoveryApi: DiscoveryApi;
oauthRequestApi: OAuthRequestApi;
environment?: string;
provider?: AuthProvider & { id: string };
defaultScopes?: string[];
scopeTransform?: (scopes: string[]) => string[];
};
export type OAuth2Response = {
@@ -61,15 +69,20 @@ const DEFAULT_PROVIDER = {
icon: OAuth2Icon,
};
const SCOPE_PREFIX = '';
class OAuth2
implements OAuthApi, OpenIdConnectApi, ProfileInfoApi, SessionStateApi {
implements
OAuthApi,
OpenIdConnectApi,
ProfileInfoApi,
BackstageIdentityApi,
SessionStateApi {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = [],
scopeTransform = x => x,
}: CreateOptions) {
const connector = new DefaultAuthConnector({
discoveryApi,
@@ -82,7 +95,10 @@ class OAuth2
providerInfo: {
idToken: res.providerInfo.idToken,
accessToken: res.providerInfo.accessToken,
scopes: OAuth2.normalizeScopes(res.providerInfo.scope),
scopes: OAuth2.normalizeScopes(
scopeTransform,
res.providerInfo.scope,
),
expiresAt: new Date(
Date.now() + res.providerInfo.expiresInSeconds * 1000,
),
@@ -93,11 +109,7 @@ class OAuth2
const sessionManager = new RefreshingAuthSessionManager({
connector,
defaultScopes: new Set([
'openid',
`${SCOPE_PREFIX}userinfo.email`,
`${SCOPE_PREFIX}userinfo.profile`,
]),
defaultScopes: new Set(defaultScopes),
sessionScopes: (session: OAuth2Session) => session.providerInfo.scopes,
sessionShouldRefresh: (session: OAuth2Session) => {
const expiresInSec =
@@ -106,20 +118,26 @@ class OAuth2
},
});
return new OAuth2(sessionManager);
return new OAuth2({ sessionManager, scopeTransform });
}
private readonly sessionManager: SessionManager<OAuth2Session>;
private readonly scopeTransform: (scopes: string[]) => string[];
constructor(options: Options) {
this.sessionManager = options.sessionManager;
this.scopeTransform = options.scopeTransform;
}
sessionState$(): Observable<SessionState> {
return this.sessionManager.sessionState$();
}
constructor(private readonly sessionManager: SessionManager<OAuth2Session>) {}
async getAccessToken(
scope?: string | string[],
options?: AuthRequestOptions,
) {
const normalizedScopes = OAuth2.normalizeScopes(scope);
const normalizedScopes = OAuth2.normalizeScopes(this.scopeTransform, scope);
const session = await this.sessionManager.getSession({
...options,
scopes: normalizedScopes,
@@ -148,16 +166,19 @@ class OAuth2
return session?.profile;
}
static normalizeScopes(scopes?: string | string[]): Set<string> {
private static normalizeScopes(
scopeTransform: (scopes: string[]) => string[],
scopes?: string | string[],
): Set<string> {
if (!scopes) {
return new Set();
}
const scopeList = Array.isArray(scopes)
? scopes
: scopes.split(/[\s]/).filter(Boolean);
: scopes.split(/[\s|,]/).filter(Boolean);
return new Set(scopeList);
return new Set(scopeTransform(scopeList));
}
}
@@ -13,102 +13,25 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import OktaAuth from './OktaAuth';
const theFuture = new Date(Date.now() + 3600000);
const thePast = new Date(Date.now() - 10);
import OktaAuth from './OktaAuth';
import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi';
import { UrlPatternDiscovery } from '../../DiscoveryApi';
const PREFIX = 'okta.';
const getSession = jest.fn();
jest.mock('../../../../lib/AuthSessionManager', () => ({
...(jest.requireActual('../../../../lib/AuthSessionManager') as any),
RefreshingAuthSessionManager: class {
getSession = getSession;
},
}));
describe('OktaAuth', () => {
it('should get refreshed access token', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { accessToken: 'access-token', expiresAt: theFuture },
});
const oktaAuth = new OktaAuth({ getSession } as any);
expect(await oktaAuth.getAccessToken()).toBe('access-token');
expect(getSession).toBeCalledTimes(1);
});
it('should get refreshed id token', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
});
const oktaAuth = new OktaAuth({ getSession } as any);
expect(await oktaAuth.getIdToken()).toBe('id-token');
expect(getSession).toBeCalledTimes(1);
});
it('should get optional id token', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
});
const oktaAuth = new OktaAuth({ getSession } as any);
expect(await oktaAuth.getIdToken({ optional: true })).toBe('id-token');
expect(getSession).toBeCalledTimes(1);
});
it('should share popup closed errors', async () => {
const error = new Error('NOPE');
error.name = 'RejectedError';
const getSession = jest
.fn()
.mockResolvedValueOnce({
providerInfo: {
accessToken: 'access-token',
expiresAt: theFuture,
scopes: new Set([`not-a-scope`]),
},
})
.mockRejectedValue(error);
const oktaAuth = new OktaAuth({ getSession } as any);
// Make sure we have a session before we do the double request, so that we get past the !this.currentSession check
await expect(oktaAuth.getAccessToken()).resolves.toBe('access-token');
const promise1 = oktaAuth.getAccessToken('more');
const promise2 = oktaAuth.getAccessToken('more');
await expect(promise1).rejects.toBe(error);
await expect(promise2).rejects.toBe(error);
expect(getSession).toBeCalledTimes(3);
});
it('should wait for all session refreshes', async () => {
const initialSession = {
providerInfo: {
idToken: 'token1',
expiresAt: theFuture,
scopes: new Set(),
},
};
const getSession = jest
.fn()
.mockResolvedValueOnce(initialSession)
.mockResolvedValue({
providerInfo: {
idToken: 'token2',
expiresAt: theFuture,
scopes: new Set(),
},
});
const oktaAuth = new OktaAuth({ getSession } as any);
// Grab the expired session first
await expect(oktaAuth.getIdToken()).resolves.toBe('token1');
expect(getSession).toBeCalledTimes(1);
initialSession.providerInfo.expiresAt = thePast;
const promise1 = oktaAuth.getIdToken();
const promise2 = oktaAuth.getIdToken();
const promise3 = oktaAuth.getIdToken();
await expect(promise1).resolves.toBe('token2');
await expect(promise2).resolves.toBe('token2');
await expect(promise3).resolves.toBe('token2');
expect(getSession).toBeCalledTimes(4); // De-duping of session requests happens in client
afterEach(() => {
jest.resetAllMocks();
});
it.each([
@@ -127,6 +50,12 @@ describe('OktaAuth', () => {
[`${PREFIX}profile`, [`${PREFIX}profile`]],
[`${PREFIX}openid`, [`${PREFIX}openid`]],
])(`should normalize scopes correctly - %p`, (scope, scopes) => {
expect(OktaAuth.normalizeScopes(scope)).toEqual(new Set(scopes));
const auth = OktaAuth.create({
oauthRequestApi: new MockOAuthApi(),
discoveryApi: UrlPatternDiscovery.compile('http://example.com'),
});
auth.getAccessToken(scope);
expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) });
});
});
@@ -15,27 +15,13 @@
*/
import OktaIcon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { OktaSession } from './types';
import {
OAuthApi,
OpenIdConnectApi,
ProfileInfoApi,
ProfileInfo,
SessionStateApi,
SessionState,
BackstageIdentityApi,
AuthRequestOptions,
BackstageIdentity,
} from '../../../definitions/auth';
import { oktaAuthApiRef } from '../../../definitions/auth';
import {
OAuthRequestApi,
AuthProvider,
DiscoveryApi,
} from '../../../definitions';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager';
import { Observable } from '../../../../types';
import { OAuth2 } from '../oauth2';
type CreateOptions = {
discoveryApi: DiscoveryApi;
@@ -45,17 +31,6 @@ type CreateOptions = {
provider?: AuthProvider & { id: string };
};
export type OktaAuthResponse = {
providerInfo: {
accessToken: string;
idToken: string;
scope: string;
expiresInSeconds: number;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
id: 'okta',
title: 'Okta',
@@ -74,110 +49,33 @@ const OKTA_OIDC_SCOPES: Set<String> = new Set([
const OKTA_SCOPE_PREFIX: string = 'okta.';
class OktaAuth
implements
OAuthApi,
OpenIdConnectApi,
ProfileInfoApi,
BackstageIdentityApi,
SessionStateApi {
class OktaAuth {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
}: CreateOptions) {
const connector = new DefaultAuthConnector({
}: CreateOptions): typeof oktaAuthApiRef.T {
return OAuth2.create({
discoveryApi,
environment,
oauthRequestApi,
provider,
oauthRequestApi: oauthRequestApi,
sessionTransform(res: OktaAuthResponse): OktaSession {
return {
...res,
providerInfo: {
idToken: res.providerInfo.idToken,
accessToken: res.providerInfo.accessToken,
scopes: OktaAuth.normalizeScopes(res.providerInfo.scope),
expiresAt: new Date(
Date.now() + res.providerInfo.expiresInSeconds * 1000,
),
},
};
environment,
defaultScopes: ['openid', 'email', 'profile', 'offline_access'],
scopeTransform(scopes) {
return scopes.map(scope => {
if (OKTA_OIDC_SCOPES.has(scope)) {
return scope;
}
if (scope.startsWith(OKTA_SCOPE_PREFIX)) {
return scope;
}
return `${OKTA_SCOPE_PREFIX}${scope}`;
});
},
});
const sessionManager = new RefreshingAuthSessionManager({
connector,
defaultScopes: new Set(['openid', 'email', 'profile', 'offline_access']),
sessionScopes: session => session.scopes,
sessionShouldRefresh: session => {
const expiresInSec =
(session.providerInfo.expiresAt.getTime() - Date.now()) / 1000;
return expiresInSec < 60 * 5;
},
});
return new OktaAuth(sessionManager);
}
sessionState$(): Observable<SessionState> {
return this.sessionManager.sessionState$();
}
constructor(private readonly sessionManager: SessionManager<OktaSession>) {}
async getAccessToken(scope?: string, options?: AuthRequestOptions) {
const session = await this.sessionManager.getSession({
...options,
scopes: OktaAuth.normalizeScopes(scope),
});
return session?.providerInfo.accessToken ?? '';
}
async getIdToken(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.providerInfo.idToken ?? '';
}
async logout() {
await this.sessionManager.removeSession();
}
async getBackstageIdentity(
options: AuthRequestOptions = {},
): Promise<BackstageIdentity | undefined> {
const session = await this.sessionManager.getSession(options);
return session?.backstageIdentity;
}
async getProfile(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.profile;
}
static normalizeScopes(scopes?: string | string[]): Set<string> {
if (!scopes) {
return new Set();
}
const scopeList = Array.isArray(scopes)
? scopes
: scopes.split(/[\s|,]/).filter(Boolean);
const normalizedScopes = scopeList.map(scope => {
if (OKTA_OIDC_SCOPES.has(scope)) {
return scope;
}
if (scope.startsWith(OKTA_SCOPE_PREFIX)) {
return scope;
}
return `${OKTA_SCOPE_PREFIX}${scope}`;
});
return new Set(normalizedScopes);
}
}
@@ -14,5 +14,4 @@
* limitations under the License.
*/
export * from './types';
export { default as OktaAuth } from './OktaAuth';
@@ -1,27 +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.
*/
import { ProfileInfo, BackstageIdentity } from '../../../definitions';
export type OktaSession = {
providerInfo: {
idToken: string;
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
+2 -1
View File
@@ -31,6 +31,7 @@ import {
AnyApiFactory,
} from '@backstage/core';
import SentimentDissatisfiedIcon from '@material-ui/icons/SentimentDissatisfied';
import { Routes } from 'react-router';
// TODO(rugvip): export proper plugin type from core that isn't the plugin class
type BackstagePlugin = ReturnType<typeof createPlugin>;
@@ -97,7 +98,7 @@ class DevAppBuilder {
<AppRouter>
<SidebarPage>
{sidebar}
{deprecatedAppRoutes}
<Routes>{deprecatedAppRoutes}</Routes>
</SidebarPage>
</AppRouter>
</AppProvider>
+1
View File
@@ -32,6 +32,7 @@
"@material-ui/core": "^4.11.0",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"classnames": "^2.2.6",
"moment": "^2.26.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
@@ -102,6 +102,9 @@ export function AboutCard({ entity }: AboutCardProps) {
<nav className={classes.links}>
<IconLinkVertical label="View Source" {...codeLink} />
<IconLinkVertical
disabled={
!entity.metadata.annotations?.['backstage.io/techdocs-ref']
}
label="View Techdocs"
icon={<DocsIcon />}
href={`/docs/${entity.kind}:${entity.metadata.namespace ?? ''}:${
@@ -14,12 +14,14 @@
* limitations under the License.
*/
import * as React from 'react';
import classnames from 'classnames';
import { makeStyles, Link } from '@material-ui/core';
import LinkIcon from '@material-ui/icons/Link';
export type IconLinkVerticalProps = {
icon?: React.ReactNode;
href?: string;
disabled?: boolean;
label: string;
};
@@ -30,6 +32,9 @@ const useIconStyles = makeStyles({
gridGap: 4,
textAlign: 'center',
},
disabled: {
color: 'gray',
},
label: {
fontSize: '0.7rem',
textTransform: 'uppercase',
@@ -41,9 +46,24 @@ const useIconStyles = makeStyles({
export function IconLinkVertical({
icon = <LinkIcon />,
href = '#',
disabled = false,
...props
}: IconLinkVerticalProps) {
const classes = useIconStyles();
if (disabled) {
return (
<Link
className={classnames(classes.link, classes.disabled)}
underline="none"
{...props}
>
{icon}
<span className={classes.label}>{props.label}</span>
</Link>
);
}
return (
<Link className={classes.link} href={href} {...props}>
{icon}
@@ -14,23 +14,17 @@
* limitations under the License.
*/
import Auth0Auth from './Auth0Auth';
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import { rootRouteRef, viewAuditRouteRef, createAuditRouteRef } from './plugin';
import AuditList from './components/AuditList';
import AuditView from './components/AuditView';
import CreateAudit from './components/CreateAudit';
describe('Auth0Auth', () => {
it('should normalize scope', () => {
const tests = [
{
arguments: ['read_user api write_repository'],
expect: new Set(['read_user', 'api', 'write_repository']),
},
{
arguments: ['read_repository sudo'],
expect: new Set(['read_repository', 'sudo']),
},
];
for (const test of tests) {
expect(Auth0Auth.normalizeScopes(...test.arguments)).toEqual(test.expect);
}
});
});
export const Router = () => (
<Routes>
<Route path={`/${rootRouteRef.path}`} element={<AuditList />} />
<Route path={`/${viewAuditRouteRef.path}`} element={<AuditView />} />
<Route path={`/${createAuditRouteRef.path}`} element={<CreateAudit />} />
</Routes>
);
@@ -58,10 +58,7 @@ describe('AuditListTable', () => {
if (!website)
throw new Error('https://anchor.fm must be present in fixture');
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute(
'href',
`/lighthouse/audit/${website.lastAudit.id}`,
);
expect(link).toHaveAttribute('href', `/audit/${website.lastAudit.id}`);
});
it('renders the dates that are available for a given row', () => {
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC, useState } from 'react';
import React, { FC, useState, useEffect } from 'react';
import { Table, TableColumn, TrendLine, useApi } from '@backstage/core';
import { Website, lighthouseApiRef } from '../../api';
import { useInterval } from 'react-use';
@@ -23,8 +23,9 @@ import {
CATEGORY_LABELS,
buildSparklinesDataForItem,
} from '../../utils';
import { Link } from '@material-ui/core';
import { Link, generatePath } from 'react-router-dom';
import AuditStatusIcon from '../AuditStatusIcon';
import { viewAuditRouteRef } from '../../plugin';
const columns: TableColumn[] = [
{
@@ -55,6 +56,10 @@ export const AuditListTable: FC<{ items: Website[] }> = ({ items }) => {
const [websiteState, setWebsiteState] = useState(items);
const lighthouseApi = useApi(lighthouseApiRef);
useEffect(() => {
setWebsiteState(items);
}, [items]);
const runRefresh = (websites: Website[]) => {
websites.forEach(async website => {
const response = await lighthouseApi.getWebsiteForAuditId(
@@ -94,7 +99,11 @@ export const AuditListTable: FC<{ items: Website[] }> = ({ items }) => {
return {
websiteUrl: (
<Link href={`/lighthouse/audit/${website.lastAudit.id}`}>
<Link
to={generatePath(viewAuditRouteRef.path, {
id: website.lastAudit.id,
})}
>
{website.url}
</Link>
),
@@ -63,7 +63,7 @@ describe('AuditList', () => {
expect(element).toBeInTheDocument();
});
it('renders a link to create a new audit', async () => {
it('renders a button to create a new audit', async () => {
const rendered = render(
wrapInTestApp(
<ApiProvider apis={apis}>
@@ -71,12 +71,8 @@ describe('AuditList', () => {
</ApiProvider>,
),
);
const element = await rendered.findByText('Create Audit');
expect(element).toBeInTheDocument();
expect(element.parentElement).toHaveAttribute(
'href',
'/lighthouse/create-audit',
);
const button = await rendered.findByText('Create Audit');
expect(button).toBeInTheDocument();
});
describe('pagination', () => {
@@ -87,7 +83,7 @@ describe('AuditList', () => {
<ApiProvider apis={apis}>
<AuditList />
</ApiProvider>,
{ routeEntries: ['/lighthouse?page=2'] },
{ routeEntries: ['?page=2'] },
),
);
expect(mockFetch).toHaveBeenLastCalledWith(
@@ -137,13 +133,13 @@ describe('AuditList', () => {
<ApiProvider apis={apis}>
<AuditList />
</ApiProvider>,
{ routeEntries: ['/lighthouse?page=2'] },
{ routeEntries: ['?page=2'] },
),
);
const element = await rendered.findByLabelText(/Go to page 1/);
fireEvent.click(element);
expect(useNavigate()).toHaveBeenCalledWith(`/lighthouse?page=1`);
expect(useNavigate()).toHaveBeenCalledWith(`?page=1`);
});
});
});
@@ -37,6 +37,7 @@ import { useQuery } from '../../utils';
import LighthouseSupportButton from '../SupportButton';
import LighthouseIntro, { LIGHTHOUSE_INTRO_LOCAL_STORAGE } from '../Intro';
import AuditListTable from './AuditListTable';
import { createAuditRouteRef } from '../../plugin';
export const LIMIT = 10;
@@ -77,7 +78,7 @@ const AuditList: FC<{}> = () => {
page={page}
count={pageCount}
onChange={(_event: Event, newPage: number) => {
navigate(`/lighthouse?page=${newPage}`);
navigate(`?page=${newPage}`);
}}
/>
)}
@@ -111,7 +112,7 @@ const AuditList: FC<{}> = () => {
<Button
variant="contained"
color="primary"
href="/lighthouse/create-audit"
onClick={() => navigate(createAuditRouteRef.path)}
>
Create Audit
</Button>
@@ -18,15 +18,17 @@
jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
const mockNavigation = jest.fn();
return {
...actual,
useParams: jest.fn(() => ({})),
useNavigate: jest.fn(() => mockNavigation),
};
});
import React from 'react';
import mockFetch from 'jest-fetch-mock';
import { render } from '@testing-library/react';
import { render, fireEvent } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { ApiRegistry, ApiProvider } from '@backstage/core';
@@ -34,11 +36,13 @@ import AuditView from '.';
import { lighthouseApiRef, LighthouseRestApi, Audit, Website } from '../../api';
import { formatTime } from '../../utils';
import * as data from '../../__fixtures__/website-response.json';
import { act } from 'react-dom/test-utils';
const { useParams }: { useParams: jest.Mock } = jest.requireMock(
'react-router-dom',
);
const websiteResponse = data as Website;
const { useNavigate } = jest.requireMock('react-router-dom');
describe('AuditView', () => {
let apis: ApiRegistry;
@@ -70,7 +74,7 @@ describe('AuditView', () => {
expect(iframe).toHaveAttribute('src', `https://lighthouse/v1/audits/${id}`);
});
it('renders a link to create a new audit for this website', async () => {
it('renders a button to click to create a new audit for this website', async () => {
const rendered = render(
wrapInTestApp(
<ApiProvider apis={apis}>
@@ -79,13 +83,15 @@ describe('AuditView', () => {
),
);
const button = await rendered.findByText('Create Audit');
const button = await rendered.findByText('Create New Audit');
expect(button).toBeInTheDocument();
expect(button.parentElement).toHaveAttribute(
'href',
`/lighthouse/create-audit?url=${encodeURIComponent(
'https://spotify.com',
)}`,
act(() => {
fireEvent.click(button);
});
expect(useNavigate()).toHaveBeenCalledWith(
`../../create-audit?url=${encodeURIComponent('https://spotify.com')}`,
);
});
@@ -151,7 +157,7 @@ describe('AuditView', () => {
expect(
rendered.getByText(formatTime(a.timeCreated)).parentElement
?.parentElement,
).toHaveAttribute('href', `/lighthouse/audit/${a.id}`);
).toHaveAttribute('href', `/audit/${a.id}`);
});
});
});
@@ -14,7 +14,13 @@
* limitations under the License.
*/
import React, { useState, useEffect, ReactNode, FC } from 'react';
import { Link, useParams } from 'react-router-dom';
import {
Link,
useParams,
useNavigate,
generatePath,
resolvePath,
} from 'react-router-dom';
import { useAsync } from 'react-use';
import {
makeStyles,
@@ -42,6 +48,7 @@ import { lighthouseApiRef, Website, Audit } from '../../api';
import AuditStatusIcon from '../AuditStatusIcon';
import LighthouseSupportButton from '../SupportButton';
import { formatTime } from '../../utils';
import { viewAuditRouteRef, createAuditRouteRef } from '../../plugin';
const useStyles = makeStyles({
contentGrid: {
@@ -75,7 +82,12 @@ const AuditLinkList: FC<AuditLinkListProps> = ({
button
component={Link}
replace
to={`/lighthouse/audit/${audit.id}`}
to={resolvePath(
generatePath(viewAuditRouteRef.path, {
id: audit.id,
}),
'../../',
)}
>
<ListItemIcon>
<AuditStatusIcon audit={audit} />
@@ -116,6 +128,7 @@ const ConnectedAuditView: FC<{}> = () => {
const lighthouseApi = useApi(lighthouseApiRef);
const params = useParams() as { id: string };
const classes = useStyles();
const navigate = useNavigate();
const { loading, error, value: nextValue } = useAsync(
async () => await lighthouseApi.getWebsiteForAuditId(params.id),
@@ -154,7 +167,7 @@ const ConnectedAuditView: FC<{}> = () => {
);
}
let createAuditButtonUrl = '/lighthouse/create-audit';
let createAuditButtonUrl = createAuditRouteRef.path;
if (value?.url) {
createAuditButtonUrl += `?url=${encodeURIComponent(value.url)}`;
}
@@ -176,9 +189,9 @@ const ConnectedAuditView: FC<{}> = () => {
<Button
variant="contained"
color="primary"
href={createAuditButtonUrl}
onClick={() => navigate(`../../${createAuditButtonUrl}`)}
>
Create Audit
Create New Audit
</Button>
<LighthouseSupportButton />
</ContentHeader>
@@ -78,9 +78,7 @@ describe('CreateAudit', () => {
<CreateAudit />
</ApiProvider>,
{
routeEntries: [
`/lighthouse/create-audit?url=${encodeURIComponent(url)}`,
],
routeEntries: [`/create-audit?url=${encodeURIComponent(url)}`],
},
),
);
@@ -137,7 +135,7 @@ describe('CreateAudit', () => {
await wait(() => expect(rendered.getByLabelText(/URL/)).toBeEnabled());
expect(useNavigate()).toHaveBeenCalledWith('/lighthouse');
expect(useNavigate()).toHaveBeenCalledWith('..');
});
});
@@ -78,7 +78,7 @@ const CreateAudit: FC<{}> = () => {
},
},
});
navigate('/lighthouse');
navigate('..');
} catch (err) {
errorApi.post(err);
} finally {
@@ -154,7 +154,7 @@ const CreateAudit: FC<{}> = () => {
<Button
variant="outlined"
color="primary"
href="/lighthouse"
onClick={() => navigate('..')}
disabled={submitting}
>
Cancel
+1
View File
@@ -15,4 +15,5 @@
*/
export { plugin } from './plugin';
export { Router } from './Router';
export * from './api';
+21 -9
View File
@@ -14,12 +14,29 @@
* limitations under the License.
*/
import { createPlugin, createApiFactory, configApiRef } from '@backstage/core';
import AuditList from './components/AuditList';
import AuditView from './components/AuditView';
import CreateAudit from './components/CreateAudit';
import {
createPlugin,
createRouteRef,
createApiFactory,
configApiRef,
} from '@backstage/core';
import { lighthouseApiRef, LighthouseRestApi } from './api';
export const rootRouteRef = createRouteRef({
path: '',
title: 'Lighthouse',
});
export const viewAuditRouteRef = createRouteRef({
path: 'audit/:id',
title: 'View Lighthouse Audit',
});
export const createAuditRouteRef = createRouteRef({
path: 'create-audit',
title: 'Create Lighthouse Audit',
});
export const plugin = createPlugin({
id: 'lighthouse',
apis: [
@@ -29,9 +46,4 @@ export const plugin = createPlugin({
factory: ({ configApi }) => LighthouseRestApi.fromConfig(configApi),
}),
],
register({ router }) {
router.registerRoute('/lighthouse', AuditList);
router.registerRoute('/lighthouse/audit/:id', AuditView);
router.registerRoute('/lighthouse/create-audit', CreateAudit);
},
});
+7 -4
View File
@@ -30,6 +30,8 @@ export class ScaffolderApi {
}
/**
* Executes the scaffolding of a component, given a template and its
* parameter values.
*
* @param template Template entity for the scaffolder to use. New project is going to be created out of this template.
* @param values Parameters for the template, e.g. name, description
@@ -49,7 +51,9 @@ export class ScaffolderApi {
});
if (response.status !== 201) {
throw new Error(await response.text());
const status = `${response.status} ${response.statusText}`;
const body = await response.text();
throw new Error(`Backend request failed, ${status} ${body.trim()}`);
}
const { id } = await response.json();
@@ -57,9 +61,8 @@ export class ScaffolderApi {
}
async getJob(jobId: string) {
const url = `${await this.discoveryApi.getBaseUrl(
'scaffolder',
)}/v1/job/${encodeURIComponent(jobId)}`;
const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder');
const url = `${baseUrl}/v1/job/${encodeURIComponent(jobId)}`;
return fetch(url).then(x => x.json());
}
}
@@ -19,11 +19,13 @@ import {
AccordionDetails,
AccordionSummary,
Box,
CircularProgress,
LinearProgress,
Typography,
} from '@material-ui/core';
import { makeStyles } from '@material-ui/core/styles';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import ExpandLessIcon from '@material-ui/icons/ExpandLess';
import cn from 'classnames';
import moment from 'moment';
import React, { Suspense, useEffect, useState } from 'react';
@@ -38,8 +40,7 @@ const useStyles = makeStyles(theme => ({
},
button: {
order: -1,
marginRight: 0,
marginLeft: '-20px',
margin: '0 1em 0 -20px',
},
cardContent: {
backgroundColor: theme.palette.background.default,
@@ -110,7 +111,7 @@ export const JobStage = ({ endedAt, startedAt, name, log, status }: Props) => {
onChange={(_, newState) => setExpanded(newState)}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
expandIcon={expanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
aria-controls={`panel-${name}-content`}
id={`panel-${name}-header`}
IconButtonProps={{
@@ -118,7 +119,8 @@ export const JobStage = ({ endedAt, startedAt, name, log, status }: Props) => {
}}
>
<Typography variant="button">
{name} {timeElapsed && `(${timeElapsed})`}
{name} {timeElapsed && `(${timeElapsed})`}{' '}
{startedAt && !endedAt && <CircularProgress size="1em" />}
</Typography>
</AccordionSummary>
<AccordionDetails className={classes.expansionPanelDetails}>
@@ -51,7 +51,7 @@ export const JobStatusModal = ({
return (
<Dialog open onClose={onClose} fullWidth>
<DialogTitle id="responsive-dialog-title">
Creating component...
Creating Component...
</DialogTitle>
<DialogContent>
{!job ? (
@@ -91,8 +91,12 @@ export const TemplatePage = () => {
const handleClose = () => setJobId(null);
const handleCreate = async () => {
const job = await scaffolderApi.scaffold(template!, formState);
setJobId(job);
try {
const job = await scaffolderApi.scaffold(template!, formState);
setJobId(job);
} catch (e) {
errorApi.post(e);
}
};
const [entity, setEntity] = React.useState<TemplateEntityV1alpha1 | null>(
@@ -157,7 +161,7 @@ export const TemplatePage = () => {
/>
)}
{template && (
<InfoCard title={template.metadata.title as string} noPadding>
<InfoCard title={template.metadata.title} noPadding>
<MultistepJsonForm
formData={formState}
onChange={handleChange}
@@ -26,9 +26,11 @@ export const addLinkClickListener = ({
return dom => {
Array.from(dom.getElementsByTagName('a')).forEach(elem => {
elem.addEventListener('click', (e: MouseEvent) => {
e.preventDefault();
const target = e.target as HTMLAnchorElement;
if (target?.getAttribute('href')) {
const href = target?.getAttribute('href');
if (!href) return;
if (!href.match(/^https?:\/\//i)) {
e.preventDefault();
onClick(e, target.getAttribute('href')!);
}
});
@@ -27,6 +27,10 @@ export const rewriteDocLinks = (): Transformer => {
.forEach((elem: T) => {
const elemAttribute = elem.getAttribute(attributeName);
if (elemAttribute) {
// if link is external, add target to open in a new window or tab
if (elemAttribute.match(/^https?:\/\//i)) {
elem.setAttribute('target', '_blank');
}
const normalizedWindowLocation = window.location.href.endsWith('/')
? window.location.href
: `${window.location.href}/`;
+27 -25
View File
@@ -3693,16 +3693,7 @@
"@svgr/hast-util-to-babel-ast" "^5.4.0"
svg-parser "^2.0.2"
"@svgr/plugin-svgo@4.3.x":
version "4.3.1"
resolved "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-4.3.1.tgz#daac0a3d872e3f55935c6588dd370336865e9e32"
integrity sha512-PrMtEDUWjX3Ea65JsVCwTIXuSqa3CG9px+DluF1/eo9mlDrgrtFE7NE/DjdhjJgSM9wenlVBzkzneSIUgfUI/w==
dependencies:
cosmiconfig "^5.2.1"
merge-deep "^3.0.2"
svgo "^1.2.2"
"@svgr/plugin-svgo@^5.4.0":
"@svgr/plugin-svgo@5.4.x", "@svgr/plugin-svgo@^5.4.0":
version "5.4.0"
resolved "https://registry.npmjs.org/@svgr/plugin-svgo/-/plugin-svgo-5.4.0.tgz#45d9800b7099a6f7b4d85ebac89ab9abe8592f64"
integrity sha512-3Cgv3aYi1l6SHyzArV9C36yo4kgwVdF3zPQUC6/aCDUeXAofDYwE5kk3e3oT5ZO2a0N3lB+lLGvipBG6lnG8EA==
@@ -7863,7 +7854,7 @@ cosmiconfig@6.0.0, cosmiconfig@^6.0.0:
path-type "^4.0.0"
yaml "^1.7.2"
cosmiconfig@^5.0.0, cosmiconfig@^5.1.0, cosmiconfig@^5.2.1:
cosmiconfig@^5.0.0, cosmiconfig@^5.1.0:
version "5.2.1"
resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz#040f726809c591e77a17c0a3626ca45b4f168b1a"
integrity sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA==
@@ -7873,6 +7864,17 @@ cosmiconfig@^5.0.0, cosmiconfig@^5.1.0, cosmiconfig@^5.2.1:
js-yaml "^3.13.1"
parse-json "^4.0.0"
cosmiconfig@^7.0.0:
version "7.0.0"
resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.0.tgz#ef9b44d773959cae63ddecd122de23853b60f8d3"
integrity sha512-pondGvTuVYDk++upghXJabWzL6Kxu6f26ljFw64Swq9v6sQPUL3EUlVDV56diOjpCayKihL6hVe8exIACU4XcA==
dependencies:
"@types/parse-json" "^4.0.0"
import-fresh "^3.2.1"
parse-json "^5.0.0"
path-type "^4.0.0"
yaml "^1.10.0"
create-ecdh@^4.0.0:
version "4.0.3"
resolved "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.3.tgz#c9111b6f33045c4697f144787f9254cdc77c45ff"
@@ -11607,14 +11609,14 @@ humanize-ms@^1.2.1:
ms "^2.0.0"
husky@^4.2.3:
version "4.2.5"
resolved "https://registry.npmjs.org/husky/-/husky-4.2.5.tgz#2b4f7622673a71579f901d9885ed448394b5fa36"
integrity sha512-SYZ95AjKcX7goYVZtVZF2i6XiZcHknw50iXvY7b0MiGoj5RwdgRQNEHdb+gPDPCXKlzwrybjFjkL6FOj8uRhZQ==
version "4.3.0"
resolved "https://registry.npmjs.org/husky/-/husky-4.3.0.tgz#0b2ec1d66424e9219d359e26a51c58ec5278f0de"
integrity sha512-tTMeLCLqSBqnflBZnlVDhpaIMucSGaYyX6855jM4AguGeWCeSzNdb1mfyWduTZ3pe3SJVvVWGL0jO1iKZVPfTA==
dependencies:
chalk "^4.0.0"
ci-info "^2.0.0"
compare-versions "^3.6.0"
cosmiconfig "^6.0.0"
cosmiconfig "^7.0.0"
find-versions "^3.2.0"
opencollective-postinstall "^2.0.2"
pkg-dir "^4.2.0"
@@ -11722,7 +11724,7 @@ import-fresh@^2.0.0:
caller-path "^2.0.0"
resolve-from "^3.0.0"
import-fresh@^3.0.0, import-fresh@^3.1.0:
import-fresh@^3.0.0, import-fresh@^3.1.0, import-fresh@^3.2.1:
version "3.2.1"
resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.2.1.tgz#633ff618506e793af5ac91bf48b72677e15cbe66"
integrity sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ==
@@ -17778,9 +17780,9 @@ react-use@^12.2.0:
tslib "^1.10.0"
react-use@^15.3.3:
version "15.3.3"
resolved "https://registry.npmjs.org/react-use/-/react-use-15.3.3.tgz#f16de7a16286c446388e8bd99680952fc3dc9a95"
integrity sha512-nYb94JbmDCaLZg3sOXmFW8HN+lXWxnl0caspXoYfZG1CON8JfLN9jMOyxRDUpm7dUq7WZ5mIept/ByqBQKJ0wQ==
version "15.3.4"
resolved "https://registry.npmjs.org/react-use/-/react-use-15.3.4.tgz#f853d310bd71f75b38900a8caa3db93f6dc6e872"
integrity sha512-cHq1dELW6122oi1+xX7lwNyE/ugZs5L902BuO8eFJCfn2api1KeuPVG1M/GJouVARoUf54S2dYFMKo5nQXdTag==
dependencies:
"@types/js-cookie" "2.2.6"
"@xobotyi/scrollbar-width" "1.9.5"
@@ -21446,9 +21448,9 @@ webpack-log@^2.0.0:
uuid "^3.3.2"
webpack-node-externals@^2.5.0:
version "2.5.0"
resolved "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-2.5.0.tgz#8d50f3289c71bc2b921a8da228e0b652acc503f1"
integrity sha512-g7/Z7Q/gsP8GkJkKZuJggn6RSb5PvxW1YD5vvmRZIxaSxAzkqjfL5n9CslVmNYlSqBVCyiqFgOqVS2IOObCSRg==
version "2.5.2"
resolved "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-2.5.2.tgz#178e017a24fec6015bc9e672c77958a6afac861d"
integrity sha512-aHdl/y2N7PW2Sx7K+r3AxpJO+aDMcYzMQd60Qxefq3+EwhewSbTBqNumOsCE1JsCUNoyfGj5465N0sSf6hc/5w==
webpack-sources@^1.1.0, webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3:
version "1.4.3"
@@ -21539,9 +21541,9 @@ whatwg-fetch@^2.0.4:
integrity sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng==
whatwg-fetch@^3.0.0, whatwg-fetch@^3.4.0:
version "3.4.0"
resolved "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.4.0.tgz#e11de14f4878f773fbebcde8871b2c0699af8b30"
integrity sha512-rsum2ulz2iuZH08mJkT0Yi6JnKhwdw4oeyMjokgxd+mmqYSd9cPpOQf01TIWgjxG/U4+QR+AwKq6lSbXVxkyoQ==
version "3.4.1"
resolved "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.4.1.tgz#e5f871572d6879663fa5674c8f833f15a8425ab3"
integrity sha512-sofZVzE1wKwO+EYPbWfiwzaKovWiZXf4coEzjGP9b2GBVgQRLQUZ2QcuPpQExGDAW5GItpEm6Tl4OU5mywnAoQ==
whatwg-mimetype@^2.1.0, whatwg-mimetype@^2.2.0, whatwg-mimetype@^2.3.0:
version "2.3.0"