Merge branch 'master' into mobile-sidebar

Signed-off-by: Philipp Hugenroth <philipph@spotify.com>
This commit is contained in:
Philipp Hugenroth
2021-12-29 18:17:13 +01:00
67 changed files with 1760 additions and 766 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-test-utils': patch
---
Lazy-load `testcontainers` module in order to avoid side-effects.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/backend-common': patch
---
Use the default CSP policy provided by `helmet` directly rather than a copy.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-auth-backend': patch
---
Update `auth0` and `onelogin` providers to allow for `authHandler` and `signIn.resolver` configuration.
+29
View File
@@ -0,0 +1,29 @@
---
'@backstage/plugin-auth-backend': minor
---
Avoid ever returning OAuth refresh tokens back to the client, and always exchange refresh tokens for a new one when available for all providers.
This comes with a breaking change to the TypeScript API for custom auth providers. The `refresh` method of `OAuthHandlers` implementation must now return a `{ response, refreshToken }` object rather than a direct response. Existing `refresh` implementations are typically migrated by changing an existing return expression that looks like this:
```ts
return await this.handleResult({
fullProfile,
params,
accessToken,
refreshToken,
});
```
Into the following:
```ts
return {
response: await this.handleResult({
fullProfile,
params,
accessToken,
}),
refreshToken,
};
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search': patch
---
Internal cleanup of the exports structure
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/create-app': patch
---
Removed unused templating asset.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-app-backend': patch
---
Set `X-Frame-Options: deny` rather than the default `sameorigin` for all content served by the `app-backend`.`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/create-app': patch
---
Add a comment to the default backend about the fallback 404 handler.
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/plugin-search': patch
---
Introduces a `<SearchType.Accordion />` variant, which operates on the same part of a search query as the existing `<SearchType />`, but in a more opinionated way (as a single-select control surface suitable for faceted search UIs).
Check the [search plugin storybook](https://backstage.io/storybook/?path=/story/plugins-search-searchtype--accordion) to see how it can be used.
@@ -0,0 +1,5 @@
---
'@backstage/plugin-search': patch
---
Captures the search term entered in the SearchBarBase as a `search` event.
+58
View File
@@ -0,0 +1,58 @@
---
'@backstage/create-app': patch
---
The `<SearchType />` filter in the composed `SearchPage.tsx` was replaced with the `<SearchType.Accordion />` variant.
This is an entirely optional change; if you wish to display a control surface for search `types` as a single-select accordion (as opposed to the current multi-select of checkboxes), you can make the following (or similar) changes to your search page layout:
```diff
--- a/packages/app/src/components/search/SearchPage.tsx
+++ b/packages/app/src/components/search/SearchPage.tsx
@@ -11,7 +11,7 @@ import {
SearchType,
DefaultResultListItem,
} from '@backstage/plugin-search';
-import { Content, Header, Page } from '@backstage/core-components';
+import { CatalogIcon, Content, DocsIcon, Header, Page } from '@backstage/core-components';
const useStyles = makeStyles((theme: Theme) => ({
bar: {
@@ -19,6 +19,7 @@ const useStyles = makeStyles((theme: Theme) => ({
},
filters: {
padding: theme.spacing(2),
+ marginTop: theme.spacing(2),
},
filter: {
'& + &': {
@@ -41,12 +42,23 @@ const SearchPage = () => {
</Paper>
</Grid>
<Grid item xs={3}>
+ <SearchType.Accordion
+ name="Result Type"
+ defaultValue="software-catalog"
+ types={[
+ {
+ value: 'software-catalog',
+ name: 'Software Catalog',
+ icon: <CatalogIcon />,
+ },
+ {
+ value: 'techdocs',
+ name: 'Documentation',
+ icon: <DocsIcon />,
+ },
+ ]}
+ />
<Paper className={classes.filters}>
- <SearchType
- values={['techdocs', 'software-catalog']}
- name="type"
- defaultValue="software-catalog"
- />
<SearchFilter.Select
className={classes.filter}
name="kind"
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-api-docs': patch
---
Display entity title on `ApiDefinitionCard` if defined
+5 -4
View File
@@ -55,10 +55,11 @@ learn how to contribute the integration yourself!
The following table summarizes events that, depending on the plugins you have
installed, may be captured.
| Action | Provided By | Subject |
| ---------- | -------------- | ----------------------------------------- |
| `navigate` | Backstage Core | The URL of the page that was navigated to |
| `click` | Backstage Core | The text of the link that was clicked on |
| Action | Provided By | Subject |
| ---------- | -------------- | --------------------------------------------------- |
| `navigate` | Backstage Core | The URL of the page that was navigated to |
| `click` | Backstage Core | The text of the link that was clicked on |
| `search` | Backstage Core | The search term entered in any search bar component |
If there is an event you'd like to see captured, please [open an
issue][add-event] describing the event you want to see and the questions it
@@ -15,7 +15,9 @@
*/
import {
CatalogIcon,
Content,
DocsIcon,
Header,
Lifecycle,
Page,
@@ -45,6 +47,7 @@ const useStyles = makeStyles((theme: Theme) => ({
},
filters: {
padding: theme.spacing(2),
marginTop: theme.spacing(2),
},
}));
@@ -64,12 +67,23 @@ const SearchPage = () => {
</Grid>
{!isMobile && (
<Grid item xs={3}>
<SearchType.Accordion
name="Result Type"
defaultValue="software-catalog"
types={[
{
value: 'software-catalog',
name: 'Software Catalog',
icon: <CatalogIcon />,
},
{
value: 'techdocs',
name: 'Documentation',
icon: <DocsIcon />,
},
]}
/>
<Paper className={classes.filters}>
<SearchType
values={['techdocs', 'software-catalog']}
name="type"
defaultValue="software-catalog"
/>
<SearchFilter.Select
className={classes.filter}
name="kind"
@@ -19,6 +19,7 @@ import compression from 'compression';
import cors from 'cors';
import express, { Router, ErrorRequestHandler } from 'express';
import helmet from 'helmet';
import { ContentSecurityPolicyOptions } from 'helmet/dist/middlewares/content-security-policy';
import * as http from 'http';
import stoppable from 'stoppable';
import { Logger } from 'winston';
@@ -43,19 +44,6 @@ import { createHttpServer, createHttpsServer } from './hostFactory';
export const DEFAULT_PORT = 7007;
// '' is express default, which listens to all interfaces
const DEFAULT_HOST = '';
// taken from the helmet source code - don't seem to be exported
const DEFAULT_CSP = {
'default-src': ["'self'"],
'base-uri': ["'self'"],
'block-all-mixed-content': [],
'font-src': ["'self'", 'https:', 'data:'],
'frame-ancestors': ["'self'"],
'img-src': ["'self'", 'data:'],
'object-src': ["'none'"],
'script-src': ["'self'", "'unsafe-eval'"],
'script-src-attr': ["'none'"],
'style-src': ["'self'", 'https:', "'unsafe-inline'"],
};
export class ServiceBuilderImpl implements ServiceBuilder {
private port: number | undefined;
@@ -236,8 +224,9 @@ export class ServiceBuilderImpl implements ServiceBuilder {
export function applyCspDirectives(
directives: Record<string, string[] | false> | undefined,
): CspOptions | undefined {
const result: CspOptions = { ...DEFAULT_CSP };
): ContentSecurityPolicyOptions['directives'] {
const result: ContentSecurityPolicyOptions['directives'] =
helmet.contentSecurityPolicy.getDefaultDirectives();
if (directives) {
for (const [key, value] of Object.entries(directives)) {
@@ -42,8 +42,6 @@ export type CertificateAttributes = {
/**
* A map from CSP directive names to their values.
*
* Added here since helmet doesn't export this type publicly.
*/
export type CspOptions = Record<string, string[]>;
@@ -15,7 +15,6 @@
*/
import createConnection, { Knex } from 'knex';
import { GenericContainer } from 'testcontainers';
import { v4 as uuid } from 'uuid';
async function waitForMysqlReady(
@@ -50,6 +49,9 @@ export async function startMysqlContainer(image: string) {
const user = 'root';
const password = uuid();
// Lazy-load to avoid side-effect of importing testcontainers
const { GenericContainer } = await import('testcontainers');
const container = await new GenericContainer(image)
.withExposedPorts(3306)
.withEnv('MYSQL_ROOT_PASSWORD', password)
@@ -15,7 +15,6 @@
*/
import createConnection, { Knex } from 'knex';
import { GenericContainer } from 'testcontainers';
import { v4 as uuid } from 'uuid';
async function waitForPostgresReady(
@@ -50,6 +49,9 @@ export async function startPostgresContainer(image: string) {
const user = 'postgres';
const password = uuid();
// Lazy-load to avoid side-effect of importing testcontainers
const { GenericContainer } = await import('testcontainers');
const container = await new GenericContainer(image)
.withExposedPorts(5432)
.withEnv('POSTGRES_PASSWORD', password)
@@ -11,7 +11,13 @@ import {
SearchType,
DefaultResultListItem,
} from '@backstage/plugin-search';
import { Content, Header, Page } from '@backstage/core-components';
import {
CatalogIcon,
Content,
DocsIcon,
Header,
Page,
} from '@backstage/core-components';
const useStyles = makeStyles((theme: Theme) => ({
bar: {
@@ -19,6 +25,7 @@ const useStyles = makeStyles((theme: Theme) => ({
},
filters: {
padding: theme.spacing(2),
marginTop: theme.spacing(2),
},
filter: {
'& + &': {
@@ -41,12 +48,23 @@ const SearchPage = () => {
</Paper>
</Grid>
<Grid item xs={3}>
<SearchType.Accordion
name="Result Type"
defaultValue="software-catalog"
types={[
{
value: 'software-catalog',
name: 'Software Catalog',
icon: <CatalogIcon />,
},
{
value: 'techdocs',
name: 'Documentation',
icon: <DocsIcon />,
},
]}
/>
<Paper className={classes.filters}>
<SearchType
values={['techdocs', 'software-catalog']}
name="type"
defaultValue="software-catalog"
/>
<SearchFilter.Select
className={classes.filter}
name="kind"
@@ -81,6 +81,8 @@ async function main() {
apiRouter.use('/techdocs', await techdocs(techdocsEnv));
apiRouter.use('/proxy', await proxy(proxyEnv));
apiRouter.use('/search', await search(searchEnv));
// Add backends ABOVE this line; this 404 handler is the catch-all fallback
apiRouter.use(notFoundHandler());
const service = createServiceBuilder(module)
@@ -1,27 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Backstage is an open platform for building developer portals"
/>
<title>Backstage</title>
</head>
<body style="margin: 0">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
@@ -62,6 +62,7 @@ paths:
kind: 'API',
metadata: {
name: 'my-name',
title: 'My Name',
},
spec: {
type: 'openapi',
@@ -88,7 +89,7 @@ paths:
);
await waitFor(() => {
expect(getByText(/my-name/i)).toBeInTheDocument();
expect(getByText(/My Name/i)).toBeInTheDocument();
expect(getByText(/OpenAPI/)).toBeInTheDocument();
expect(getByText(/Raw/i)).toBeInTheDocument();
expect(getByText(/List all artists/i)).toBeInTheDocument();
@@ -101,6 +102,7 @@ paths:
kind: 'API',
metadata: {
name: 'my-name',
title: 'My Name',
},
spec: {
type: 'custom-type',
@@ -118,7 +120,7 @@ paths:
</Wrapper>,
);
expect(getByText(/my-name/i)).toBeInTheDocument();
expect(getByText(/My Name/i)).toBeInTheDocument();
expect(getByText(/custom-type/i)).toBeInTheDocument();
expect(
getAllByText(
@@ -39,10 +39,11 @@ export const ApiDefinitionCard = (_: Props) => {
}
const definitionWidget = getApiDefinitionWidget(entity);
const entityTitle = entity.metadata.title ?? entity.metadata.name;
if (definitionWidget) {
return (
<TabbedCard title={entity.metadata.name}>
<TabbedCard title={entityTitle}>
<CardTab label={definitionWidget.title} key="widget">
{definitionWidget.component(entity.spec.definition)}
</CardTab>
@@ -58,7 +59,7 @@ export const ApiDefinitionCard = (_: Props) => {
return (
<TabbedCard
title={entity.metadata.name}
title={entityTitle}
children={[
// Has to be an array, otherwise typescript doesn't like that this has only a single child
<CardTab label={entity.spec.type} key="raw">
+1
View File
@@ -38,6 +38,7 @@
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"fs-extra": "9.1.0",
"helmet": "^4.0.0",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
@@ -16,6 +16,7 @@
import { notFoundHandler, resolvePackagePath } from '@backstage/backend-common';
import { Config } from '@backstage/config';
import helmet from 'helmet';
import express from 'express';
import Router from 'express-promise-router';
import fs from 'fs-extra';
@@ -89,6 +90,8 @@ export async function createRouter(
const router = Router();
router.use(helmet.frameguard({ action: 'deny' }));
// Use a separate router for static content so that a fallback can be provided by backend
const staticRouter = Router();
staticRouter.use(express.static(resolvePath(appDistDir, 'static')));
+41 -10
View File
@@ -27,10 +27,13 @@ export class AtlassianAuthProvider implements OAuthHandlers {
// (undocumented)
handler(req: express.Request): Promise<{
response: OAuthResponse;
refreshToken: string;
refreshToken: string | undefined;
}>;
// (undocumented)
refresh(req: OAuthRefreshRequest): Promise<OAuthResponse>;
refresh(req: OAuthRefreshRequest): Promise<{
response: OAuthResponse;
refreshToken: string | undefined;
}>;
// Warning: (ae-forgotten-export) The symbol "RedirectInfo" needs to be exported by the entry point index.d.ts
//
// (undocumented)
@@ -47,6 +50,14 @@ export type AtlassianProviderOptions = {
};
};
// @public (undocumented)
export type Auth0ProviderOptions = {
authHandler?: AuthHandler<OAuthResult>;
signIn?: {
resolver: SignInResolver<OAuthResult>;
};
};
// @public
export type AuthHandler<AuthResult> = (
input: AuthResult,
@@ -219,6 +230,11 @@ export const createAtlassianProvider: (
options?: AtlassianProviderOptions | undefined,
) => AuthProviderFactory;
// @public (undocumented)
export const createAuth0Provider: (
options?: Auth0ProviderOptions | undefined,
) => AuthProviderFactory;
// Warning: (ae-missing-release-tag) "createAwsAlbProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -282,6 +298,11 @@ export const createOktaProvider: (
_options?: OktaProviderOptions | undefined,
) => AuthProviderFactory;
// @public (undocumented)
export const createOneLoginProvider: (
options?: OneLoginProviderOptions | undefined,
) => AuthProviderFactory;
// Warning: (ae-missing-release-tag) "createOriginFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -470,7 +491,10 @@ export interface OAuthHandlers {
// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}'
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}'
refresh?(req: OAuthRefreshRequest): Promise<OAuthResponse>;
refresh?(req: OAuthRefreshRequest): Promise<{
response: OAuthResponse;
refreshToken?: string;
}>;
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}'
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
@@ -485,7 +509,6 @@ export type OAuthProviderInfo = {
idToken?: string;
expiresInSeconds?: number;
scope: string;
refreshToken?: string;
};
// Warning: (ae-missing-release-tag) "OAuthProviderOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
@@ -572,6 +595,14 @@ export type OktaProviderOptions = {
};
};
// @public (undocumented)
export type OneLoginProviderOptions = {
authHandler?: AuthHandler<OAuthResult>;
signIn?: {
resolver: SignInResolver<OAuthResult>;
};
};
// Warning: (ae-missing-release-tag) "postMessageResponse" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -677,11 +708,11 @@ export type WebMessageResponse =
//
// src/identity/types.d.ts:31:9 - (ae-forgotten-export) The symbol "AnyJWK" needs to be exported by the entry point index.d.ts
// src/providers/aws-alb/provider.d.ts:77:5 - (ae-forgotten-export) The symbol "AwsAlbResult" needs to be exported by the entry point index.d.ts
// src/providers/github/provider.d.ts:71:58 - (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag
// src/providers/github/provider.d.ts:71:90 - (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag
// src/providers/github/provider.d.ts:71:89 - (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag
// src/providers/github/provider.d.ts:71:67 - (tsdoc-malformed-html-name) Invalid HTML element: Expecting an HTML name
// src/providers/github/provider.d.ts:71:68 - (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@"
// src/providers/github/provider.d.ts:78:5 - (ae-forgotten-export) The symbol "StateEncoder" needs to be exported by the entry point index.d.ts
// src/providers/github/provider.d.ts:74:58 - (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag
// src/providers/github/provider.d.ts:74:90 - (tsdoc-escape-greater-than) The ">" character should be escaped using a backslash to avoid confusion with an HTML tag
// src/providers/github/provider.d.ts:74:89 - (tsdoc-escape-right-brace) The "}" character should be escaped using a backslash to avoid confusion with a TSDoc inline tag
// src/providers/github/provider.d.ts:74:67 - (tsdoc-malformed-html-name) Invalid HTML element: Expecting an HTML name
// src/providers/github/provider.d.ts:74:68 - (tsdoc-malformed-inline-tag) Expecting a TSDoc tag starting with "{@"
// src/providers/github/provider.d.ts:81:5 - (ae-forgotten-export) The symbol "StateEncoder" needs to be exported by the entry point index.d.ts
// src/providers/types.d.ts:100:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts
```
@@ -57,7 +57,10 @@ describe('OAuthAdapter', () => {
};
}
async refresh() {
return mockResponseData;
return {
response: mockResponseData,
refreshToken: 'token',
};
}
}
const providerInstance = new MyAuthProvider();
@@ -257,7 +260,10 @@ describe('OAuthAdapter', () => {
});
it('correctly populates incomplete identities', async () => {
const mockRefresh = jest.fn<Promise<OAuthResponse>, [express.Request]>();
const mockRefresh = jest.fn<
Promise<{ response: OAuthResponse }>,
[express.Request]
>();
const oauthProvider = new OAuthAdapter(
{
@@ -291,10 +297,12 @@ describe('OAuthAdapter', () => {
// Without a token
mockRefresh.mockResolvedValueOnce({
...mockResponseData,
backstageIdentity: {
id: 'foo',
token: '',
response: {
...mockResponseData,
backstageIdentity: {
id: 'foo',
token: '',
},
},
});
await oauthProvider.refresh(mockRequest, mockResponse);
@@ -315,10 +323,12 @@ describe('OAuthAdapter', () => {
// With a token
mockRefresh.mockResolvedValueOnce({
...mockResponseData,
backstageIdentity: {
id: 'foo',
token: `z.${mkTokenBody({ sub: 'user:my-ns/foo' })}.z`,
response: {
...mockResponseData,
backstageIdentity: {
id: 'foo',
token: `z.${mkTokenBody({ sub: 'user:my-ns/foo' })}.z`,
},
},
});
await oauthProvider.refresh(mockRequest, mockResponse);
@@ -212,19 +212,15 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
const forwardReq = Object.assign(req, { scope, refreshToken });
// get new access_token
const response = await this.handlers.refresh(
forwardReq as OAuthRefreshRequest,
);
const { response, refreshToken: newRefreshToken } =
await this.handlers.refresh(forwardReq as OAuthRefreshRequest);
const backstageIdentity = await this.populateIdentity(
response.backstageIdentity,
);
if (
response.providerInfo.refreshToken &&
response.providerInfo.refreshToken !== refreshToken
) {
this.setRefreshTokenCookie(res, response.providerInfo.refreshToken);
if (newRefreshToken && newRefreshToken !== refreshToken) {
this.setRefreshTokenCookie(res, newRefreshToken);
}
res.status(200).json({ ...response, backstageIdentity });
+4 -5
View File
@@ -79,10 +79,6 @@ export type OAuthProviderInfo = {
* Scopes granted for the access token.
*/
scope: string;
/**
* A refresh token issued for the signed in user
*/
refreshToken?: string;
};
export type OAuthState = {
@@ -130,7 +126,10 @@ export interface OAuthHandlers {
* @param {string} refreshToken
* @param {string} scope
*/
refresh?(req: OAuthRefreshRequest): Promise<OAuthResponse>;
refresh?(req: OAuthRefreshRequest): Promise<{
response: OAuthResponse;
refreshToken?: string;
}>;
/**
* (Optional) Sign out of the auth provider.
@@ -78,20 +78,22 @@ describe('createAtlassianProvider', () => {
refreshToken: 'wacka',
},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual({
providerInfo: {
accessToken: 'accessToken',
expiresInSeconds: 123,
idToken: 'idToken',
scope: 'scope',
refreshToken: 'wacka',
},
profile: {
email: 'conrad@example.com',
displayName: 'Conrad',
picture: 'http://google.com/lols',
const result = await provider.handler({} as any);
expect(result).toEqual({
response: {
providerInfo: {
accessToken: 'accessToken',
expiresInSeconds: 123,
idToken: 'idToken',
scope: 'scope',
},
profile: {
email: 'conrad@example.com',
displayName: 'Conrad',
picture: 'http://google.com/lols',
},
},
refreshToken: 'wacka',
});
});
@@ -127,20 +129,22 @@ describe('createAtlassianProvider', () => {
],
});
const response = await provider.refresh({} as any);
const result = await provider.refresh({} as any);
expect(response).toEqual({
profile: {
displayName: 'Mocked User',
email: 'mockuser@gmail.com',
picture: 'http://google.com/lols',
},
providerInfo: {
accessToken: 'a.b.c',
idToken: 'my-id',
refreshToken: 'dont-forget-to-send-refresh',
scope: 'read_user',
expect(result).toEqual({
response: {
profile: {
displayName: 'Mocked User',
email: 'mockuser@gmail.com',
picture: 'http://google.com/lols',
},
providerInfo: {
accessToken: 'a.b.c',
idToken: 'my-id',
scope: 'read_user',
},
},
refreshToken: 'dont-forget-to-send-refresh',
});
});
});
@@ -107,9 +107,7 @@ export class AtlassianAuthProvider implements OAuthHandlers {
});
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken: string }> {
async handler(req: express.Request) {
const { result } = await executeFrameHandlerStrategy<OAuthResult>(
req,
this._strategy,
@@ -117,7 +115,7 @@ export class AtlassianAuthProvider implements OAuthHandlers {
return {
response: await this.handleResult(result),
refreshToken: result.refreshToken ?? '',
refreshToken: result.refreshToken,
};
}
@@ -128,7 +126,6 @@ export class AtlassianAuthProvider implements OAuthHandlers {
providerInfo: {
idToken: result.params.id_token,
accessToken: result.accessToken,
refreshToken: result.refreshToken,
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
},
@@ -152,28 +149,27 @@ export class AtlassianAuthProvider implements OAuthHandlers {
return response;
}
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
const {
accessToken,
params,
refreshToken: newRefreshToken,
} = await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
async refresh(req: OAuthRefreshRequest) {
const { accessToken, params, refreshToken } =
await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
return this.handleResult({
fullProfile,
params,
accessToken,
refreshToken: newRefreshToken,
});
return {
response: await this.handleResult({
fullProfile,
params,
accessToken,
}),
refreshToken,
};
}
}
@@ -36,7 +36,15 @@ import {
makeProfileInfo,
PassportDoneCallback,
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
import {
RedirectInfo,
AuthProviderFactory,
AuthHandler,
SignInResolver,
} from '../types';
import { CatalogIdentityClient } from '../../lib/catalog';
import { TokenIssuer } from '../../identity';
import { Logger } from 'winston';
type PrivateInfo = {
refreshToken: string;
@@ -44,12 +52,27 @@ type PrivateInfo = {
export type Auth0AuthProviderOptions = OAuthProviderOptions & {
domain: string;
signInResolver?: SignInResolver<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
};
export class Auth0AuthProvider implements OAuthHandlers {
private readonly _strategy: Auth0Strategy;
private readonly signInResolver?: SignInResolver<OAuthResult>;
private readonly authHandler: AuthHandler<OAuthResult>;
private readonly tokenIssuer: TokenIssuer;
private readonly catalogIdentityClient: CatalogIdentityClient;
private readonly logger: Logger;
constructor(options: Auth0AuthProviderOptions) {
this.signInResolver = options.signInResolver;
this.authHandler = options.authHandler;
this.tokenIssuer = options.tokenIssuer;
this.catalogIdentityClient = options.catalogIdentityClient;
this.logger = options.logger;
this._strategy = new Auth0Strategy(
{
clientID: options.clientId,
@@ -90,88 +113,144 @@ export class Auth0AuthProvider implements OAuthHandlers {
});
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken: string }> {
async handler(req: express.Request) {
const { result, privateInfo } = await executeFrameHandlerStrategy<
OAuthResult,
PrivateInfo
>(req, this._strategy);
const profile = makeProfileInfo(result.fullProfile, result.params.id_token);
return {
response: await this.populateIdentity({
profile,
providerInfo: {
idToken: result.params.id_token,
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
},
}),
response: await this.handleResult(result),
refreshToken: privateInfo.refreshToken,
};
}
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
const { accessToken, params } = await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
async refresh(req: OAuthRefreshRequest) {
const { accessToken, refreshToken, params } =
await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
const profile = makeProfileInfo(fullProfile, params.id_token);
return this.populateIdentity({
providerInfo: {
return {
response: await this.handleResult({
fullProfile,
params,
accessToken,
idToken: params.id_token,
expiresInSeconds: params.expires_in,
scope: params.scope,
},
profile,
});
}),
refreshToken,
};
}
// Use this function to grab the user profile info from the token
// Then populate the profile with it
private async populateIdentity(
response: OAuthResponse,
): Promise<OAuthResponse> {
const { profile } = response;
private async handleResult(result: OAuthResult) {
const { profile } = await this.authHandler(result);
if (!profile.email) {
throw new Error('Profile does not contain an email');
const response: OAuthResponse = {
providerInfo: {
idToken: result.params.id_token,
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
},
profile,
};
if (this.signInResolver) {
response.backstageIdentity = await this.signInResolver(
{
result,
profile,
},
{
tokenIssuer: this.tokenIssuer,
catalogIdentityClient: this.catalogIdentityClient,
logger: this.logger,
},
);
}
const id = profile.email.split('@')[0];
return { ...response, backstageIdentity: { id, token: '' } };
return response;
}
}
export type Auth0ProviderOptions = {};
const defaultSignInResolver: SignInResolver<OAuthResult> = async info => {
const { profile } = info;
if (!profile.email) {
throw new Error('Profile does not contain an email');
}
const id = profile.email.split('@')[0];
return { id, token: '' };
};
/** @public */
export type Auth0ProviderOptions = {
/**
* The profile transformation function used to verify and convert the auth response
* into the profile that will be presented to the user.
*/
authHandler?: AuthHandler<OAuthResult>;
/**
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
*/
signIn?: {
/**
* Maps an auth result to a Backstage identity for the user.
*/
resolver: SignInResolver<OAuthResult>;
};
};
/** @public */
export const createAuth0Provider = (
_options?: Auth0ProviderOptions,
options?: Auth0ProviderOptions,
): AuthProviderFactory => {
return ({ providerId, globalConfig, config, tokenIssuer }) =>
return ({
providerId,
globalConfig,
config,
tokenIssuer,
catalogApi,
logger,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const domain = envConfig.getString('domain');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const catalogIdentityClient = new CatalogIdentityClient({
catalogApi,
tokenIssuer,
});
const authHandler: AuthHandler<OAuthResult> = options?.authHandler
? options.authHandler
: async ({ fullProfile, params }) => ({
profile: makeProfileInfo(fullProfile, params.id_token),
});
const signInResolver = options?.signIn?.resolver ?? defaultSignInResolver;
const provider = new Auth0AuthProvider({
clientId,
clientSecret,
callbackUrl,
domain,
authHandler,
signInResolver,
tokenIssuer,
catalogIdentityClient,
logger,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
@@ -138,9 +138,7 @@ export class BitbucketAuthProvider implements OAuthHandlers {
});
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken: string }> {
async handler(req: express.Request) {
const { result, privateInfo } = await executeFrameHandlerStrategy<
OAuthResult,
PrivateInfo
@@ -152,22 +150,25 @@ export class BitbucketAuthProvider implements OAuthHandlers {
};
}
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
const { accessToken, params } = await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
async refresh(req: OAuthRefreshRequest) {
const { accessToken, refreshToken, params } =
await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
return this.handleResult({
fullProfile,
params,
accessToken,
refreshToken: req.refreshToken,
});
return {
response: await this.handleResult({
fullProfile,
params,
accessToken,
}),
refreshToken,
};
}
private async handleResult(result: BitbucketOAuthResult) {
@@ -316,24 +316,26 @@ describe('GithubAuthProvider', () => {
],
});
const response = await provider.refresh({} as any);
const result = await provider.refresh({} as any);
expect(response).toEqual({
backstageIdentity: {
id: 'mockuser',
token: 'token-for-mockuser',
},
profile: {
displayName: 'Mocked User',
email: 'mockuser@gmail.com',
picture: undefined,
},
providerInfo: {
accessToken: 'a.b.c',
refreshToken: 'dont-forget-to-send-refresh',
expiresInSeconds: 123,
scope: 'read_user',
expect(result).toEqual({
response: {
backstageIdentity: {
id: 'mockuser',
token: 'token-for-mockuser',
},
profile: {
displayName: 'Mocked User',
email: 'mockuser@gmail.com',
picture: undefined,
},
providerInfo: {
accessToken: 'a.b.c',
expiresInSeconds: 123,
scope: 'read_user',
},
},
refreshToken: 'dont-forget-to-send-refresh',
});
});
});
@@ -129,26 +129,26 @@ export class GithubAuthProvider implements OAuthHandlers {
};
}
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
const {
accessToken,
refreshToken: newRefreshToken,
params,
} = await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
async refresh(req: OAuthRefreshRequest) {
const { accessToken, refreshToken, params } =
await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
return this.handleResult({
fullProfile,
params,
accessToken,
refreshToken: newRefreshToken,
});
return {
response: await this.handleResult({
fullProfile,
params,
accessToken,
}),
refreshToken,
};
}
private async handleResult(result: GithubOAuthResult) {
@@ -158,7 +158,6 @@ export class GithubAuthProvider implements OAuthHandlers {
const response: OAuthResponse = {
providerInfo: {
accessToken: result.accessToken,
refreshToken: result.refreshToken, // GitHub expires the old refresh token when used
scope: result.params.scope,
expiresInSeconds:
expiresInStr === undefined ? undefined : Number(expiresInStr),
@@ -184,23 +184,25 @@ describe('GitlabAuthProvider', () => {
],
});
const response = await provider.refresh({} as any);
const result = await provider.refresh({} as any);
expect(response).toEqual({
backstageIdentity: {
id: 'mockuser',
},
profile: {
displayName: 'Mocked User',
email: 'mockuser@gmail.com',
picture: 'http://gitlab.com/lols',
},
providerInfo: {
accessToken: 'a.b.c',
idToken: 'my-id',
refreshToken: 'dont-forget-to-send-refresh',
scope: 'read_user',
expect(result).toEqual({
response: {
backstageIdentity: {
id: 'mockuser',
},
profile: {
displayName: 'Mocked User',
email: 'mockuser@gmail.com',
picture: 'http://gitlab.com/lols',
},
providerInfo: {
accessToken: 'a.b.c',
idToken: 'my-id',
scope: 'read_user',
},
},
refreshToken: 'dont-forget-to-send-refresh',
});
});
});
@@ -132,9 +132,7 @@ export class GitlabAuthProvider implements OAuthHandlers {
});
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken: string }> {
async handler(req: express.Request) {
const { result, privateInfo } = await executeFrameHandlerStrategy<
OAuthResult,
PrivateInfo
@@ -146,28 +144,26 @@ export class GitlabAuthProvider implements OAuthHandlers {
};
}
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
const {
accessToken,
refreshToken: newRefreshToken,
params,
} = await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
async refresh(req: OAuthRefreshRequest) {
const { accessToken, refreshToken, params } =
await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
return this.handleResult({
fullProfile,
params,
accessToken,
refreshToken: newRefreshToken,
});
return {
response: await this.handleResult({
fullProfile,
params,
accessToken,
}),
refreshToken,
};
}
private async handleResult(result: OAuthResult): Promise<OAuthResponse> {
@@ -177,7 +173,6 @@ export class GitlabAuthProvider implements OAuthHandlers {
providerInfo: {
idToken: result.params.id_token,
accessToken: result.accessToken,
refreshToken: result.refreshToken, // GitLab expires the old refresh token when used
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
},
@@ -113,9 +113,7 @@ export class GoogleAuthProvider implements OAuthHandlers {
});
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken: string }> {
async handler(req: express.Request) {
const { result, privateInfo } = await executeFrameHandlerStrategy<
OAuthResult,
PrivateInfo
@@ -127,22 +125,26 @@ export class GoogleAuthProvider implements OAuthHandlers {
};
}
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
const { accessToken, params } = await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
async refresh(req: OAuthRefreshRequest) {
const { accessToken, refreshToken, params } =
await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
return this.handleResult({
fullProfile,
params,
accessToken,
refreshToken: req.refreshToken,
});
return {
response: await this.handleResult({
fullProfile,
params,
accessToken,
}),
refreshToken,
};
}
private async handleResult(result: OAuthResult) {
+5 -3
View File
@@ -14,6 +14,10 @@
* limitations under the License.
*/
export * from './atlassian';
export * from './auth0';
export * from './aws-alb';
export * from './bitbucket';
export * from './github';
export * from './gitlab';
export * from './google';
@@ -21,9 +25,7 @@ export * from './microsoft';
export * from './oauth2';
export * from './oidc';
export * from './okta';
export * from './bitbucket';
export * from './atlassian';
export * from './aws-alb';
export * from './onelogin';
export * from './saml';
export { factories as defaultAuthProviderFactories } from './factories';
@@ -104,9 +104,7 @@ export class MicrosoftAuthProvider implements OAuthHandlers {
});
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken: string }> {
async handler(req: express.Request) {
const { result, privateInfo } = await executeFrameHandlerStrategy<
OAuthResult,
PrivateInfo
@@ -118,24 +116,27 @@ export class MicrosoftAuthProvider implements OAuthHandlers {
};
}
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
const { accessToken, params } = await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
async refresh(req: OAuthRefreshRequest) {
const { accessToken, refreshToken, params } =
await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
return this.handleResult({
fullProfile,
params,
accessToken,
refreshToken: req.refreshToken,
});
return {
response: await this.handleResult({
fullProfile,
params,
accessToken,
}),
refreshToken,
};
}
private async handleResult(result: OAuthResult) {
@@ -127,9 +127,7 @@ export class OAuth2AuthProvider implements OAuthHandlers {
});
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken: string }> {
async handler(req: express.Request) {
const { result, privateInfo } = await executeFrameHandlerStrategy<
OAuthResult,
PrivateInfo
@@ -141,29 +139,27 @@ export class OAuth2AuthProvider implements OAuthHandlers {
};
}
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
async refresh(req: OAuthRefreshRequest) {
const refreshTokenResponse = await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
const {
accessToken,
params,
refreshToken: updatedRefreshToken,
} = refreshTokenResponse;
const { accessToken, params, refreshToken } = refreshTokenResponse;
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
return this.handleResult({
fullProfile,
params,
accessToken,
refreshToken: updatedRefreshToken,
});
return {
response: await this.handleResult({
fullProfile,
params,
accessToken,
}),
refreshToken,
};
}
private async handleResult(result: OAuthResult) {
@@ -175,7 +171,6 @@ export class OAuth2AuthProvider implements OAuthHandlers {
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
refreshToken: result.refreshToken,
},
profile,
};
@@ -112,34 +112,31 @@ export class OidcAuthProvider implements OAuthHandlers {
return await executeRedirectStrategy(req, strategy, options);
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken?: string }> {
async handler(req: express.Request) {
const { strategy } = await this.implementation;
const strategyResponse = await executeFrameHandlerStrategy<
const { result, privateInfo } = await executeFrameHandlerStrategy<
OidcAuthResult,
PrivateInfo
>(req, strategy);
const {
result: { userinfo, tokenset },
privateInfo,
} = strategyResponse;
const identityResponse = await this.handleResult({ tokenset, userinfo });
return {
response: identityResponse,
response: await this.handleResult(result),
refreshToken: privateInfo.refreshToken,
};
}
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
async refresh(req: OAuthRefreshRequest) {
const { client } = await this.implementation;
const tokenset = await client.refresh(req.refreshToken);
if (!tokenset.access_token) {
throw new Error('Refresh failed');
}
const profile = await client.userinfo(tokenset.access_token);
return this.handleResult({ tokenset, userinfo: profile });
const userinfo = await client.userinfo(tokenset.access_token);
return {
response: await this.handleResult({ tokenset, userinfo }),
refreshToken: tokenset.refresh_token,
};
}
private async setupStrategy(options: Options): Promise<OidcImpl> {
@@ -190,7 +187,6 @@ export class OidcAuthProvider implements OAuthHandlers {
providerInfo: {
idToken: result.tokenset.id_token,
accessToken: result.tokenset.access_token!,
refreshToken: result.tokenset.refresh_token,
scope: result.tokenset.scope!,
expiresInSeconds: result.tokenset.expires_in,
},
@@ -133,9 +133,7 @@ export class OktaAuthProvider implements OAuthHandlers {
});
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken: string }> {
async handler(req: express.Request) {
const { result, privateInfo } = await executeFrameHandlerStrategy<
OAuthResult,
PrivateInfo
@@ -147,7 +145,7 @@ export class OktaAuthProvider implements OAuthHandlers {
};
}
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
async refresh(req: OAuthRefreshRequest) {
const { accessToken, refreshToken, params } =
await executeRefreshTokenStrategy(
this._strategy,
@@ -160,12 +158,14 @@ export class OktaAuthProvider implements OAuthHandlers {
accessToken,
);
return this.handleResult({
fullProfile,
params,
accessToken,
return {
response: await this.handleResult({
fullProfile,
params,
accessToken,
}),
refreshToken,
});
};
}
private async handleResult(result: OAuthResult) {
@@ -177,7 +177,6 @@ export class OktaAuthProvider implements OAuthHandlers {
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
refreshToken: result.refreshToken,
},
profile,
};
@@ -36,7 +36,15 @@ import {
executeFetchUserProfileStrategy,
PassportDoneCallback,
} from '../../lib/passport';
import { RedirectInfo, AuthProviderFactory } from '../types';
import {
RedirectInfo,
AuthProviderFactory,
AuthHandler,
SignInResolver,
} from '../types';
import { CatalogIdentityClient } from '../../lib/catalog';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
type PrivateInfo = {
refreshToken: string;
@@ -44,12 +52,27 @@ type PrivateInfo = {
export type Options = OAuthProviderOptions & {
issuer: string;
signInResolver?: SignInResolver<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
};
export class OneLoginProvider implements OAuthHandlers {
private readonly _strategy: any;
private readonly signInResolver?: SignInResolver<OAuthResult>;
private readonly authHandler: AuthHandler<OAuthResult>;
private readonly tokenIssuer: TokenIssuer;
private readonly catalogIdentityClient: CatalogIdentityClient;
private readonly logger: Logger;
constructor(options: Options) {
this.signInResolver = options.signInResolver;
this.authHandler = options.authHandler;
this.tokenIssuer = options.tokenIssuer;
this.catalogIdentityClient = options.catalogIdentityClient;
this.logger = options.logger;
this._strategy = new OneLoginStrategy(
{
issuer: options.issuer,
@@ -89,86 +112,144 @@ export class OneLoginProvider implements OAuthHandlers {
});
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken: string }> {
async handler(req: express.Request) {
const { result, privateInfo } = await executeFrameHandlerStrategy<
OAuthResult,
PrivateInfo
>(req, this._strategy);
const profile = makeProfileInfo(result.fullProfile, result.params.id_token);
return {
response: await this.populateIdentity({
profile,
providerInfo: {
idToken: result.params.id_token,
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
},
}),
response: await this.handleResult(result),
refreshToken: privateInfo.refreshToken,
};
}
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
const { accessToken, params } = await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
async refresh(req: OAuthRefreshRequest) {
const { accessToken, refreshToken, params } =
await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
const profile = makeProfileInfo(fullProfile, params.id_token);
return this.populateIdentity({
providerInfo: {
return {
response: await this.handleResult({
fullProfile,
params,
accessToken,
idToken: params.id_token,
expiresInSeconds: params.expires_in,
scope: params.scope,
},
profile,
});
}),
refreshToken,
};
}
private async populateIdentity(
response: OAuthResponse,
): Promise<OAuthResponse> {
const { profile } = response;
private async handleResult(result: OAuthResult) {
const { profile } = await this.authHandler(result);
if (!profile.email) {
throw new Error('OIDC profile contained no email');
const response: OAuthResponse = {
providerInfo: {
idToken: result.params.id_token,
accessToken: result.accessToken,
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
},
profile,
};
if (this.signInResolver) {
response.backstageIdentity = await this.signInResolver(
{
result,
profile,
},
{
tokenIssuer: this.tokenIssuer,
catalogIdentityClient: this.catalogIdentityClient,
logger: this.logger,
},
);
}
const id = profile.email.split('@')[0];
return { ...response, backstageIdentity: { id, token: '' } };
return response;
}
}
export type OneLoginProviderOptions = {};
const defaultSignInResolver: SignInResolver<OAuthResult> = async info => {
const { profile } = info;
if (!profile.email) {
throw new Error('OIDC profile contained no email');
}
const id = profile.email.split('@')[0];
return { id, token: '' };
};
/** @public */
export type OneLoginProviderOptions = {
/**
* The profile transformation function used to verify and convert the auth response
* into the profile that will be presented to the user.
*/
authHandler?: AuthHandler<OAuthResult>;
/**
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
*/
signIn?: {
/**
* Maps an auth result to a Backstage identity for the user.
*/
resolver: SignInResolver<OAuthResult>;
};
};
/** @public */
export const createOneLoginProvider = (
_options?: OneLoginProviderOptions,
options?: OneLoginProviderOptions,
): AuthProviderFactory => {
return ({ providerId, globalConfig, config, tokenIssuer }) =>
return ({
providerId,
globalConfig,
config,
tokenIssuer,
catalogApi,
logger,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const issuer = envConfig.getString('issuer');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const catalogIdentityClient = new CatalogIdentityClient({
catalogApi,
tokenIssuer,
});
const authHandler: AuthHandler<OAuthResult> = options?.authHandler
? options.authHandler
: async ({ fullProfile, params }) => ({
profile: makeProfileInfo(fullProfile, params.id_token),
});
const signInResolver = options?.signIn?.resolver ?? defaultSignInResolver;
const provider = new OneLoginProvider({
clientId,
clientSecret,
callbackUrl,
issuer,
authHandler,
signInResolver,
tokenIssuer,
catalogIdentityClient,
logger,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
+23 -7
View File
@@ -213,16 +213,32 @@ export const SearchResult: ({
// @public (undocumented)
export const SearchResultPager: () => JSX.Element;
// Warning: (ae-forgotten-export) The symbol "SearchTypeProps" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "SearchType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const SearchType: ({
values,
className,
name,
defaultValue,
}: SearchTypeProps) => JSX.Element;
export const SearchType: {
(props: SearchTypeProps): JSX.Element;
Accordion(props: SearchTypeAccordionProps): JSX.Element;
};
// @public (undocumented)
export type SearchTypeAccordionProps = {
name: string;
types: Array<{
value: string;
name: string;
icon: JSX.Element;
}>;
defaultValue?: string;
};
// @public (undocumented)
export type SearchTypeProps = {
className?: string;
name: string;
values?: string[];
defaultValue?: string[] | string | null;
};
// Warning: (ae-missing-release-tag) "SidebarSearch" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -14,17 +14,28 @@
* limitations under the License.
*/
import React from 'react';
import { Button } from '@backstage/core-components';
import { Grid } from '@material-ui/core';
import FindInPageIcon from '@material-ui/icons/FindInPage';
import GroupIcon from '@material-ui/icons/Group';
import { Button } from '@backstage/core-components';
import { DefaultResultListItem } from '../index';
import React from 'react';
import { MemoryRouter } from 'react-router';
import { DefaultResultListItem } from './DefaultResultListItem';
export default {
title: 'Plugins/Search/DefaultResultListItem',
component: DefaultResultListItem,
decorators: [
(Story: () => JSX.Element) => (
<MemoryRouter>
<Grid container direction="row">
<Grid item xs={12}>
<Story />
</Grid>
</Grid>
</MemoryRouter>
),
],
};
const mockSearchResult = {
@@ -35,54 +46,34 @@ const mockSearchResult = {
};
export const Default = () => {
return (
<MemoryRouter>
<Grid container direction="row">
<Grid item xs={12}>
<DefaultResultListItem result={mockSearchResult} />
</Grid>
</Grid>
</MemoryRouter>
);
return <DefaultResultListItem result={mockSearchResult} />;
};
export const WithIcon = () => {
return (
<MemoryRouter>
<Grid container direction="row">
<Grid item xs={12}>
<DefaultResultListItem
result={mockSearchResult}
icon={<FindInPageIcon color="primary" />}
/>
</Grid>
</Grid>
</MemoryRouter>
<DefaultResultListItem
result={mockSearchResult}
icon={<FindInPageIcon color="primary" />}
/>
);
};
export const WithSecondaryAction = () => {
return (
<MemoryRouter>
<Grid container direction="row">
<Grid item xs={12}>
<DefaultResultListItem
result={mockSearchResult}
secondaryAction={
<Button
to="#"
size="small"
aria-label="owner"
variant="text"
startIcon={<GroupIcon />}
style={{ textTransform: 'lowercase' }}
>
{mockSearchResult.owner}
</Button>
}
/>
</Grid>
</Grid>
</MemoryRouter>
<DefaultResultListItem
result={mockSearchResult}
secondaryAction={
<Button
to="#"
size="small"
aria-label="owner"
variant="text"
startIcon={<GroupIcon />}
style={{ textTransform: 'lowercase' }}
>
{mockSearchResult.owner}
</Button>
}
/>
);
};
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Grid } from '@material-ui/core';
import React, { useEffect, useState } from 'react';
import { useDebounce } from 'react-use';
@@ -14,88 +14,58 @@
* limitations under the License.
*/
import React from 'react';
import { Paper, Grid, makeStyles } from '@material-ui/core';
import { SearchBar, SearchContext } from '../index';
import { MemoryRouter } from 'react-router';
import { Grid, makeStyles, Paper } from '@material-ui/core';
import React, { ComponentType } from 'react';
import { SearchContextProvider } from '../SearchContext/SearchContextForStorybook.stories';
import { SearchBar } from './SearchBar';
export default {
title: 'Plugins/Search/SearchBar',
component: SearchBar,
};
const defaultValue = {
term: '',
setTerm: () => {},
decorators: [
(Story: ComponentType<{}>) => (
<SearchContextProvider>
<Grid container direction="row">
<Grid item xs={12}>
<Story />
</Grid>
</Grid>
</SearchContextProvider>
),
],
};
export const Default = () => {
return (
<MemoryRouter>
{/* @ts-ignore (defaultValue requires more than what is used here) */}
<SearchContext.Provider value={defaultValue}>
<Grid container direction="row">
<Grid item xs={12}>
<Paper style={{ padding: '8px 0' }}>
<SearchBar />
</Paper>
</Grid>
</Grid>
</SearchContext.Provider>
</MemoryRouter>
<Paper style={{ padding: '8px 0' }}>
<SearchBar />
</Paper>
);
};
export const CustomPlaceholder = () => {
return (
<MemoryRouter>
{/* @ts-ignore (defaultValue requires more than what is used here) */}
<SearchContext.Provider value={defaultValue}>
<Grid container direction="row">
<Grid item xs={12}>
<Paper style={{ padding: '8px 0' }}>
<SearchBar placeholder="This is a custom placeholder" />
</Paper>
</Grid>
</Grid>
</SearchContext.Provider>
</MemoryRouter>
<Paper style={{ padding: '8px 0' }}>
<SearchBar placeholder="This is a custom placeholder" />
</Paper>
);
};
export const Focused = () => {
return (
<MemoryRouter>
{/* @ts-ignore (defaultValue requires more than what is used here) */}
<SearchContext.Provider value={defaultValue}>
<Grid container direction="row">
<Grid item xs={12}>
<Paper style={{ padding: '8px 0' }}>
{/* decision up to adopter, read https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/master/docs/rules/no-autofocus.md#no-autofocus */}
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<SearchBar autoFocus />
</Paper>
</Grid>
</Grid>
</SearchContext.Provider>
</MemoryRouter>
<Paper style={{ padding: '8px 0' }}>
{/* decision up to adopter, read https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/master/docs/rules/no-autofocus.md#no-autofocus */}
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<SearchBar autoFocus />
</Paper>
);
};
export const WithoutClearButton = () => {
return (
<MemoryRouter>
{/* @ts-ignore (defaultValue requires more than what is used here) */}
<SearchContext.Provider value={defaultValue}>
<Grid container direction="row">
<Grid item xs={12}>
<Paper style={{ padding: '8px 0' }}>
<SearchBar clearButton={false} />
</Paper>
</Grid>
</Grid>
</SearchContext.Provider>
</MemoryRouter>
<Paper style={{ padding: '8px 0' }}>
<SearchBar clearButton={false} />
</Paper>
);
};
@@ -112,17 +82,8 @@ const useStyles = makeStyles({
export const CustomStyles = () => {
const classes = useStyles();
return (
<MemoryRouter>
{/* @ts-ignore (defaultValue requires more than what is used here) */}
<SearchContext.Provider value={defaultValue}>
<Grid container direction="row">
<Grid item xs={12}>
<Paper className={classes.search}>
<SearchBar />
</Paper>
</Grid>
</Grid>
</SearchContext.Provider>
</MemoryRouter>
<Paper className={classes.search}>
<SearchBar />
</Paper>
);
};
@@ -20,10 +20,10 @@ import userEvent from '@testing-library/user-event';
import { SearchContextProvider } from '../SearchContext';
import { SearchBar } from './SearchBar';
import { configApiRef } from '@backstage/core-plugin-api';
import { configApiRef, analyticsApiRef } from '@backstage/core-plugin-api';
import { ApiProvider, ConfigReader } from '@backstage/core-app-api';
import { searchApiRef } from '../../apis';
import { TestApiRegistry } from '@backstage/test-utils';
import { MockAnalyticsApi, TestApiRegistry } from '@backstage/test-utils';
jest.mock('@backstage/core-plugin-api', () => ({
...jest.requireActual('@backstage/core-plugin-api'),
@@ -38,9 +38,16 @@ describe('SearchBar', () => {
};
const query = jest.fn().mockResolvedValue({});
const analyticsApiSpy = new MockAnalyticsApi();
let apiRegistry: TestApiRegistry;
const apiRegistry = TestApiRegistry.from(
[configApiRef, new ConfigReader({ app: { title: 'Mock title' } })],
apiRegistry = TestApiRegistry.from(
[
configApiRef,
new ConfigReader({
app: { title: 'Mock title' },
}),
],
[searchApiRef, { query }],
);
@@ -210,4 +217,128 @@ describe('SearchBar', () => {
expect.objectContaining({ term: value }),
);
});
it('does not capture analytics event if not enabled in app', async () => {
jest.useFakeTimers();
const debounceTime = 600;
render(
<ApiProvider apis={apiRegistry}>
<SearchContextProvider initialState={initialState}>
<SearchBar debounceTime={debounceTime} />
</SearchContextProvider>
,
</ApiProvider>,
);
await waitFor(() => {
expect(screen.getByRole('textbox', { name })).toBeInTheDocument();
});
const textbox = screen.getByRole('textbox', { name });
const value = 'value';
userEvent.type(textbox, value);
act(() => {
jest.advanceTimersByTime(debounceTime);
});
await waitFor(() => expect(textbox).toHaveValue(value));
expect(analyticsApiSpy.getEvents()).toHaveLength(0);
});
it('captures analytics events if enabled in app', async () => {
jest.useFakeTimers();
const debounceTime = 600;
apiRegistry = TestApiRegistry.from(
[analyticsApiRef, analyticsApiSpy],
[
configApiRef,
new ConfigReader({
app: {
title: 'Mock title',
analytics: {
ga: {
trackingId: 'xyz123',
},
},
},
}),
],
[searchApiRef, { query }],
);
render(
<ApiProvider apis={apiRegistry}>
<SearchContextProvider
initialState={{
term: '',
types: ['techdocs', 'software-catalog'],
filters: {},
}}
>
<SearchBar debounceTime={debounceTime} />
</SearchContextProvider>
</ApiProvider>,
);
await waitFor(() => {
expect(screen.getByRole('textbox', { name })).toBeInTheDocument();
});
const textbox = screen.getByRole('textbox', { name });
const value = 'value';
userEvent.type(textbox, value);
expect(analyticsApiSpy.getEvents()).toHaveLength(0);
act(() => {
jest.advanceTimersByTime(debounceTime);
});
await waitFor(() => expect(textbox).toHaveValue(value));
expect(analyticsApiSpy.getEvents()).toHaveLength(1);
expect(analyticsApiSpy.getEvents()[0]).toEqual({
action: 'search',
context: {
extension: 'App',
pluginId: 'root',
routeRef: 'unknown',
searchTypes: 'software-catalog,techdocs',
},
subject: 'value',
});
userEvent.clear(textbox);
// make sure new term is captured
userEvent.type(textbox, 'new value');
act(() => {
jest.advanceTimersByTime(debounceTime);
});
await waitFor(() => expect(textbox).toHaveValue('new value'));
expect(analyticsApiSpy.getEvents()).toHaveLength(2);
expect(analyticsApiSpy.getEvents()[1]).toEqual({
action: 'search',
context: {
extension: 'App',
pluginId: 'root',
routeRef: 'unknown',
searchTypes: 'software-catalog,techdocs',
},
subject: 'new value',
});
});
});
@@ -33,6 +33,7 @@ import SearchIcon from '@material-ui/icons/Search';
import ClearButton from '@material-ui/icons/Clear';
import { useSearch } from '../SearchContext';
import { TrackSearch } from '../SearchTracker';
/**
* Props for {@link SearchBarBase}.
@@ -119,18 +120,20 @@ export const SearchBarBase = ({
);
return (
<InputBase
data-testid="search-bar-next"
value={value}
placeholder={placeholder}
startAdornment={startAdornment}
endAdornment={clearButton ? endAdornment : defaultEndAdornment}
inputProps={{ 'aria-label': 'Search', ...defaultInputProps }}
fullWidth={fullWidth}
onChange={handleChange}
onKeyDown={handleKeyDown}
{...props}
/>
<TrackSearch>
<InputBase
data-testid="search-bar-next"
value={value}
placeholder={placeholder}
startAdornment={startAdornment}
endAdornment={clearButton ? endAdornment : defaultEndAdornment}
inputProps={{ 'aria-label': 'Search', ...defaultInputProps }}
fullWidth={fullWidth}
onChange={handleChange}
onKeyDown={handleKeyDown}
{...props}
/>
</TrackSearch>
);
};
@@ -150,8 +153,11 @@ export const SearchBar = ({ onChange, ...props }: SearchBarProps) => {
const { term, setTerm } = useSearch();
const handleChange = (newValue: string) => {
setTerm(newValue);
if (onChange) onChange(newValue);
if (onChange) {
onChange(newValue);
} else {
setTerm(newValue);
}
};
return <SearchBarBase value={term} onChange={handleChange} {...props} />;
@@ -15,7 +15,7 @@
*/
import { JsonObject } from '@backstage/types';
import { useApi } from '@backstage/core-plugin-api';
import { useApi, AnalyticsContext } from '@backstage/core-plugin-api';
import { SearchResultSet } from '@backstage/search-common';
import React, {
createContext,
@@ -130,7 +130,11 @@ export const SearchContextProvider = ({
fetchPreviousPage: hasPreviousPage ? fetchPreviousPage : undefined,
};
return <SearchContext.Provider value={value} children={children} />;
return (
<AnalyticsContext attributes={{ searchTypes: types.sort().join(',') }}>
<SearchContext.Provider value={value} children={children} />
</AnalyticsContext>
);
};
export const useSearch = () => {
@@ -0,0 +1,45 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 { ApiProvider } from '@backstage/core-app-api';
import { SearchResultSet } from '@backstage/search-common';
import { TestApiRegistry } from '@backstage/test-utils';
import React, { ComponentProps } from 'react';
import { searchApiRef } from '../../apis';
import { SearchContextProvider as RealSearchContextProvider } from './SearchContext';
type QueryResultProps = {
mockedResults?: SearchResultSet;
};
/**
* Utility context provider only for use in Storybook stories. You should use
* the real `<SearchContextProvider>` exported by `@backstage/plugin-search` in
* your app instead of this! In some cases (like the search page) it may
* already be provided on your behalf.
*/
export const SearchContextProvider = (
props: ComponentProps<typeof RealSearchContextProvider> & QueryResultProps,
) => {
const { mockedResults, ...contextProps } = props;
const query: any = () => Promise.resolve(mockedResults || {});
const apiRegistry = TestApiRegistry.from([searchApiRef, { query }]);
return (
<ApiProvider apis={apiRegistry}>
<RealSearchContextProvider {...contextProps} />
</ApiProvider>
);
};
@@ -14,56 +14,45 @@
* limitations under the License.
*/
import React from 'react';
import { Grid, Paper } from '@material-ui/core';
import { SearchFilter, SearchContext } from '../index';
import { MemoryRouter } from 'react-router';
import React, { ComponentType } from 'react';
import { SearchContextProvider } from '../SearchContext/SearchContextForStorybook.stories';
import { SearchFilter } from './SearchFilter';
export default {
title: 'Plugins/Search/SearchFilter',
component: SearchFilter,
};
const defaultValue = {
filters: {},
decorators: [
(Story: ComponentType<{}>) => (
<SearchContextProvider>
<Grid container direction="row">
<Grid item xs={4}>
<Story />
</Grid>
</Grid>
</SearchContextProvider>
),
],
};
export const CheckBoxFilter = () => {
return (
<MemoryRouter>
{/* @ts-ignore (defaultValue requires more than what is used here) */}
<SearchContext.Provider value={defaultValue}>
<Grid container direction="row">
<Grid item xs={4}>
<Paper style={{ padding: 10 }}>
<SearchFilter.Checkbox
name="Search Checkbox Filter"
values={['value1', 'value2']}
/>
</Paper>
</Grid>
</Grid>
</SearchContext.Provider>
</MemoryRouter>
<Paper style={{ padding: 10 }}>
<SearchFilter.Checkbox
name="Search Checkbox Filter"
values={['value1', 'value2']}
/>
</Paper>
);
};
export const SelectFilter = () => {
return (
<MemoryRouter>
{/* @ts-ignore (defaultValue requires more than what is used here) */}
<SearchContext.Provider value={defaultValue}>
<Grid container direction="row">
<Grid item xs={4}>
<Paper style={{ padding: 10 }}>
<SearchFilter.Select
name="Search Select Filter"
values={['value1', 'value2']}
/>
</Paper>
</Grid>
</Grid>
</SearchContext.Provider>
</MemoryRouter>
<Paper style={{ padding: 10 }}>
<SearchFilter.Select
name="Search Select Filter"
values={['value1', 'value2']}
/>
</Paper>
);
};
@@ -14,62 +14,52 @@
* limitations under the License.
*/
import React, { ComponentType } from 'react';
import { Button } from '@material-ui/core';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { wrapInTestApp } from '@backstage/test-utils';
import { SearchModal } from '../index';
import { useSearch, SearchContextProvider } from '../SearchContext';
import { searchApiRef } from '../../apis';
import { Button } from '@material-ui/core';
import React, { ComponentType } from 'react';
import { rootRouteRef } from '../../plugin';
import { useSearch } from '../SearchContext';
import { SearchContextProvider } from '../SearchContext/SearchContextForStorybook.stories';
import { SearchModal } from './SearchModal';
const mockSearchApi = {
query: () =>
Promise.resolve({
results: [
{
type: 'custom-result-item',
document: {
location: 'search/search-result-1',
title: 'Search Result 1',
text: 'some text from the search result',
},
},
{
type: 'no-custom-result-item',
document: {
location: 'search/search-result-2',
title: 'Search Result 2',
text: 'some text from the search result',
},
},
{
type: 'no-custom-result-item',
document: {
location: 'search/search-result-3',
title: 'Search Result 3',
text: 'some text from the search result',
},
},
],
}),
const mockResults = {
results: [
{
type: 'custom-result-item',
document: {
location: 'search/search-result-1',
title: 'Search Result 1',
text: 'some text from the search result',
},
},
{
type: 'no-custom-result-item',
document: {
location: 'search/search-result-2',
title: 'Search Result 2',
text: 'some text from the search result',
},
},
{
type: 'no-custom-result-item',
document: {
location: 'search/search-result-3',
title: 'Search Result 3',
text: 'some text from the search result',
},
},
],
};
const apiRegistry = () => ApiRegistry.from([[searchApiRef, mockSearchApi]]);
export default {
title: 'Plugins/Search/SearchModal',
component: SearchModal,
decorators: [
(Story: ComponentType<{}>) =>
wrapInTestApp(
<>
<ApiProvider apis={apiRegistry()}>
<SearchContextProvider>
<Story />
</SearchContextProvider>
</ApiProvider>
</>,
<SearchContextProvider mockedResults={mockResults}>
<Story />
</SearchContextProvider>,
{ mountedRoutes: { '/search': rootRouteRef } },
),
],
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { screen } from '@testing-library/react';
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import {
Dialog,
@@ -18,7 +18,7 @@ import { renderInTestApp } from '@backstage/test-utils';
import React from 'react';
import { useLocation, useOutlet } from 'react-router';
import { useSearch } from '../SearchContext';
import { SearchPage } from './';
import { SearchPage } from './SearchPage';
jest.mock('react-router', () => ({
...jest.requireActual('react-router'),
@@ -14,83 +14,83 @@
* limitations under the License.
*/
import React from 'react';
import { List, ListItem } from '@material-ui/core';
import { SearchResult, SearchContext, DefaultResultListItem } from '../index';
import { MemoryRouter } from 'react-router';
import { Link } from '@backstage/core-components';
import { List, ListItem } from '@material-ui/core';
import React, { ComponentType } from 'react';
import { MemoryRouter } from 'react-router';
import { DefaultResultListItem } from '../DefaultResultListItem';
import { SearchContextProvider } from '../SearchContext/SearchContextForStorybook.stories';
import { SearchResult } from './SearchResult';
const mockResults = {
results: [
{
type: 'custom-result-item',
document: {
location: 'search/search-result-1',
title: 'Search Result 1',
text: 'some text from the search result',
},
},
{
type: 'no-custom-result-item',
document: {
location: 'search/search-result-2',
title: 'Search Result 2',
text: 'some text from the search result',
},
},
{
type: 'no-custom-result-item',
document: {
location: 'search/search-result-3',
title: 'Search Result 3',
text: 'some text from the search result',
},
},
],
};
export default {
title: 'Plugins/Search/SearchResult',
component: SearchResult,
};
const defaultValue = {
result: {
loading: false,
error: '',
value: {
results: [
{
type: 'custom-result-item',
document: {
location: 'search/search-result-1',
title: 'Search Result 1',
text: 'some text from the search result',
},
},
{
type: 'no-custom-result-item',
document: {
location: 'search/search-result-2',
title: 'Search Result 2',
text: 'some text from the search result',
},
},
{
type: 'no-custom-result-item',
document: {
location: 'search/search-result-3',
title: 'Search Result 3',
text: 'some text from the search result',
},
},
],
},
},
decorators: [
(Story: ComponentType<{}>) => (
<MemoryRouter>
<SearchContextProvider mockedResults={mockResults}>
<Story />
</SearchContextProvider>
</MemoryRouter>
),
],
};
export const Default = () => {
return (
<MemoryRouter>
{/* @ts-ignore (defaultValue requires more than what is used here) */}
<SearchContext.Provider value={defaultValue}>
<SearchResult>
{({ results }) => (
<List>
{results.map(({ type, document }) => {
switch (type) {
case 'custom-result-item':
return (
<DefaultResultListItem
key={document.location}
result={document}
/>
);
default:
return (
<ListItem>
<Link to={document.location}>
{document.title} - {document.text}
</Link>
</ListItem>
);
}
})}
</List>
)}
</SearchResult>
</SearchContext.Provider>
</MemoryRouter>
<SearchResult>
{({ results }) => (
<List>
{results.map(({ type, document }) => {
switch (type) {
case 'custom-result-item':
return (
<DefaultResultListItem
key={document.location}
result={document}
/>
);
default:
return (
<ListItem>
<Link to={document.location}>
{document.title} - {document.text}
</Link>
</ListItem>
);
}
})}
</List>
)}
</SearchResult>
);
};
@@ -0,0 +1,36 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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, { useEffect } from 'react';
import { useAnalytics } from '@backstage/core-plugin-api';
import { useSearch } from '../SearchContext';
/**
* Capture search event on term change.
*/
export const TrackSearch = ({ children }: { children: React.ReactChild }) => {
const analytics = useAnalytics();
const { term } = useSearch();
useEffect(() => {
if (term) {
// Capture analytics search event with search term provided as value
analytics.captureEvent('search', term);
}
}, [analytics, term]);
return <>{children}</>;
};
@@ -1,5 +1,5 @@
/*
* Copyright 2020 The Backstage Authors
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -13,17 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './DefaultResultListItem';
export * from './Filters';
export * from './SearchBar';
export * from './SearchContext';
export * from './SearchFilter';
export * from './SearchModal';
export * from './SearchPage';
export * from './SearchResult';
export * from './SearchResultPager';
export * from './SearchType';
export * from './SidebarSearch';
export * from './SidebarSearchModal';
export * from './HomePageComponent';
export { TrackSearch } from './SearchTracker';
@@ -0,0 +1,133 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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 from 'react';
import { ApiProvider } from '@backstage/core-app-api';
import { TestApiRegistry } from '@backstage/test-utils';
import { act, render } from '@testing-library/react';
import user from '@testing-library/user-event';
import { searchApiRef } from '../../apis';
import { SearchContext, SearchContextProvider } from '../SearchContext';
import { SearchType } from './SearchType';
describe('SearchType.Accordion', () => {
const query = jest.fn();
const mockApis = TestApiRegistry.from([searchApiRef, { query }]);
const contextSpy = {
result: { loading: false, value: { results: [] } },
term: '',
types: [],
filters: {},
toggleModal: jest.fn(),
setTerm: jest.fn(),
setTypes: jest.fn(),
setFilters: jest.fn(),
setPageCursor: jest.fn(),
};
const expectedLabel = 'Expected Label';
const expectedType = {
value: 'expected-type',
name: 'Expected Type',
icon: <></>,
};
beforeEach(() => {
query.mockResolvedValue({ results: [] });
});
afterEach(() => {
jest.resetAllMocks();
});
it('should render as expected', async () => {
const { getByText } = render(
<ApiProvider apis={mockApis}>
<SearchContextProvider>
<SearchType.Accordion name={expectedLabel} types={[expectedType]} />
</SearchContextProvider>
</ApiProvider>,
);
// The given label should be rendered.
expect(getByText(expectedLabel)).toBeInTheDocument();
// "Collapse" is visible by default (element is not collapsed)
expect(getByText('Collapse')).toBeInTheDocument();
// The default "all" type should be rendered.
expect(getByText('All')).toBeInTheDocument();
// The given type is also visible
expect(getByText(expectedType.name)).toBeInTheDocument();
await act(() => Promise.resolve());
});
it('should set entire types array when a type is selected', () => {
const { getByText } = render(
<SearchContext.Provider value={contextSpy}>
<SearchType.Accordion name={expectedLabel} types={[expectedType]} />
</SearchContext.Provider>,
);
user.click(getByText(expectedType.name));
expect(contextSpy.setTypes).toHaveBeenCalledWith([expectedType.value]);
});
it('should reset types array when all is selected', () => {
const { getByText } = render(
<SearchContext.Provider value={contextSpy}>
<SearchType.Accordion
name={expectedLabel}
defaultValue={expectedType.value}
types={[expectedType]}
/>
</SearchContext.Provider>,
);
user.click(getByText('All'));
expect(contextSpy.setTypes).toHaveBeenCalledWith([]);
});
it('should reset page cursor when a new type is selected', () => {
const { getByText } = render(
<SearchContext.Provider value={contextSpy}>
<SearchType.Accordion name={expectedLabel} types={[expectedType]} />
</SearchContext.Provider>,
);
user.click(getByText(expectedType.name));
expect(contextSpy.setPageCursor).toHaveBeenCalledWith(undefined);
});
it('should collapse when a new type is selected', () => {
const { getByText, queryByText } = render(
<SearchContext.Provider value={contextSpy}>
<SearchType.Accordion name={expectedLabel} types={[expectedType]} />
</SearchContext.Provider>,
);
user.click(getByText(expectedType.name));
expect(queryByText('Collapse')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,174 @@
/*
* Copyright 2021 The Backstage Authors
*
* 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, { cloneElement, Fragment, useEffect, useState } from 'react';
import { useSearch } from '../SearchContext';
import {
Accordion,
AccordionSummary,
AccordionDetails,
Card,
CardContent,
CardHeader,
Divider,
List,
ListItem,
ListItemIcon,
ListItemText,
makeStyles,
} from '@material-ui/core';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import AllIcon from '@material-ui/icons/FontDownload';
const useStyles = makeStyles(theme => ({
card: {
backgroundColor: 'rgba(0, 0, 0, .11)',
},
cardContent: {
paddingTop: theme.spacing(1),
},
icon: {
color: theme.palette.common.black,
},
list: {
width: '100%',
},
listItemIcon: {
width: '24px',
height: '24px',
},
accordion: {
backgroundColor: theme.palette.background.paper,
},
accordionSummary: {
minHeight: 'auto',
'&.Mui-expanded': {
minHeight: 'auto',
},
},
accordionSummaryContent: {
margin: theme.spacing(2, 0),
'&.Mui-expanded': {
margin: theme.spacing(2, 0),
},
},
accordionDetails: {
padding: theme.spacing(0, 0, 1),
},
}));
/**
* @public
*/
export type SearchTypeAccordionProps = {
name: string;
types: Array<{
value: string;
name: string;
icon: JSX.Element;
}>;
defaultValue?: string;
};
export const SearchTypeAccordion = (props: SearchTypeAccordionProps) => {
const classes = useStyles();
const { setPageCursor, setTypes, types } = useSearch();
const [expanded, setExpanded] = useState(true);
const { defaultValue, name, types: givenTypes } = props;
const toggleExpanded = () => setExpanded(prevState => !prevState);
const handleClick = (type: string) => {
return () => {
setTypes(type !== '' ? [type] : []);
setPageCursor(undefined);
setExpanded(false);
};
};
// Handle any provided defaultValue
useEffect(() => {
if (defaultValue) {
setTypes([defaultValue]);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const definedTypes = [
{
value: '',
name: 'All',
icon: <AllIcon />,
},
...givenTypes,
];
const selected = types[0] || '';
return (
<Card className={classes.card}>
<CardHeader title={name} titleTypographyProps={{ variant: 'overline' }} />
<CardContent className={classes.cardContent}>
<Accordion
className={classes.accordion}
expanded={expanded}
onChange={toggleExpanded}
>
<AccordionSummary
classes={{
root: classes.accordionSummary,
content: classes.accordionSummaryContent,
}}
expandIcon={<ExpandMoreIcon className={classes.icon} />}
IconButtonProps={{ size: 'small' }}
>
{expanded
? 'Collapse'
: definedTypes.filter(t => t.value === selected)[0]!.name}
</AccordionSummary>
<AccordionDetails classes={{ root: classes.accordionDetails }}>
<List
className={classes.list}
component="nav"
aria-label="filter by type"
disablePadding
dense
>
{definedTypes.map(type => (
<Fragment key={type.value}>
<Divider />
<ListItem
selected={
types[0] === type.value ||
(types.length === 0 && type.value === '')
}
onClick={handleClick(type.value)}
button
>
<ListItemIcon>
{cloneElement(type.icon, {
className: classes.listItemIcon,
})}
</ListItemIcon>
<ListItemText primary={type.name} />
</ListItem>
</Fragment>
))}
</List>
</AccordionDetails>
</Accordion>
</CardContent>
</Card>
);
};
@@ -13,24 +13,51 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState } from 'react';
import { SearchType } from '../index';
import { SearchContext } from '../SearchContext';
import { Grid, Paper } from '@material-ui/core';
import CatalogIcon from '@material-ui/icons/MenuBook';
import DocsIcon from '@material-ui/icons/Description';
import UsersGroupsIcon from '@material-ui/icons/Person';
import React, { ComponentType } from 'react';
import { SearchContextProvider } from '../SearchContext/SearchContextForStorybook.stories';
import { SearchType } from './SearchType';
export default {
title: 'Plugins/Search/SearchType',
component: SearchType,
decorators: [
(Story: ComponentType<{}>) => (
<SearchContextProvider>
<Grid container direction="row">
<Grid item xs={4}>
<Story />
</Grid>
</Grid>
</SearchContextProvider>
),
],
};
const values = ['value-1', 'value-2', 'value-3'];
export const Default = () => {
const [types, setTypes] = useState<string[]>([]);
return (
<SearchContext.Provider value={{ types, setTypes } as any}>
<Paper style={{ padding: 10 }}>
<SearchType name="Search type" values={values} defaultValue={values[0]} />
</SearchContext.Provider>
</Paper>
);
};
export const Accordion = () => {
return (
<SearchType.Accordion
name="Result Types"
defaultValue="value-1"
types={[
{ value: 'value-1', name: 'Value One', icon: <CatalogIcon /> },
{ value: 'value-2', name: 'Value Two', icon: <DocsIcon /> },
{ value: 'value-3', name: 'Value Three', icon: <UsersGroupsIcon /> },
]}
/>
);
};
@@ -25,6 +25,10 @@ import {
} from '@material-ui/core';
import React, { ChangeEvent } from 'react';
import { useEffectOnce } from 'react-use';
import {
SearchTypeAccordion,
SearchTypeAccordionProps,
} from './SearchType.Accordion';
import { useSearch } from '../SearchContext';
const useStyles = makeStyles(theme => ({
@@ -41,6 +45,9 @@ const useStyles = makeStyles(theme => ({
},
}));
/**
* @public
*/
export type SearchTypeProps = {
className?: string;
name: string;
@@ -48,12 +55,8 @@ export type SearchTypeProps = {
defaultValue?: string[] | string | null;
};
const SearchType = ({
values = [],
className,
name,
defaultValue,
}: SearchTypeProps) => {
const SearchType = (props: SearchTypeProps) => {
const { className, defaultValue, name, values = [] } = props;
const classes = useStyles();
const { types, setTypes } = useSearch();
@@ -112,4 +115,14 @@ const SearchType = ({
);
};
/**
* A control surface for the search query's "types" property, displayed as a
* single-select collapsible accordion suitable for use in faceted search UIs.
* @public
*/
SearchType.Accordion = (props: SearchTypeAccordionProps) => {
return <SearchTypeAccordion {...props} />;
};
export { SearchType };
export type { SearchTypeAccordionProps };
@@ -15,3 +15,4 @@
*/
export { SearchType } from './SearchType';
export type { SearchTypeAccordionProps, SearchTypeProps } from './SearchType';
+22 -22
View File
@@ -22,30 +22,30 @@
export { searchApiRef } from './apis';
export type { SearchApi } from './apis';
export {
Filters,
FiltersButton,
SearchBar,
SearchBarBase,
SearchContextProvider,
SearchFilter,
SearchFilterNext,
SearchModal,
SearchPage as Router,
SearchResultPager,
SearchType,
SidebarSearch,
useSearch,
} from './components';
export { Filters, FiltersButton } from './components/Filters';
export type { FiltersState } from './components/Filters';
export type { HomePageSearchBarProps } from './components/HomePageComponent';
export { SearchBar, SearchBarBase } from './components/SearchBar';
export type {
SearchModalProps,
SidebarSearchModalProps,
HomePageSearchBarProps,
SidebarSearchProps,
FiltersState,
SearchBarProps,
SearchBarBaseProps,
} from './components';
SearchBarProps,
} from './components/SearchBar';
export { SearchContextProvider, useSearch } from './components/SearchContext';
export { SearchFilter, SearchFilterNext } from './components/SearchFilter';
export { SearchModal } from './components/SearchModal';
export type { SearchModalProps } from './components/SearchModal';
export { SearchPage as Router } from './components/SearchPage';
export { SearchResultPager } from './components/SearchResultPager';
export { SearchType } from './components/SearchType';
export type {
SearchTypeAccordionProps,
SearchTypeProps,
} from './components/SearchType';
export { SidebarSearch } from './components/SidebarSearch';
export type { SidebarSearchProps } from './components/SidebarSearch';
export type { SidebarSearchModalProps } from './components/SidebarSearchModal';
export {
DefaultResultListItem,
HomePageSearchBar,
+188 -36
View File
@@ -5814,22 +5814,22 @@
integrity sha512-64WWqE40U/WwWV8iIQBseTU+b2t+SdJSyQoCLdVPCKM9uf7KOjRivVwXe4KlWoV3y7duNSGuB2UgWhkXzscVmQ==
"@storybook/addon-a11y@^6.3.4":
version "6.3.12"
resolved "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-6.3.12.tgz#2f930fc84fc275a4ed43a716fc09cc12caf4e110"
integrity sha512-q1NdRHFJV6sLEEJw0hatCc5ZIthELqM/AWdrEWDyhcJNyiq7Tq4nKqQBMTQSYwHiUAmxVgw7i4oa1vM2M51/3g==
version "6.4.9"
resolved "https://registry.npmjs.org/@storybook/addon-a11y/-/addon-a11y-6.4.9.tgz#95cd51ad7a71e4c0a39259df2eb10c382bf324db"
integrity sha512-9LwFprh7A3KWmQRTqnyh/wvQ1SX/BewzhBPWqJzq0fGnx9fG35zt0VFtXV13i157wTRDqSO1g92Dgxw9l3S8/A==
dependencies:
"@storybook/addons" "6.3.12"
"@storybook/api" "6.3.12"
"@storybook/channels" "6.3.12"
"@storybook/client-api" "6.3.12"
"@storybook/client-logger" "6.3.12"
"@storybook/components" "6.3.12"
"@storybook/core-events" "6.3.12"
"@storybook/theming" "6.3.12"
"@storybook/addons" "6.4.9"
"@storybook/api" "6.4.9"
"@storybook/channels" "6.4.9"
"@storybook/client-logger" "6.4.9"
"@storybook/components" "6.4.9"
"@storybook/core-events" "6.4.9"
"@storybook/csf" "0.0.2--canary.87bc651.0"
"@storybook/theming" "6.4.9"
axe-core "^4.2.0"
core-js "^3.8.2"
global "^4.4.0"
lodash "^4.17.20"
lodash "^4.17.21"
react-sizeme "^3.0.1"
regenerator-runtime "^0.13.7"
ts-dedent "^2.0.0"
@@ -5911,7 +5911,7 @@
global "^4.4.0"
regenerator-runtime "^0.13.7"
"@storybook/addons@6.3.12", "@storybook/addons@^6.1.11":
"@storybook/addons@6.3.12":
version "6.3.12"
resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.3.12.tgz#8773dcc113c5086dfff722388b7b65580e43b65b"
integrity sha512-UgoMyr7Qr0FS3ezt8u6hMEcHgyynQS9ucr5mAwZky3wpXRPFyUTmMto9r4BBUdqyUvTUj/LRKIcmLBfj+/l0Fg==
@@ -5941,6 +5941,23 @@
global "^4.4.0"
regenerator-runtime "^0.13.7"
"@storybook/addons@6.4.9", "@storybook/addons@^6.1.11":
version "6.4.9"
resolved "https://registry.npmjs.org/@storybook/addons/-/addons-6.4.9.tgz#43b5dabf6781d863fcec0a0b293c236b4d5d4433"
integrity sha512-y+oiN2zd+pbRWwkf6aQj4tPDFn+rQkrv7fiVoMxsYub+kKyZ3CNOuTSJH+A1A+eBL6DmzocChUyO6jvZFuh6Dg==
dependencies:
"@storybook/api" "6.4.9"
"@storybook/channels" "6.4.9"
"@storybook/client-logger" "6.4.9"
"@storybook/core-events" "6.4.9"
"@storybook/csf" "0.0.2--canary.87bc651.0"
"@storybook/router" "6.4.9"
"@storybook/theming" "6.4.9"
"@types/webpack-env" "^1.16.0"
core-js "^3.8.2"
global "^4.4.0"
regenerator-runtime "^0.13.7"
"@storybook/api@6.3.11":
version "6.3.11"
resolved "https://registry.npmjs.org/@storybook/api/-/api-6.3.11.tgz#ea3806a0570da65bfb5b39e4edb90289b5ba701e"
@@ -6019,6 +6036,29 @@
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
"@storybook/api@6.4.9":
version "6.4.9"
resolved "https://registry.npmjs.org/@storybook/api/-/api-6.4.9.tgz#6187d08658629580f0a583f2069d55b34964b34a"
integrity sha512-U+YKcDQg8xal9sE5eSMXB9vcqk8fD1pSyewyAjjbsW5hV0B3L3i4u7z/EAD9Ujbnor+Cvxq+XGvp+Qnc5Gd40A==
dependencies:
"@storybook/channels" "6.4.9"
"@storybook/client-logger" "6.4.9"
"@storybook/core-events" "6.4.9"
"@storybook/csf" "0.0.2--canary.87bc651.0"
"@storybook/router" "6.4.9"
"@storybook/semver" "^7.3.2"
"@storybook/theming" "6.4.9"
core-js "^3.8.2"
fast-deep-equal "^3.1.3"
global "^4.4.0"
lodash "^4.17.21"
memoizerific "^1.11.3"
regenerator-runtime "^0.13.7"
store2 "^2.12.0"
telejson "^5.3.2"
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
"@storybook/builder-webpack4@6.3.11":
version "6.3.11"
resolved "https://registry.npmjs.org/@storybook/builder-webpack4/-/builder-webpack4-6.3.11.tgz#b1b62a41b2fbd951733e86aaa4730dc2541b4221"
@@ -6148,6 +6188,15 @@
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
"@storybook/channels@6.4.9":
version "6.4.9"
resolved "https://registry.npmjs.org/@storybook/channels/-/channels-6.4.9.tgz#132c574d3fb2e6aaa9c52312c592794699b9d8ec"
integrity sha512-DNW1qDg+1WFS2aMdGh658WJXh8xBXliO5KAn0786DKcWCsKjfsPPQg/QCHczHK0+s5SZyzQT5aOBb4kTRHELQA==
dependencies:
core-js "^3.8.2"
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
"@storybook/client-api@6.3.11":
version "6.3.11"
resolved "https://registry.npmjs.org/@storybook/client-api/-/client-api-6.3.11.tgz#3e6548bf6e83a2958db701cf59740a2519eea771"
@@ -6220,6 +6269,14 @@
core-js "^3.8.2"
global "^4.4.0"
"@storybook/client-logger@6.4.9":
version "6.4.9"
resolved "https://registry.npmjs.org/@storybook/client-logger/-/client-logger-6.4.9.tgz#ef6af30fac861fea69c8917120ed06b4c2f0b54e"
integrity sha512-BVagmmHcuKDZ/XROADfN3tiolaDW2qG0iLmDhyV1gONnbGE6X5Qm19Jt2VYu3LvjKF1zMPSWm4mz7HtgdwKbuQ==
dependencies:
core-js "^3.8.2"
global "^4.4.0"
"@storybook/components@6.3.11":
version "6.3.11"
resolved "https://registry.npmjs.org/@storybook/components/-/components-6.3.11.tgz#a7d015fc9808d0200d033d8f694db79277770030"
@@ -6310,6 +6367,36 @@
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
"@storybook/components@6.4.9":
version "6.4.9"
resolved "https://registry.npmjs.org/@storybook/components/-/components-6.4.9.tgz#caed59eb3f09d1646da748186f718a0e54fb8fd7"
integrity sha512-uOUR97S6kjptkMCh15pYNM1vAqFXtpyneuonmBco5vADJb3ds0n2a8NeVd+myIbhIXn55x0OHKiSwBH/u7swCQ==
dependencies:
"@popperjs/core" "^2.6.0"
"@storybook/client-logger" "6.4.9"
"@storybook/csf" "0.0.2--canary.87bc651.0"
"@storybook/theming" "6.4.9"
"@types/color-convert" "^2.0.0"
"@types/overlayscrollbars" "^1.12.0"
"@types/react-syntax-highlighter" "11.0.5"
color-convert "^2.0.1"
core-js "^3.8.2"
fast-deep-equal "^3.1.3"
global "^4.4.0"
lodash "^4.17.21"
markdown-to-jsx "^7.1.3"
memoizerific "^1.11.3"
overlayscrollbars "^1.13.1"
polished "^4.0.5"
prop-types "^15.7.2"
react-colorful "^5.1.2"
react-popper-tooltip "^3.1.1"
react-syntax-highlighter "^13.5.3"
react-textarea-autosize "^8.3.0"
regenerator-runtime "^0.13.7"
ts-dedent "^2.0.0"
util-deprecate "^1.0.2"
"@storybook/core-client@6.3.11":
version "6.3.11"
resolved "https://registry.npmjs.org/@storybook/core-client/-/core-client-6.3.11.tgz#beede8dfb0b0d86945f0b15ef574c6ed8af37576"
@@ -6408,6 +6495,13 @@
dependencies:
core-js "^3.8.2"
"@storybook/core-events@6.4.9":
version "6.4.9"
resolved "https://registry.npmjs.org/@storybook/core-events/-/core-events-6.4.9.tgz#7febedb8d263fbd6e4a69badbfcdce0101e6f782"
integrity sha512-YhU2zJr6wzvh5naYYuy/0UKNJ/SaXu73sIr0Tx60ur3bL08XkRg7eZ9vBhNBTlAa35oZqI0iiGCh0ljiX7yEVQ==
dependencies:
core-js "^3.8.2"
"@storybook/core-server@6.3.11":
version "6.3.11"
resolved "https://registry.npmjs.org/@storybook/core-server/-/core-server-6.3.11.tgz#e6cfdab8de72254007de50473186734c26bbcbc7"
@@ -6483,6 +6577,13 @@
dependencies:
lodash "^4.17.15"
"@storybook/csf@0.0.2--canary.87bc651.0":
version "0.0.2--canary.87bc651.0"
resolved "https://registry.npmjs.org/@storybook/csf/-/csf-0.0.2--canary.87bc651.0.tgz#c7b99b3a344117ef67b10137b6477a3d2750cf44"
integrity sha512-ajk1Uxa+rBpFQHKrCcTmJyQBXZ5slfwHVEaKlkuFaW77it8RgbPJp/ccna3sgoi8oZ7FkkOyvv1Ve4SmwFqRqw==
dependencies:
lodash "^4.17.15"
"@storybook/manager-webpack4@6.3.11":
version "6.3.11"
resolved "https://registry.npmjs.org/@storybook/manager-webpack4/-/manager-webpack4-6.3.11.tgz#477a796da09a771b2d3e1adfe217a79de37057af"
@@ -6627,6 +6728,23 @@
qs "^6.10.0"
ts-dedent "^2.0.0"
"@storybook/router@6.4.9":
version "6.4.9"
resolved "https://registry.npmjs.org/@storybook/router/-/router-6.4.9.tgz#7cc3f85494f4e14d38925e2802145df69a071201"
integrity sha512-GT2KtVHo/mBjxDBFB5ZtVJVf8vC+3p5kRlQC4jao68caVp7H24ikPOkcY54VnQwwe4A1aXpGbJXUyTisEPFlhQ==
dependencies:
"@storybook/client-logger" "6.4.9"
core-js "^3.8.2"
fast-deep-equal "^3.1.3"
global "^4.4.0"
history "5.0.0"
lodash "^4.17.21"
memoizerific "^1.11.3"
qs "^6.10.0"
react-router "^6.0.0"
react-router-dom "^6.0.0"
ts-dedent "^2.0.0"
"@storybook/semver@^7.3.2":
version "7.3.2"
resolved "https://registry.npmjs.org/@storybook/semver/-/semver-7.3.2.tgz#f3b9c44a1c9a0b933c04e66d0048fcf2fa10dac0"
@@ -6705,6 +6823,24 @@
resolve-from "^5.0.0"
ts-dedent "^2.0.0"
"@storybook/theming@6.4.9":
version "6.4.9"
resolved "https://registry.npmjs.org/@storybook/theming/-/theming-6.4.9.tgz#8ece44007500b9a592e71eca693fbeac90803b0d"
integrity sha512-Do6GH6nKjxfnBg6djcIYAjss5FW9SRKASKxLYxX2RyWJBpz0m/8GfcGcRyORy0yFTk6jByA3Hs+WFH3GnEbWkw==
dependencies:
"@emotion/core" "^10.1.1"
"@emotion/is-prop-valid" "^0.8.6"
"@emotion/styled" "^10.0.27"
"@storybook/client-logger" "6.4.9"
core-js "^3.8.2"
deep-object-diff "^1.1.0"
emotion-theming "^10.0.27"
global "^4.4.0"
memoizerific "^1.11.3"
polished "^4.0.5"
resolve-from "^5.0.0"
ts-dedent "^2.0.0"
"@storybook/ui@6.3.11":
version "6.3.11"
resolved "https://registry.npmjs.org/@storybook/ui/-/ui-6.3.11.tgz#fc04d6ab50b78dc5f3d8fdc1eade0c78e4c4a4e9"
@@ -7886,15 +8022,7 @@
resolved "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.0.tgz#f946cefb5c05c64f460090f6be97bd50460c8898"
integrity sha512-RNBIyVwa/1v2r8/SqK8tadH2sJlFRAo5Ghac/cOcCv4Kp94m0I03UmAh9WVhCqS9ZdB84dF3x47p9aTw8E4c4A==
"@types/node-fetch@^2.5.0", "@types/node-fetch@^2.5.7":
version "2.5.8"
resolved "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.8.tgz#e199c835d234c7eb0846f6618012e558544ee2fb"
integrity sha512-fbjI6ja0N5ZA8TV53RUqzsKNkl9fv8Oj3T7zxW7FGv1GSH7gwJaNF8dzCjrqKaxKeUpTz4yT1DaJFq/omNpGfw==
dependencies:
"@types/node" "*"
form-data "^3.0.0"
"@types/node-fetch@^2.5.12":
"@types/node-fetch@^2.5.0", "@types/node-fetch@^2.5.12", "@types/node-fetch@^2.5.7":
version "2.5.12"
resolved "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.12.tgz#8a6f779b1d4e60b7a57fb6fd48d84fb545b9cc66"
integrity sha512-MKgC4dlq4kKNa/mYrwpKfzQMB5X3ee5U6fSprkKpToBqBmX4nFZL9cW5jl6sWn+xpRJ7ypWh2yyqqr8UUCstSw==
@@ -10006,12 +10134,7 @@ aws4@^1.11.0, aws4@^1.8.0:
resolved "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz#d61f46d83b2519250e2784daf5b09479a8b41c59"
integrity sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==
axe-core@^4.0.2:
version "4.1.3"
resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.1.3.tgz#64a4c85509e0991f5168340edc4bedd1ceea6966"
integrity sha512-vwPpH4Aj4122EW38mxO/fxhGKtwWTMLDIJfZ1He0Edbtjcfna/R3YB67yVhezUMzqc3Jr3+Ii50KRntlENL4xQ==
axe-core@^4.2.0:
axe-core@^4.0.2, axe-core@^4.2.0:
version "4.3.1"
resolved "https://registry.npmjs.org/axe-core/-/axe-core-4.3.1.tgz#0c6a076e4a1c3e0544ba6a9479158f9be7a7928e"
integrity sha512-3WVgVPs/7OnKU3s+lqMtkv3wQlg3WxK1YifmpJSDO0E1aPBrZWlrrTO6cxRqCXLuX2aYgCljqXIQd0VnRidV0g==
@@ -16645,6 +16768,13 @@ highlight.js@^10.7.2, highlight.js@~10.7.0:
resolved "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz#697272e3991356e40c3cac566a74eef681756531"
integrity sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==
history@5.0.0:
version "5.0.0"
resolved "https://registry.npmjs.org/history/-/history-5.0.0.tgz#0cabbb6c4bbf835addb874f8259f6d25101efd08"
integrity sha512-3NyRMKIiFSJmIPdq7FxkNMJkQ7ZEtVblOQ38VtKaA0zZMW1Eo6Q6W8oDKEflr1kNNTItSnk4JMCO1deeSgbLLg==
dependencies:
"@babel/runtime" "^7.7.6"
history@^5.0.0:
version "5.1.0"
resolved "https://registry.npmjs.org/history/-/history-5.1.0.tgz#2e93c09c064194d38d52ed62afd0afc9d9b01ece"
@@ -16652,6 +16782,13 @@ history@^5.0.0:
dependencies:
"@babel/runtime" "^7.7.6"
history@^5.2.0:
version "5.2.0"
resolved "https://registry.npmjs.org/history/-/history-5.2.0.tgz#7cdd31cf9bac3c5d31f09c231c9928fad0007b7c"
integrity sha512-uPSF6lAJb3nSePJ43hN3eKj1dTWpN9gMod0ZssbFTIsen+WehTmEadgL+kg78xLJFdRfrrC//SavDzmRVdE+Ig==
dependencies:
"@babel/runtime" "^7.7.6"
hmac-drbg@^1.0.1:
version "1.0.1"
resolved "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
@@ -23948,13 +24085,13 @@ promzard@^0.3.0:
read "1"
prop-types@^15.0.0, prop-types@^15.5.10, prop-types@^15.5.7, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2:
version "15.7.2"
resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
version "15.8.0"
resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.0.tgz#d237e624c45a9846e469f5f31117f970017ff588"
integrity sha512-fDGekdaHh65eI3lMi5OnErU6a8Ighg2KjcjQxO7m8VHyWjcPyj5kiOgV1LQDOOOgVy3+5FgjXvdSSX7B8/5/4g==
dependencies:
loose-envify "^1.4.0"
object-assign "^4.1.1"
react-is "^16.8.1"
react-is "^16.13.1"
property-expr@^2.0.4:
version "2.0.4"
@@ -24549,7 +24686,7 @@ react-inspector@^5.1.0, react-inspector@^5.1.1:
is-dom "^1.0.0"
prop-types "^15.0.0"
react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6, react-is@^16.9.0:
react-is@^16.13.1, react-is@^16.7.0, react-is@^16.8.0, react-is@^16.8.4, react-is@^16.8.6, react-is@^16.9.0:
version "16.13.1"
resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
@@ -24651,6 +24788,14 @@ react-router-dom@6.0.0-beta.0, react-router-dom@^6.0.0-beta.0:
prop-types "^15.7.2"
react-router "6.0.0-beta.0"
react-router-dom@^6.0.0:
version "6.2.1"
resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.2.1.tgz#32ec81829152fbb8a7b045bf593a22eadf019bec"
integrity sha512-I6Zax+/TH/cZMDpj3/4Fl2eaNdcvoxxHoH1tYOREsQ22OKDYofGebrNm6CTPUcvLvZm63NL/vzCYdjf9CUhqmA==
dependencies:
history "^5.2.0"
react-router "6.2.1"
react-router@6.0.0-beta.0, react-router@^6.0.0-beta.0:
version "6.0.0-beta.0"
resolved "https://registry.npmjs.org/react-router/-/react-router-6.0.0-beta.0.tgz#3e11f39b6ded4412c2fed9e4f989dd4c8156724d"
@@ -24658,6 +24803,13 @@ react-router@6.0.0-beta.0, react-router@^6.0.0-beta.0:
dependencies:
prop-types "^15.7.2"
react-router@6.2.1, react-router@^6.0.0:
version "6.2.1"
resolved "https://registry.npmjs.org/react-router/-/react-router-6.2.1.tgz#be2a97a6006ce1d9123c28934e604faef51448a3"
integrity sha512-2fG0udBtxou9lXtK97eJeET2ki5//UWfQSl1rlJ7quwe6jrktK9FCCc8dQb5QY6jAv3jua8bBQRhhDOM/kVRsg==
dependencies:
history "^5.2.0"
react-side-effect@^2.1.0:
version "2.1.0"
resolved "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.0.tgz#1ce4a8b4445168c487ed24dab886421f74d380d3"
@@ -26785,9 +26937,9 @@ stacktrace-js@^2.0.2:
stacktrace-gps "^3.0.4"
start-server-and-test@^1.10.11:
version "1.13.1"
resolved "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-1.13.1.tgz#c06eb18c3f31d610724722b7eecbdf2550b03582"
integrity sha512-wZjksmjG5scEHXmV/3HWzImxNzUgaNQ6W8kkqL2GbiOldM+nqiqh7niimlC9ZGNopTGj16kheWZnZtSWgdBZNQ==
version "1.14.0"
resolved "https://registry.npmjs.org/start-server-and-test/-/start-server-and-test-1.14.0.tgz#c57f04f73eac15dd51733b551d775b40837fdde3"
integrity sha512-on5ELuxO2K0t8EmNj9MtVlFqwBMxfWOhu4U7uZD1xccVpFlOQKR93CSe0u98iQzfNxRyaNTb/CdadbNllplTsw==
dependencies:
bluebird "3.7.2"
check-more-types "2.24.0"