Merge branch 'master' of github.com:spotify/backstage into shmidt-i/location-update-results
This commit is contained in:
@@ -85,7 +85,7 @@ Take a look at the [Getting Started](docs/getting-started/README.md) guide to le
|
||||
- [Architecture](docs/architecture-terminology.md)
|
||||
- [API references](docs/reference/README.md)
|
||||
- [Designing for Backstage](docs/design.md)
|
||||
- [Storybook - UI components](http://storybook.backstage.io) ([WIP](https://github.com/spotify/backstage/milestone/9))
|
||||
- [Storybook - UI components](http://storybook.backstage.io)
|
||||
- [Contributing to Storybook](docs/getting-started/contributing-to-storybook.md)
|
||||
- Using Backstage components (TODO)
|
||||
|
||||
|
||||
@@ -14,9 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createApp, AlertDisplay, OAuthRequestDialog } from '@backstage/core';
|
||||
import {
|
||||
createApp,
|
||||
AlertDisplay,
|
||||
OAuthRequestDialog,
|
||||
LoginPage,
|
||||
} from '@backstage/core';
|
||||
import React, { FC } from 'react';
|
||||
import { BrowserRouter as Router } from 'react-router-dom';
|
||||
import { BrowserRouter as Router, Route } from 'react-router-dom';
|
||||
import Root from './components/Root';
|
||||
import * as plugins from './plugins';
|
||||
import apis from './apis';
|
||||
@@ -35,6 +40,7 @@ const App: FC<{}> = () => (
|
||||
<OAuthRequestDialog />
|
||||
<Router>
|
||||
<Root>
|
||||
<Route key="login" path="/login" component={LoginPage} exact />,
|
||||
<AppComponent />
|
||||
</Root>
|
||||
</Router>
|
||||
|
||||
@@ -40,8 +40,7 @@
|
||||
"@types/helmet": "^0.0.47",
|
||||
"jest": "^26.0.1",
|
||||
"tsc-watch": "^4.2.3",
|
||||
"typescript": "^3.9.2",
|
||||
"winston": "^3.2.1"
|
||||
"typescript": "^3.9.2"
|
||||
},
|
||||
"nodemonConfig": {
|
||||
"watch": "./dist"
|
||||
|
||||
@@ -58,9 +58,13 @@ function createEnv(plugin: string): PluginEnvironment {
|
||||
|
||||
async function main() {
|
||||
const app = express();
|
||||
const corsOptions: cors.CorsOptions = {
|
||||
origin: 'http://localhost:3000',
|
||||
credentials: true,
|
||||
};
|
||||
|
||||
app.use(helmet());
|
||||
app.use(cors());
|
||||
app.use(cors(corsOptions));
|
||||
app.use(compression());
|
||||
app.use(express.json());
|
||||
app.use(requestLoggingHandler());
|
||||
|
||||
+6
-3
@@ -13,10 +13,13 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { AlertApi, AlertMessage } from '../../../';
|
||||
import { PublishSubject } from './lib';
|
||||
import { Observable } from '../../types';
|
||||
import { AlertApi, AlertMessage } from '../../../..';
|
||||
import { PublishSubject } from '../lib';
|
||||
import { Observable } from '../../../types';
|
||||
|
||||
/**
|
||||
* Base implementation for the AlertApi that simply forwards alerts to consumers.
|
||||
*/
|
||||
export class AlertApiForwarder implements AlertApi {
|
||||
private readonly subject = new PublishSubject<AlertMessage>();
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { AlertApiForwarder } from './AlertApiForwarder';
|
||||
+2
-2
@@ -33,7 +33,7 @@ export class AppThemeSelector implements AppThemeApi {
|
||||
|
||||
selector.setActiveThemeId(initialThemeId);
|
||||
|
||||
selector.activeThemeId$().subscribe((themeId) => {
|
||||
selector.activeThemeId$().subscribe(themeId => {
|
||||
if (themeId) {
|
||||
window.localStorage.setItem(STORAGE_KEY, themeId);
|
||||
} else {
|
||||
@@ -41,7 +41,7 @@ export class AppThemeSelector implements AppThemeApi {
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('storage', (event) => {
|
||||
window.addEventListener('storage', event => {
|
||||
if (event.key === STORAGE_KEY) {
|
||||
const themeId = localStorage.getItem(STORAGE_KEY) ?? undefined;
|
||||
selector.setActiveThemeId(themeId);
|
||||
+1
-1
@@ -13,7 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { ErrorApi, ErrorContext, AlertApi } from '../../../';
|
||||
import { ErrorApi, ErrorContext, AlertApi } from '../../../..';
|
||||
|
||||
/**
|
||||
* Decorates an ErrorApi by also forwarding error messages
|
||||
+6
-3
@@ -13,10 +13,13 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { ErrorApi, ErrorContext } from '../../../';
|
||||
import { PublishSubject } from './lib';
|
||||
import { Observable } from '../../types';
|
||||
import { ErrorApi, ErrorContext } from '../../../..';
|
||||
import { PublishSubject } from '../lib';
|
||||
import { Observable } from '../../../types';
|
||||
|
||||
/**
|
||||
* Base implementation for the ErrorApi that simply forwards errors to consumers.
|
||||
*/
|
||||
export class ErrorApiForwarder implements ErrorApi {
|
||||
private readonly subject = new PublishSubject<{
|
||||
error: Error;
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { ErrorAlerter } from './ErrorAlerter';
|
||||
export { ErrorApiForwarder } from './ErrorApiForwarder';
|
||||
@@ -40,7 +40,7 @@ type CreateOptions = {
|
||||
export type GoogleAuthResponse = {
|
||||
accessToken: string;
|
||||
idToken: string;
|
||||
scopes: string;
|
||||
scope: string;
|
||||
expiresInSeconds: number;
|
||||
};
|
||||
|
||||
@@ -70,7 +70,7 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi {
|
||||
return {
|
||||
idToken: res.idToken,
|
||||
accessToken: res.accessToken,
|
||||
scopes: GoogleAuth.normalizeScopes(res.scopes),
|
||||
scopes: GoogleAuth.normalizeScopes(res.scope),
|
||||
expiresAt: new Date(Date.now() + res.expiresInSeconds * 1000),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
// Plugins should rely on these APIs for functionality as much as possible.
|
||||
|
||||
export * from './auth';
|
||||
export * from './AppThemeSelector';
|
||||
export * from './AlertApiForwarder';
|
||||
export * from './ErrorAlerter';
|
||||
export * from './ErrorApiForwarder';
|
||||
export * from './OAuthRequestManager';
|
||||
|
||||
export * from './AlertApi';
|
||||
export * from './AppThemeApi';
|
||||
export * from './ErrorApi';
|
||||
export * from './OAuthRequestApi';
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@
|
||||
|
||||
import ProviderIcon from '@material-ui/icons/AcUnit';
|
||||
import { DefaultAuthConnector } from './DefaultAuthConnector';
|
||||
import MockOAuthApi from '../../OAuthRequestManager/MockOAuthApi';
|
||||
import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi';
|
||||
import * as loginPopup from '../loginPopup';
|
||||
|
||||
const anyFetch = fetch as any;
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@ export class DefaultAuthConnector<AuthSession>
|
||||
}
|
||||
|
||||
async refreshSession(): Promise<any> {
|
||||
const res = await fetch(this.buildUrl('/token', { optional: true }), {
|
||||
const res = await fetch(this.buildUrl('/refresh', { optional: true }), {
|
||||
headers: {
|
||||
'x-requested-with': 'XMLHttpRequest',
|
||||
},
|
||||
|
||||
+1
-2
@@ -20,8 +20,7 @@ import {
|
||||
SessionShouldRefreshFunc,
|
||||
} from './types';
|
||||
import { AuthConnector } from '../AuthConnector';
|
||||
import { SessionScopeHelper } from './common';
|
||||
import { hasScopes } from '../../OAuthRequestManager/OAuthPendingRequests';
|
||||
import { SessionScopeHelper, hasScopes } from './common';
|
||||
|
||||
type Options<T> = {
|
||||
/** The connector used for acting on the auth session */
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
AppThemeSelector,
|
||||
appThemeApiRef,
|
||||
} from '../apis';
|
||||
import LoginPage from './LoginPage';
|
||||
import { lightTheme, darkTheme } from '@backstage/theme';
|
||||
import { ApiAggregator } from '../apis/ApiAggregator';
|
||||
|
||||
@@ -138,10 +137,6 @@ class AppImpl implements BackstageApp {
|
||||
FeatureFlags.registeredFeatureFlags = registeredFeatureFlags;
|
||||
}
|
||||
|
||||
routes.push(
|
||||
<Route key="login" path="/login" component={LoginPage} exact />,
|
||||
);
|
||||
|
||||
const rendered = (
|
||||
<Switch>
|
||||
{routes}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
import { ComponentType } from 'react';
|
||||
import { SvgIconProps } from '@material-ui/core';
|
||||
|
||||
export type IconComponent = ComponentType<SvgIconProps>;
|
||||
export type SystemIconKey = 'user' | 'group';
|
||||
export type SystemIcons = { [key in SystemIconKey]: IconComponent };
|
||||
|
||||
@@ -28,6 +28,7 @@ export { default as InfoCard } from './layout/InfoCard';
|
||||
export { CardTab, TabbedCard } from './layout/TabbedCard';
|
||||
export { default as ErrorBoundary } from './layout/ErrorBoundary';
|
||||
export * from './layout/Sidebar';
|
||||
export * from './layout/LoginPage';
|
||||
export { AlertDisplay } from './components/AlertDisplay';
|
||||
export { default as HorizontalScrollGrid } from './components/HorizontalScrollGrid';
|
||||
export { default as ProgressCard } from './components/ProgressBars/ProgressCard';
|
||||
@@ -46,3 +47,4 @@ export { default as TrendLine } from './components/TrendLine';
|
||||
export { FeatureCalloutCircular } from './components/FeatureDiscovery/FeatureCalloutCircular';
|
||||
export * from './components/Status';
|
||||
export { default as WarningPanel } from './components/WarningPanel';
|
||||
export type { IconComponent } from './icons';
|
||||
|
||||
+8
-10
@@ -16,10 +16,10 @@
|
||||
|
||||
import React, { FC, useState } from 'react';
|
||||
import GitHubIcon from '@material-ui/icons/GitHub';
|
||||
import Page from '../../../layout/Page';
|
||||
import Header from '../../../layout/Header';
|
||||
import Content from '../../../layout/Content/Content';
|
||||
import ContentHeader from '../../../layout/ContentHeader/ContentHeader';
|
||||
import Page from '../Page';
|
||||
import Header from '../Header';
|
||||
import Content from '../Content/Content';
|
||||
import ContentHeader from '../ContentHeader/ContentHeader';
|
||||
import {
|
||||
Grid,
|
||||
Typography,
|
||||
@@ -29,13 +29,13 @@ import {
|
||||
ListItem,
|
||||
Link,
|
||||
} from '@material-ui/core';
|
||||
import InfoCard from '../../../layout/InfoCard/InfoCard';
|
||||
import InfoCard from '../InfoCard/InfoCard';
|
||||
|
||||
enum AuthType {
|
||||
GitHub,
|
||||
}
|
||||
|
||||
const LoginPage: FC<{}> = () => {
|
||||
export const LoginPage: FC<{}> = () => {
|
||||
const [githubUsername, setGithubUsername] = useState(String);
|
||||
const [githubPersonalAuthToken, setGithubPersonalAuthToken] = useState(
|
||||
String,
|
||||
@@ -72,11 +72,11 @@ const LoginPage: FC<{}> = () => {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}),
|
||||
})
|
||||
.then((response) => {
|
||||
.then(response => {
|
||||
if (response.status === 200) return response.json();
|
||||
throw Error(`${response.status} ${response.statusText}`);
|
||||
})
|
||||
.then((data) => {
|
||||
.then(data => {
|
||||
const info = {
|
||||
username: username,
|
||||
token: token,
|
||||
@@ -176,5 +176,3 @@ const LoginPage: FC<{}> = () => {
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
+1
-1
@@ -14,4 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { default } from './LoginPage';
|
||||
export { LoginPage } from './LoginPage';
|
||||
@@ -25,10 +25,16 @@
|
||||
"morgan": "^1.10.0",
|
||||
"winston": "^3.2.1",
|
||||
"yn": "^4.0.0",
|
||||
"passport": "0.4.1",
|
||||
"passport-google-oauth20": "2.0.0",
|
||||
"@types/passport": "1.0.3",
|
||||
"@types/passport-google-oauth20": "2.0.3"
|
||||
"passport": "^0.4.1",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-oauth2-refresh": "^2.0.0",
|
||||
"passport-oauth2": "^1.5.0",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"@types/passport-oauth2-refresh": "^1.1.1",
|
||||
"@types/passport": "^1.0.3",
|
||||
"@types/passport-google-oauth20": "^2.0.3",
|
||||
"@types/cookie-parser": "^1.4.2",
|
||||
"@types/passport-oauth2": "^1.4.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.6",
|
||||
|
||||
@@ -14,10 +14,11 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { GoogleAuthProvider } from './provider';
|
||||
import { GoogleAuthProvider, THOUSAND_DAYS_MS } from './provider';
|
||||
import passport from 'passport';
|
||||
import express from 'express';
|
||||
import * as utils from './../utils';
|
||||
import refresh from 'passport-oauth2-refresh';
|
||||
|
||||
const googleAuthProviderConfig = {
|
||||
provider: 'google',
|
||||
@@ -51,7 +52,7 @@ describe('GoogleAuthProvider', () => {
|
||||
});
|
||||
|
||||
describe('start authentication handler', () => {
|
||||
const mockResponse: any = ({} as unknown) as express.Response;
|
||||
const mockResponse = ({} as unknown) as express.Response;
|
||||
const mockNext: express.NextFunction = jest.fn();
|
||||
|
||||
it('should initiate authenticate request with provided scopes', () => {
|
||||
@@ -97,6 +98,7 @@ describe('GoogleAuthProvider', () => {
|
||||
it('should perform logout and respond with 200', () => {
|
||||
const mockResponse: any = ({
|
||||
send: jest.fn(),
|
||||
cookie: jest.fn(),
|
||||
} as unknown) as express.Response;
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
@@ -110,23 +112,68 @@ describe('GoogleAuthProvider', () => {
|
||||
googleAuthProvider.logout(mockRequest, mockResponse);
|
||||
expect(spyResponse).toBeCalledTimes(1);
|
||||
expect(spyResponse).toBeCalledWith('logout!');
|
||||
expect(mockResponse.cookie).toBeCalledTimes(1);
|
||||
expect(mockResponse.cookie).toBeCalledWith(
|
||||
'google-refresh-token',
|
||||
'',
|
||||
expect.objectContaining({ maxAge: 0 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redirect frame handler', () => {
|
||||
const mockRequest = ({} as unknown) as express.Request;
|
||||
const mockResponse: any = ({
|
||||
send: jest.fn(),
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
cookie: jest.fn().mockReturnThis(),
|
||||
} as unknown) as express.Response;
|
||||
const mockNext: express.NextFunction = jest.fn();
|
||||
|
||||
it('should call authenticate and post a response ', () => {
|
||||
it('should call authenticate and post a response', () => {
|
||||
const spyPostMessage = jest
|
||||
.spyOn(utils, 'postMessageResponse')
|
||||
.mockImplementation(() => jest.fn());
|
||||
|
||||
const spyPassport = jest
|
||||
.spyOn(passport, 'authenticate')
|
||||
.mockImplementation((_x, callbackFunc) => {
|
||||
const cb = callbackFunc as Function;
|
||||
cb(null, { refreshToken: 'REFRESH_TOKEN' });
|
||||
return jest.fn();
|
||||
});
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
|
||||
googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
|
||||
expect(spyPassport).toBeCalledTimes(1);
|
||||
expect(spyPostMessage).toBeCalledTimes(1);
|
||||
expect(mockResponse.cookie).toBeCalledTimes(1);
|
||||
expect(mockResponse.cookie).toBeCalledWith(
|
||||
'google-refresh-token',
|
||||
'REFRESH_TOKEN',
|
||||
expect.objectContaining({
|
||||
path: '/auth/google',
|
||||
sameSite: 'none',
|
||||
httpOnly: true,
|
||||
maxAge: THOUSAND_DAYS_MS,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should respond with a error message if no refresh token returned', () => {
|
||||
const spyPassport = jest
|
||||
.spyOn(passport, 'authenticate')
|
||||
.mockImplementation((_x, callbackFunc) => {
|
||||
const cb = callbackFunc as Function;
|
||||
cb(null, {});
|
||||
return jest.fn();
|
||||
});
|
||||
|
||||
const spyPostMessage = jest
|
||||
.spyOn(utils, 'postMessageResponse')
|
||||
.mockImplementation(() => jest.fn());
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
@@ -134,10 +181,38 @@ describe('GoogleAuthProvider', () => {
|
||||
);
|
||||
|
||||
googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
|
||||
const callbackFunc = spyPassport.mock.calls[0][1] as Function;
|
||||
callbackFunc();
|
||||
expect(spyPassport).toBeCalledTimes(1);
|
||||
expect(spyPostMessage).toBeCalledTimes(1);
|
||||
expect(spyPostMessage).toBeCalledWith(mockResponse, {
|
||||
type: 'auth-result',
|
||||
error: new Error('Missing refresh token'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should respond with a error message if auth failed', () => {
|
||||
const spyPassport = jest
|
||||
.spyOn(passport, 'authenticate')
|
||||
.mockImplementation((_x, callbackFunc) => {
|
||||
const cb = callbackFunc as Function;
|
||||
cb(new Error('TokenError'), null);
|
||||
return jest.fn();
|
||||
});
|
||||
|
||||
const spyPostMessage = jest
|
||||
.spyOn(utils, 'postMessageResponse')
|
||||
.mockImplementation(() => jest.fn());
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
|
||||
googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
|
||||
expect(spyPassport).toBeCalledTimes(1);
|
||||
expect(spyPostMessage).toBeCalledTimes(1);
|
||||
expect(spyPostMessage).toBeCalledWith(mockResponse, {
|
||||
type: 'auth-result',
|
||||
error: new Error('Google auth failed, Error: TokenError'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -159,4 +234,159 @@ describe('GoogleAuthProvider', () => {
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh token handler', () => {
|
||||
const mockResponse = ({
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
} as unknown) as express.Response;
|
||||
|
||||
describe('no refresh token cookie', () => {
|
||||
it('should respond with a 401', () => {
|
||||
const mockRequest = ({
|
||||
cookies: jest.fn(),
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
|
||||
googleAuthProvider.refresh(mockRequest, mockResponse);
|
||||
expect(mockResponse.send).toBeCalledTimes(1);
|
||||
expect(mockResponse.send).toBeCalledWith('Missing session cookie');
|
||||
|
||||
expect(mockResponse.status).toBeCalledTimes(1);
|
||||
expect(mockResponse.status).toBeCalledWith(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh token cookie, no scope', () => {
|
||||
const mockRequest = ({
|
||||
cookies: { 'google-refresh-token': 'REFRESH_TOKEN' },
|
||||
query: {},
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
|
||||
it('should request for a new access token and fail if no access token returned', () => {
|
||||
const spyRefresh = jest
|
||||
.spyOn(refresh, 'requestNewAccessToken')
|
||||
.mockImplementation((_x, _y, _z, callbackFunc) => {
|
||||
const cb = callbackFunc as Function;
|
||||
cb(undefined, undefined, undefined, {});
|
||||
});
|
||||
|
||||
googleAuthProvider.refresh(mockRequest, mockResponse);
|
||||
expect(spyRefresh).toBeCalledTimes(1);
|
||||
expect(spyRefresh).toBeCalledWith(
|
||||
'google',
|
||||
'REFRESH_TOKEN',
|
||||
{},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockResponse.status).toBeCalledTimes(1);
|
||||
expect(mockResponse.status).toBeCalledWith(401);
|
||||
expect(mockResponse.send).toBeCalledTimes(1);
|
||||
expect(mockResponse.send).toBeCalledWith(
|
||||
'Failed to refresh access token',
|
||||
);
|
||||
});
|
||||
|
||||
it('should request for a new access token and return 401 if any error', () => {
|
||||
const spyRefresh = jest
|
||||
.spyOn(refresh, 'requestNewAccessToken')
|
||||
.mockImplementation((_x, _y, _z, callbackFunc) => {
|
||||
const cb = callbackFunc as Function;
|
||||
cb({ error: 'ERROR' }, undefined, undefined, {});
|
||||
});
|
||||
|
||||
googleAuthProvider.refresh(mockRequest, mockResponse);
|
||||
expect(spyRefresh).toBeCalledTimes(1);
|
||||
expect(spyRefresh).toBeCalledWith(
|
||||
'google',
|
||||
'REFRESH_TOKEN',
|
||||
{},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockResponse.status).toBeCalledTimes(1);
|
||||
expect(mockResponse.status).toBeCalledWith(401);
|
||||
expect(mockResponse.send).toBeCalledTimes(1);
|
||||
expect(mockResponse.send).toBeCalledWith(
|
||||
'Failed to refresh access token',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fetch and return a new access token', () => {
|
||||
const spyRefresh = jest
|
||||
.spyOn(refresh, 'requestNewAccessToken')
|
||||
.mockImplementation((_x, _y, _z, callbackFunc) => {
|
||||
const cb = callbackFunc as Function;
|
||||
cb(undefined, 'ACCESS_TOKEN', undefined, {
|
||||
expires_in: 'EXPIRES_IN',
|
||||
id_token: 'ID_TOKEN',
|
||||
});
|
||||
});
|
||||
|
||||
googleAuthProvider.refresh(mockRequest, mockResponse);
|
||||
expect(spyRefresh).toBeCalledTimes(1);
|
||||
expect(spyRefresh).toBeCalledWith(
|
||||
'google',
|
||||
'REFRESH_TOKEN',
|
||||
{},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockResponse.send).toBeCalledTimes(1);
|
||||
expect(mockResponse.send).toBeCalledWith({
|
||||
accessToken: 'ACCESS_TOKEN',
|
||||
idToken: 'ID_TOKEN',
|
||||
expiresInSeconds: 'EXPIRES_IN',
|
||||
scope: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh token cookie and scope', () => {
|
||||
const mockRequest = ({
|
||||
cookies: { 'google-refresh-token': 'REFRESH_TOKEN' },
|
||||
query: {
|
||||
scope: 'a,b',
|
||||
},
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
|
||||
it('should fetch and return a new access token with scopes', () => {
|
||||
const spyRefresh = jest
|
||||
.spyOn(refresh, 'requestNewAccessToken')
|
||||
.mockImplementation((_x, _y, _z, callbackFunc) => {
|
||||
const cb = callbackFunc as Function;
|
||||
cb(undefined, 'ACCESS_TOKEN', undefined, {
|
||||
expires_in: 'EXPIRES_IN',
|
||||
id_token: 'ID_TOKEN',
|
||||
scope: 'a,b',
|
||||
});
|
||||
});
|
||||
|
||||
googleAuthProvider.refresh(mockRequest, mockResponse);
|
||||
expect(spyRefresh).toBeCalledTimes(1);
|
||||
expect(spyRefresh).toBeCalledWith(
|
||||
'google',
|
||||
'REFRESH_TOKEN',
|
||||
{ scope: 'a,b' },
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockResponse.send).toBeCalledTimes(1);
|
||||
expect(mockResponse.send).toBeCalledWith({
|
||||
accessToken: 'ACCESS_TOKEN',
|
||||
idToken: 'ID_TOKEN',
|
||||
expiresInSeconds: 'EXPIRES_IN',
|
||||
scope: 'a,b',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
*/
|
||||
|
||||
import passport from 'passport';
|
||||
import express from 'express';
|
||||
import express, { CookieOptions } from 'express';
|
||||
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
|
||||
import refresh from 'passport-oauth2-refresh';
|
||||
import {
|
||||
AuthProvider,
|
||||
AuthProviderRouteHandlers,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
import { postMessageResponse } from './../utils';
|
||||
import { InputError } from '@backstage/backend-common';
|
||||
|
||||
export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000;
|
||||
export class GoogleAuthProvider
|
||||
implements AuthProvider, AuthProviderRouteHandlers {
|
||||
private readonly providerConfig: AuthProviderConfig;
|
||||
@@ -53,8 +55,40 @@ export class GoogleAuthProvider
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) {
|
||||
return passport.authenticate('google', (_, user) => {
|
||||
postMessageResponse(res, {
|
||||
return passport.authenticate('google', (err, user) => {
|
||||
if (err) {
|
||||
return postMessageResponse(res, {
|
||||
type: 'auth-result',
|
||||
error: new Error(`Google auth failed, ${err}`),
|
||||
});
|
||||
}
|
||||
|
||||
const { refreshToken } = user;
|
||||
|
||||
if (!refreshToken) {
|
||||
return postMessageResponse(res, {
|
||||
type: 'auth-result',
|
||||
error: new Error('Missing refresh token'),
|
||||
});
|
||||
}
|
||||
|
||||
delete user.refreshToken;
|
||||
|
||||
const options: CookieOptions = {
|
||||
maxAge: THOUSAND_DAYS_MS,
|
||||
secure: false,
|
||||
sameSite: 'none',
|
||||
domain: 'localhost',
|
||||
path: `/auth/${this.providerConfig.provider}`,
|
||||
httpOnly: true,
|
||||
};
|
||||
|
||||
res.cookie(
|
||||
`${this.providerConfig.provider}-refresh-token`,
|
||||
refreshToken,
|
||||
options,
|
||||
);
|
||||
return postMessageResponse(res, {
|
||||
type: 'auth-result',
|
||||
payload: user,
|
||||
});
|
||||
@@ -62,7 +96,46 @@ export class GoogleAuthProvider
|
||||
}
|
||||
|
||||
async logout(_req: express.Request, res: express.Response) {
|
||||
res.send('logout!');
|
||||
const options: CookieOptions = {
|
||||
maxAge: 0,
|
||||
secure: false,
|
||||
sameSite: 'none',
|
||||
domain: 'localhost',
|
||||
path: `/auth/${this.providerConfig.provider}`,
|
||||
httpOnly: true,
|
||||
};
|
||||
|
||||
res.cookie(`${this.providerConfig.provider}-refresh-token`, '', options);
|
||||
return res.send('logout!');
|
||||
}
|
||||
|
||||
async refresh(req: express.Request, res: express.Response) {
|
||||
const refreshToken =
|
||||
req.cookies[`${this.providerConfig.provider}-refresh-token`];
|
||||
|
||||
if (!refreshToken) {
|
||||
return res.status(401).send('Missing session cookie');
|
||||
}
|
||||
|
||||
const scope = req.query.scope?.toString() ?? '';
|
||||
const refreshTokenRequestParams = scope ? { scope } : {};
|
||||
|
||||
return refresh.requestNewAccessToken(
|
||||
this.providerConfig.provider,
|
||||
refreshToken,
|
||||
refreshTokenRequestParams,
|
||||
(err, accessToken, _refreshToken, params) => {
|
||||
if (err || !accessToken) {
|
||||
return res.status(401).send('Failed to refresh access token');
|
||||
}
|
||||
return res.send({
|
||||
accessToken,
|
||||
idToken: params.id_token,
|
||||
expiresInSeconds: params.expires_in,
|
||||
scope: params.scope,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
strategy(): passport.Strategy {
|
||||
@@ -81,6 +154,8 @@ export class GoogleAuthProvider
|
||||
idToken: params.id_token,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
scope: params.scope,
|
||||
expiresInSeconds: params.expires_in,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -24,7 +24,7 @@ export const defaultRouter = (provider: AuthProviderRouteHandlers) => {
|
||||
router.get('/handler/frame', provider.frameHandler);
|
||||
router.get('/logout', provider.logout);
|
||||
if (provider.refresh) {
|
||||
router.get('/refreshToken', provider.refresh);
|
||||
router.get('/refresh', provider.refresh);
|
||||
}
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -58,16 +58,25 @@ export type AuthProviderFactory = {
|
||||
new (providerConfig: any): AuthProvider & AuthProviderRouteHandlers;
|
||||
};
|
||||
|
||||
export type AuthInfo = {
|
||||
profile: passport.Profile;
|
||||
export type AuthInfoBase = {
|
||||
accessToken: string;
|
||||
idToken?: string;
|
||||
expiresInSeconds?: number;
|
||||
scope: string;
|
||||
};
|
||||
|
||||
export type AuthInfoWithProfile = AuthInfoBase & {
|
||||
profile: passport.Profile;
|
||||
};
|
||||
|
||||
export type AuthInfoPrivate = AuthInfoWithProfile & {
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
export type AuthResponse =
|
||||
| {
|
||||
type: 'auth-result';
|
||||
payload: AuthInfo;
|
||||
payload: AuthInfoBase | AuthInfoWithProfile;
|
||||
}
|
||||
| {
|
||||
type: 'auth-result';
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import passport from 'passport';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import refresh from 'passport-oauth2-refresh';
|
||||
import OAuth2Strategy from 'passport-oauth2';
|
||||
import { Logger } from 'winston';
|
||||
import { providers } from './../providers/config';
|
||||
import { makeProvider } from '../providers';
|
||||
@@ -39,6 +42,9 @@ export async function createRouter(
|
||||
);
|
||||
logger.info(`Configuring provider: ${providerId}`);
|
||||
passport.use(strategy);
|
||||
if (strategy instanceof OAuth2Strategy) {
|
||||
refresh.use(strategy);
|
||||
}
|
||||
providerRouters[providerId] = providerRouter;
|
||||
}
|
||||
|
||||
@@ -52,6 +58,7 @@ export async function createRouter(
|
||||
|
||||
router.use(passport.initialize());
|
||||
router.use(passport.session());
|
||||
router.use(cookieParser());
|
||||
|
||||
for (const providerId in providerRouters) {
|
||||
if (providerRouters.hasOwnProperty(providerId)) {
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.1.1-alpha.6",
|
||||
"@types/node-fetch": "^2.5.7",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.17.1",
|
||||
@@ -37,9 +38,10 @@
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.6",
|
||||
"@types/lodash": "^4.14.151",
|
||||
"@types/uuid": "^7.0.3",
|
||||
"@types/uuid": "^8.0.0",
|
||||
"@types/yup": "^0.28.2",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"supertest": "^4.0.2",
|
||||
"tsc-watch": "^4.2.3"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -14,32 +14,47 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { NotFoundError } from '@backstage/backend-common';
|
||||
import { Database } from '../database';
|
||||
import { DescriptorEnvelope } from '../ingestion/types';
|
||||
import { EntitiesCatalog } from './types';
|
||||
import { EntitiesCatalog, EntityFilters } from './types';
|
||||
|
||||
export class DatabaseEntitiesCatalog implements EntitiesCatalog {
|
||||
constructor(private readonly database: Database) {}
|
||||
|
||||
async entities(): Promise<DescriptorEnvelope[]> {
|
||||
async entities(filters?: EntityFilters): Promise<DescriptorEnvelope[]> {
|
||||
const items = await this.database.transaction(tx =>
|
||||
this.database.entities(tx),
|
||||
this.database.entities(tx, filters),
|
||||
);
|
||||
return items.map(i => i.entity);
|
||||
}
|
||||
|
||||
async entity(
|
||||
async entityByUid(uid: string): Promise<DescriptorEnvelope | undefined> {
|
||||
const matches = await this.database.transaction(tx =>
|
||||
this.database.entities(tx, [{ key: 'uid', values: [uid] }]),
|
||||
);
|
||||
|
||||
return matches.length ? matches[0].entity : undefined;
|
||||
}
|
||||
|
||||
async entityByName(
|
||||
kind: string,
|
||||
name: string,
|
||||
namespace: string | undefined,
|
||||
): Promise<DescriptorEnvelope | undefined> {
|
||||
const item = await this.database.transaction(tx =>
|
||||
this.database.entity(tx, kind, name, namespace),
|
||||
const matches = await this.database.transaction(tx =>
|
||||
this.database.entities(tx, [
|
||||
{ key: 'kind', values: [kind] },
|
||||
{ key: 'name', values: [name] },
|
||||
{
|
||||
key: 'namespace',
|
||||
values:
|
||||
!namespace || namespace === 'default'
|
||||
? [null, 'default']
|
||||
: [namespace],
|
||||
},
|
||||
]),
|
||||
);
|
||||
if (!item) {
|
||||
throw new NotFoundError('Entity cannot be found');
|
||||
}
|
||||
return item.entity;
|
||||
|
||||
return matches.length ? matches[0].entity : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,15 @@ export class StaticEntitiesCatalog implements EntitiesCatalog {
|
||||
return lodash.cloneDeep(this._entities);
|
||||
}
|
||||
|
||||
async entity(
|
||||
async entityByUid(uid: string): Promise<DescriptorEnvelope | undefined> {
|
||||
const item = this._entities.find(e => uid === e.metadata?.uid);
|
||||
if (!item) {
|
||||
throw new NotFoundError('Entity cannot be found');
|
||||
}
|
||||
return lodash.cloneDeep(item);
|
||||
}
|
||||
|
||||
async entityByName(
|
||||
kind: string,
|
||||
name: string,
|
||||
namespace: string | undefined,
|
||||
|
||||
@@ -19,15 +19,22 @@ import { DescriptorEnvelope } from '../ingestion';
|
||||
import { DatabaseLocationUpdateLogEvent } from '../database';
|
||||
|
||||
//
|
||||
// Items
|
||||
// Entities
|
||||
//
|
||||
|
||||
export type EntityFilter = {
|
||||
key: string;
|
||||
values: (string | null)[];
|
||||
};
|
||||
export type EntityFilters = EntityFilter[];
|
||||
|
||||
export type EntitiesCatalog = {
|
||||
entities(): Promise<DescriptorEnvelope[]>;
|
||||
entity(
|
||||
entities(filters?: EntityFilters): Promise<DescriptorEnvelope[]>;
|
||||
entityByUid(uid: string): Promise<DescriptorEnvelope | undefined>;
|
||||
entityByName(
|
||||
kind: string,
|
||||
name: string,
|
||||
namespace: string | undefined,
|
||||
name: string,
|
||||
): Promise<DescriptorEnvelope | undefined>;
|
||||
};
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '@backstage/backend-common';
|
||||
import Knex from 'knex';
|
||||
import path from 'path';
|
||||
import { DescriptorEnvelope } from '../ingestion';
|
||||
import { Database } from './Database';
|
||||
import {
|
||||
AddDatabaseLocation,
|
||||
@@ -289,4 +290,112 @@ describe('Database', () => {
|
||||
).rejects.toThrow(ConflictError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('entities', () => {
|
||||
it('can get all entities with empty filters list', async () => {
|
||||
const catalog = new Database(database, getVoidLogger());
|
||||
const e1: DescriptorEnvelope = { apiVersion: 'a', kind: 'b' };
|
||||
const e2: DescriptorEnvelope = {
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
spec: { c: null },
|
||||
};
|
||||
await catalog.transaction(async tx => {
|
||||
await catalog.addEntity(tx, { entity: e1 });
|
||||
await catalog.addEntity(tx, { entity: e2 });
|
||||
});
|
||||
const result = await catalog.transaction(async tx =>
|
||||
catalog.entities(tx, []),
|
||||
);
|
||||
expect(result.length).toEqual(2);
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ locationId: undefined, entity: expect.objectContaining(e1) },
|
||||
{ locationId: undefined, entity: expect.objectContaining(e2) },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('can get all specific entities for matching filters (naive case)', async () => {
|
||||
const catalog = new Database(database, getVoidLogger());
|
||||
const entities: DescriptorEnvelope[] = [
|
||||
{ apiVersion: 'a', kind: 'b' },
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
spec: { c: 'some' },
|
||||
},
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
spec: { c: null },
|
||||
},
|
||||
];
|
||||
|
||||
await catalog.transaction(async tx => {
|
||||
for (const entity of entities) {
|
||||
await catalog.addEntity(tx, { entity });
|
||||
}
|
||||
});
|
||||
|
||||
await expect(
|
||||
catalog.transaction(async tx =>
|
||||
catalog.entities(tx, [
|
||||
{ key: 'kind', values: ['b'] },
|
||||
{ key: 'spec.c', values: ['some'] },
|
||||
]),
|
||||
),
|
||||
).resolves.toEqual([
|
||||
{ locationId: undefined, entity: expect.objectContaining(entities[1]) },
|
||||
]);
|
||||
});
|
||||
|
||||
it('can get all specific entities for matching filters with nulls (both missing and literal null value)', async () => {
|
||||
const catalog = new Database(database, getVoidLogger());
|
||||
const entities: DescriptorEnvelope[] = [
|
||||
{ apiVersion: 'a', kind: 'b' },
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
spec: { c: 'some' },
|
||||
},
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
spec: { c: null },
|
||||
},
|
||||
];
|
||||
|
||||
await catalog.transaction(async tx => {
|
||||
for (const entity of entities) {
|
||||
await catalog.addEntity(tx, { entity });
|
||||
}
|
||||
});
|
||||
|
||||
const rows = await catalog.transaction(async tx =>
|
||||
catalog.entities(tx, [
|
||||
{ key: 'kind', values: ['b'] },
|
||||
{ key: 'spec.c', values: [null, 'some'] },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(rows.length).toEqual(3);
|
||||
expect(rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
locationId: undefined,
|
||||
entity: expect.objectContaining(entities[0]),
|
||||
},
|
||||
{
|
||||
locationId: undefined,
|
||||
entity: expect.objectContaining(entities[1]),
|
||||
},
|
||||
{
|
||||
locationId: undefined,
|
||||
entity: expect.objectContaining(entities[2]),
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ import Knex from 'knex';
|
||||
import lodash from 'lodash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Logger } from 'winston';
|
||||
import { EntityFilters } from '../catalog';
|
||||
import { DescriptorEnvelope, EntityMeta } from '../ingestion';
|
||||
import { buildEntitySearch } from './search';
|
||||
import {
|
||||
@@ -187,7 +188,8 @@ export class Database {
|
||||
}
|
||||
|
||||
const newEntity = lodash.cloneDeep(request.entity);
|
||||
newEntity.metadata = Object.assign({}, newEntity.metadata, {
|
||||
newEntity.metadata = {
|
||||
...newEntity.metadata,
|
||||
uid: generateUid(),
|
||||
etag: generateEtag(),
|
||||
generation: 1,
|
||||
@@ -195,7 +197,7 @@ export class Database {
|
||||
...(newEntity.metadata?.annotations ?? {}),
|
||||
'backstage.io/managed-by-location': request.locationId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const newRow = toEntityRow(request.locationId, newEntity);
|
||||
await tx<DbEntitiesRow>('entities').insert(newRow);
|
||||
@@ -280,11 +282,12 @@ export class Database {
|
||||
? oldRow.generation
|
||||
: oldRow.generation + 1;
|
||||
const newEntity = lodash.cloneDeep(request.entity);
|
||||
newEntity.metadata = Object.assign({}, request.entity.metadata, {
|
||||
newEntity.metadata = {
|
||||
...newEntity.metadata,
|
||||
uid: oldRow.id,
|
||||
etag: newEtag,
|
||||
generation: newGeneration,
|
||||
});
|
||||
};
|
||||
|
||||
// Preserve annotations that were set on the old version of the entity,
|
||||
// unless the new version overwrites them
|
||||
@@ -314,10 +317,30 @@ export class Database {
|
||||
return { locationId: request.locationId, entity: newEntity };
|
||||
}
|
||||
|
||||
async entities(tx: Knex.Transaction<any, any>): Promise<DbEntityResponse[]> {
|
||||
const rows = await tx<DbEntitiesRow>('entities')
|
||||
async entities(
|
||||
tx: Knex.Transaction<any, any>,
|
||||
filters?: EntityFilters,
|
||||
): Promise<DbEntityResponse[]> {
|
||||
let builder = tx<DbEntitiesRow>('entities');
|
||||
for (const [index, filter] of (filters ?? []).entries()) {
|
||||
builder = builder
|
||||
.leftOuterJoin(`entities_search as t${index}`, function join() {
|
||||
this.on('entities.id', '=', `t${index}.entity_id`).onIn(
|
||||
`t${index}.value`,
|
||||
filter.values.filter(x => x),
|
||||
);
|
||||
if (filter.values.some(x => !x)) {
|
||||
this.orOnNull(`t${index}.value`);
|
||||
}
|
||||
})
|
||||
.where(`t${index}.key`, '=', filter.key);
|
||||
}
|
||||
|
||||
const rows = await builder
|
||||
.orderBy('namespace', 'name')
|
||||
.select();
|
||||
.select('entities.*')
|
||||
.groupBy('id');
|
||||
|
||||
return rows.map(row => toEntityResponse(row));
|
||||
}
|
||||
|
||||
@@ -341,9 +364,7 @@ export class Database {
|
||||
async addLocation(location: AddDatabaseLocation): Promise<DbLocationsRow> {
|
||||
return await this.database.transaction<DbLocationsRow>(async tx => {
|
||||
const existingLocation = await tx<DbLocationsRow>('locations')
|
||||
.where({
|
||||
target: location.target,
|
||||
})
|
||||
.where({ target: location.target })
|
||||
.select();
|
||||
|
||||
if (existingLocation?.[0]) {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import Knex from 'knex';
|
||||
import {
|
||||
ComponentDescriptor,
|
||||
DescriptorParser,
|
||||
@@ -28,7 +29,6 @@ import {
|
||||
DbLocationsRow,
|
||||
DbLocationsRowWithStatus,
|
||||
} from './types';
|
||||
import Knex from 'knex';
|
||||
|
||||
describe('DatabaseManager', () => {
|
||||
describe('refreshLocations', () => {
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { EntitiesCatalog, Location, LocationsCatalog } from '../catalog';
|
||||
import { DescriptorEnvelope } from '../ingestion';
|
||||
import { createRouter } from './router';
|
||||
|
||||
class MockEntitiesCatalog implements EntitiesCatalog {
|
||||
entities = jest.fn();
|
||||
entityByUid = jest.fn();
|
||||
entityByName = jest.fn();
|
||||
}
|
||||
|
||||
class MockLocationsCatalog implements LocationsCatalog {
|
||||
addLocation = jest.fn();
|
||||
removeLocation = jest.fn();
|
||||
locations = jest.fn();
|
||||
location = jest.fn();
|
||||
}
|
||||
|
||||
describe('createRouter', () => {
|
||||
describe('entities', () => {
|
||||
it('happy path: lists entities', async () => {
|
||||
const entities: DescriptorEnvelope[] = [{ apiVersion: 'a', kind: 'b' }];
|
||||
|
||||
const catalog = new MockEntitiesCatalog();
|
||||
catalog.entities.mockResolvedValueOnce(entities);
|
||||
|
||||
const router = await createRouter({
|
||||
entitiesCatalog: catalog,
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
const app = express().use(router);
|
||||
const response = await request(app).get('/entities');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(entities);
|
||||
});
|
||||
|
||||
it('parses single and multiple request parameters and passes them down', async () => {
|
||||
const catalog = new MockEntitiesCatalog();
|
||||
|
||||
const router = await createRouter({
|
||||
entitiesCatalog: catalog,
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
const app = express().use(router);
|
||||
const response = await request(app).get('/entities?a=1&a=&a=3&b=4&c=');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(catalog.entities).toHaveBeenCalledWith([
|
||||
{ key: 'a', values: ['1', null, '3'] },
|
||||
{ key: 'b', values: ['4'] },
|
||||
{ key: 'c', values: [null] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('entityByUid', () => {
|
||||
it('can fetch entity by uid', async () => {
|
||||
const entity: DescriptorEnvelope = {
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
metadata: {
|
||||
name: 'c',
|
||||
},
|
||||
};
|
||||
const catalog = new MockEntitiesCatalog();
|
||||
catalog.entityByUid.mockResolvedValue(entity);
|
||||
|
||||
const router = await createRouter({
|
||||
entitiesCatalog: catalog,
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
const app = express().use(router);
|
||||
const response = await request(app).get('/entities/by-uid/zzz');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(expect.objectContaining(entity));
|
||||
});
|
||||
|
||||
it('responds with a 404 for missing entities', async () => {
|
||||
const catalog = new MockEntitiesCatalog();
|
||||
catalog.entityByUid.mockResolvedValue(undefined);
|
||||
|
||||
const router = await createRouter({
|
||||
entitiesCatalog: catalog,
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
const app = express().use(router);
|
||||
const response = await request(app).get('/entities/by-uid/zzz');
|
||||
|
||||
expect(response.status).toEqual(404);
|
||||
expect(response.text).toMatch(/uid/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('entityByName', () => {
|
||||
it('can fetch entity by name', async () => {
|
||||
const entity: DescriptorEnvelope = {
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
metadata: {
|
||||
name: 'c',
|
||||
namespace: 'd',
|
||||
},
|
||||
};
|
||||
const catalog = new MockEntitiesCatalog();
|
||||
catalog.entityByName.mockResolvedValue(entity);
|
||||
|
||||
const router = await createRouter({
|
||||
entitiesCatalog: catalog,
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
const app = express().use(router);
|
||||
const response = await request(app).get('/entities/by-name/b/d/c');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(expect.objectContaining(entity));
|
||||
});
|
||||
|
||||
it('responds with a 404 for missing entities', async () => {
|
||||
const catalog = new MockEntitiesCatalog();
|
||||
catalog.entityByName.mockResolvedValue(undefined);
|
||||
|
||||
const router = await createRouter({
|
||||
entitiesCatalog: catalog,
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
const app = express().use(router);
|
||||
const response = await request(app).get('/entities/by-name//b/d/c');
|
||||
|
||||
expect(response.status).toEqual(404);
|
||||
expect(response.text).toMatch(/name/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('locations', () => {
|
||||
it('happy path: lists locations', async () => {
|
||||
const locations: Location[] = [{ id: 'a', type: 'b', target: 'c' }];
|
||||
|
||||
const catalog = new MockLocationsCatalog();
|
||||
catalog.locations.mockResolvedValueOnce(locations);
|
||||
|
||||
const router = await createRouter({
|
||||
locationsCatalog: catalog,
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
const app = express().use(router);
|
||||
const response = await request(app).get('/locations');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(locations);
|
||||
});
|
||||
|
||||
it('rejects malformed locations', async () => {
|
||||
const location = ({
|
||||
id: 'a',
|
||||
typez: 'b',
|
||||
target: 'c',
|
||||
} as unknown) as Location;
|
||||
|
||||
const catalog = new MockLocationsCatalog();
|
||||
const router = await createRouter({
|
||||
locationsCatalog: catalog,
|
||||
logger: getVoidLogger(),
|
||||
});
|
||||
|
||||
const app = express().use(router);
|
||||
const response = await request(app).post('/locations').send(location);
|
||||
|
||||
expect(response.status).toEqual(400);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,12 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { errorHandler, InputError } from '@backstage/backend-common';
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import { Logger } from 'winston';
|
||||
import {
|
||||
addLocationSchema,
|
||||
EntitiesCatalog,
|
||||
EntityFilters,
|
||||
LocationsCatalog,
|
||||
} from '../catalog';
|
||||
import { validateRequestBody } from './util';
|
||||
@@ -34,17 +36,43 @@ export async function createRouter(
|
||||
options: RouterOptions,
|
||||
): Promise<express.Router> {
|
||||
const { entitiesCatalog, locationsCatalog } = options;
|
||||
|
||||
const router = Router();
|
||||
router.use(express.json());
|
||||
|
||||
if (entitiesCatalog) {
|
||||
// Entities
|
||||
router.get('/entities', async (_req, res) => {
|
||||
const entities = await entitiesCatalog.entities();
|
||||
res.status(200).send(entities);
|
||||
});
|
||||
router
|
||||
.get('/entities', async (req, res) => {
|
||||
const filters = translateQueryToEntityFilters(req);
|
||||
const entities = await entitiesCatalog.entities(filters);
|
||||
res.status(200).send(entities);
|
||||
})
|
||||
.get('/entities/by-uid/:uid', async (req, res) => {
|
||||
const { uid } = req.params;
|
||||
const entity = await entitiesCatalog.entityByUid(uid);
|
||||
if (!entity) {
|
||||
res.status(404).send(`No entity with uid ${uid}`);
|
||||
}
|
||||
res.status(200).send(entity);
|
||||
})
|
||||
.get('/entities/by-name/:kind/:namespace/:name', async (req, res) => {
|
||||
const { kind, namespace, name } = req.params;
|
||||
const entity = await entitiesCatalog.entityByName(
|
||||
kind,
|
||||
name,
|
||||
namespace,
|
||||
);
|
||||
if (!entity) {
|
||||
res
|
||||
.status(404)
|
||||
.send(
|
||||
`No entity with kind ${kind} namespace ${namespace} name ${name}`,
|
||||
);
|
||||
}
|
||||
res.status(200).send(entity);
|
||||
});
|
||||
}
|
||||
|
||||
// Locations
|
||||
if (locationsCatalog) {
|
||||
router
|
||||
.post('/locations', async (req, res) => {
|
||||
@@ -73,5 +101,29 @@ export async function createRouter(
|
||||
});
|
||||
}
|
||||
|
||||
router.use(errorHandler());
|
||||
return router;
|
||||
}
|
||||
|
||||
function translateQueryToEntityFilters(
|
||||
request: express.Request,
|
||||
): EntityFilters {
|
||||
const filters: EntityFilters = [];
|
||||
|
||||
for (const [key, valueOrValues] of Object.entries(request.query)) {
|
||||
const values = Array.isArray(valueOrValues)
|
||||
? valueOrValues
|
||||
: [valueOrValues];
|
||||
|
||||
if (values.some(v => typeof v !== 'string')) {
|
||||
throw new InputError('Complex query parameters are not supported');
|
||||
}
|
||||
|
||||
filters.push({
|
||||
key,
|
||||
values: values.map(v => v || null) as string[],
|
||||
});
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { wrapInThemedTestApp } from '@backstage/test-utils';
|
||||
import { CatalogFilter, CatalogFilterGroup } from './CatalogFilter';
|
||||
|
||||
describe('Catalog Filter', () => {
|
||||
it('should render the different groups', async () => {
|
||||
const mockGroups: CatalogFilterGroup[] = [
|
||||
{ name: 'Test Group 1', items: [] },
|
||||
{ name: 'Test Group 2', items: [] },
|
||||
];
|
||||
const { findByText } = render(
|
||||
wrapInThemedTestApp(<CatalogFilter groups={mockGroups} />),
|
||||
);
|
||||
|
||||
for (const group of mockGroups) {
|
||||
expect(await findByText(group.name)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('should render the different items and their names', async () => {
|
||||
const mockGroups: CatalogFilterGroup[] = [
|
||||
{
|
||||
name: 'Test Group 1',
|
||||
items: [
|
||||
{
|
||||
id: 'first',
|
||||
label: 'First Label',
|
||||
},
|
||||
{
|
||||
id: 'second',
|
||||
label: 'Second Label',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const { findByText } = render(
|
||||
wrapInThemedTestApp(<CatalogFilter groups={mockGroups} />),
|
||||
);
|
||||
|
||||
const [group] = mockGroups;
|
||||
for (const item of group.items) {
|
||||
expect(await findByText(item.label)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('should render the count in each item', async () => {
|
||||
const mockGroups: CatalogFilterGroup[] = [
|
||||
{
|
||||
name: 'Test Group 1',
|
||||
items: [
|
||||
{
|
||||
id: 'first',
|
||||
label: 'First Label',
|
||||
count: 100,
|
||||
},
|
||||
{
|
||||
id: 'second',
|
||||
label: 'Second Label',
|
||||
count: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const { findByText } = render(
|
||||
wrapInThemedTestApp(<CatalogFilter groups={mockGroups} />),
|
||||
);
|
||||
|
||||
const [group] = mockGroups;
|
||||
for (const item of group.items) {
|
||||
expect(await findByText(item.count!.toString())).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it('should fire the callback when an item is clicked', async () => {
|
||||
const mockGroups: CatalogFilterGroup[] = [
|
||||
{
|
||||
name: 'Test Group 1',
|
||||
items: [
|
||||
{
|
||||
id: 'first',
|
||||
label: 'First Label',
|
||||
count: 100,
|
||||
},
|
||||
{
|
||||
id: 'second',
|
||||
label: 'Second Label',
|
||||
count: 400,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const onSelectedChangeHandler = jest.fn();
|
||||
|
||||
const { findByText } = render(
|
||||
wrapInThemedTestApp(
|
||||
<CatalogFilter
|
||||
groups={mockGroups}
|
||||
onSelectedChange={onSelectedChangeHandler}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
const item = mockGroups[0].items[0];
|
||||
|
||||
const element = await findByText(item.label);
|
||||
|
||||
fireEvent.click(element);
|
||||
|
||||
expect(onSelectedChangeHandler).toHaveBeenCalledWith(item);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import {
|
||||
Card,
|
||||
List,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
Typography,
|
||||
Theme,
|
||||
makeStyles,
|
||||
} from '@material-ui/core';
|
||||
import type { IconComponent } from '@backstage/core';
|
||||
|
||||
export type CatalogFilterItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon?: IconComponent;
|
||||
count?: number;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export type CatalogFilterGroup = {
|
||||
name: string;
|
||||
items: CatalogFilterItem[];
|
||||
};
|
||||
|
||||
export type CatalogFilterProps = {
|
||||
groups: CatalogFilterGroup[];
|
||||
selectedId?: string;
|
||||
onSelectedChange?: (item: CatalogFilterItem) => void;
|
||||
};
|
||||
|
||||
const useStyles = makeStyles<Theme>(theme => ({
|
||||
root: {
|
||||
backgroundColor: 'rgba(0, 0, 0, .11)',
|
||||
boxShadow: 'none',
|
||||
},
|
||||
title: {
|
||||
margin: theme.spacing(1, 0, 0, 1),
|
||||
textTransform: 'uppercase',
|
||||
fontSize: 12,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
listIcon: {
|
||||
minWidth: 30,
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
menuItem: {
|
||||
minHeight: theme.spacing(6),
|
||||
},
|
||||
groupWrapper: {
|
||||
margin: theme.spacing(1, 1, 2, 1),
|
||||
},
|
||||
menuTitle: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
}));
|
||||
|
||||
export const CatalogFilter: React.FC<CatalogFilterProps> = ({
|
||||
groups,
|
||||
selectedId,
|
||||
onSelectedChange,
|
||||
}) => {
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<Card className={classes.root}>
|
||||
{groups.map(group => (
|
||||
<React.Fragment key={group.name}>
|
||||
<Typography variant="subtitle2" className={classes.title}>
|
||||
{group.name}
|
||||
</Typography>
|
||||
<Card className={classes.groupWrapper}>
|
||||
<List disablePadding dense>
|
||||
{group.items.map(item => (
|
||||
<MenuItem
|
||||
key={item.id}
|
||||
button
|
||||
divider
|
||||
onClick={() => onSelectedChange?.(item)}
|
||||
selected={item.id === selectedId}
|
||||
className={classes.menuItem}
|
||||
>
|
||||
{item.icon && (
|
||||
<ListItemIcon className={classes.listIcon}>
|
||||
<item.icon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
)}
|
||||
<ListItemText>
|
||||
<Typography variant="body1" className={classes.menuTitle}>
|
||||
{item.label}
|
||||
</Typography>
|
||||
</ListItemText>
|
||||
{item.count}
|
||||
</MenuItem>
|
||||
))}
|
||||
</List>
|
||||
</Card>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { CatalogFilter } from './CatalogFilter';
|
||||
@@ -17,8 +17,7 @@
|
||||
import React from 'react';
|
||||
import { render } from '@testing-library/react';
|
||||
import CatalogPage from './CatalogPage';
|
||||
import { ThemeProvider } from '@material-ui/core';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import { wrapInThemedTestApp } from '@backstage/test-utils';
|
||||
import { ComponentFactory } from '../../data/component';
|
||||
|
||||
const testComponentFactory: ComponentFactory = {
|
||||
@@ -28,11 +27,14 @@ const testComponentFactory: ComponentFactory = {
|
||||
};
|
||||
|
||||
describe('CatalogPage', () => {
|
||||
// this test right now causes some red lines in the log output when running tests
|
||||
// related to some theme issues in mui-table
|
||||
// https://github.com/mbrn/material-table/issues/1293
|
||||
it('should render', async () => {
|
||||
const rendered = render(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<CatalogPage componentFactory={testComponentFactory} />
|
||||
</ThemeProvider>,
|
||||
wrapInThemedTestApp(
|
||||
<CatalogPage componentFactory={testComponentFactory} />,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
await rendered.findByText('Keep track of your software'),
|
||||
|
||||
@@ -27,13 +27,38 @@ import {
|
||||
import { useAsync } from 'react-use';
|
||||
import { ComponentFactory } from '../../data/component';
|
||||
import CatalogTable from '../CatalogTable/CatalogTable';
|
||||
import { Button } from '@material-ui/core';
|
||||
import {
|
||||
CatalogFilter,
|
||||
CatalogFilterItem,
|
||||
} from '../CatalogFilter/CatalogFilter';
|
||||
import { Button, makeStyles } from '@material-ui/core';
|
||||
import { filterGroups, defaultFilter } from '../../data/filters';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
contentWrapper: {
|
||||
display: 'grid',
|
||||
gridTemplateAreas: "'filters' 'table'",
|
||||
gridTemplateColumns: '250px 1fr',
|
||||
gridColumnGap: theme.spacing(2),
|
||||
},
|
||||
}));
|
||||
|
||||
type CatalogPageProps = {
|
||||
componentFactory: ComponentFactory;
|
||||
};
|
||||
|
||||
const CatalogPage: FC<CatalogPageProps> = ({ componentFactory }) => {
|
||||
const { value, error, loading } = useAsync(componentFactory.getAllComponents);
|
||||
const [selectedFilter, setSelectedFilter] = React.useState<CatalogFilterItem>(
|
||||
defaultFilter,
|
||||
);
|
||||
|
||||
const onFilterSelected = React.useCallback(
|
||||
selected => setSelectedFilter(selected),
|
||||
[],
|
||||
);
|
||||
|
||||
const styles = useStyles();
|
||||
return (
|
||||
<Page theme={pageTheme.home}>
|
||||
<Header title="Service Catalog" subtitle="Keep track of your software">
|
||||
@@ -46,11 +71,21 @@ const CatalogPage: FC<CatalogPageProps> = ({ componentFactory }) => {
|
||||
</Button>
|
||||
<SupportButton>All your components</SupportButton>
|
||||
</ContentHeader>
|
||||
<CatalogTable
|
||||
components={value || []}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
<div className={styles.contentWrapper}>
|
||||
<div>
|
||||
<CatalogFilter
|
||||
groups={filterGroups}
|
||||
selectedId={selectedFilter.id}
|
||||
onSelectedChange={onFilterSelected}
|
||||
/>
|
||||
</div>
|
||||
<CatalogTable
|
||||
titlePreamble={selectedFilter.label}
|
||||
components={value || []}
|
||||
loading={loading}
|
||||
error={error}
|
||||
/>
|
||||
</div>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,9 @@ const components: Component[] = [
|
||||
describe('CatalogTable component', () => {
|
||||
it('should render loading when loading prop it set to true', async () => {
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(<CatalogTable components={[]} loading />),
|
||||
wrapInThemedTestApp(
|
||||
<CatalogTable titlePreamble="Owned" components={[]} loading />,
|
||||
),
|
||||
);
|
||||
const progress = await rendered.findByTestId('progress');
|
||||
expect(progress).toBeInTheDocument();
|
||||
@@ -38,6 +40,7 @@ describe('CatalogTable component', () => {
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(
|
||||
<CatalogTable
|
||||
titlePreamble="Owned"
|
||||
components={[]}
|
||||
loading={false}
|
||||
error={{ code: 'error' }}
|
||||
@@ -53,9 +56,16 @@ describe('CatalogTable component', () => {
|
||||
it('should display component names when loading has finished and no error occurred', async () => {
|
||||
const rendered = render(
|
||||
wrapInThemedTestApp(
|
||||
<CatalogTable components={components} loading={false} />,
|
||||
<CatalogTable
|
||||
titlePreamble="Owned"
|
||||
components={components}
|
||||
loading={false}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
await rendered.findByText(`Owned (${components.length})`),
|
||||
).toBeInTheDocument();
|
||||
expect(await rendered.findByText('component1')).toBeInTheDocument();
|
||||
expect(await rendered.findByText('component2')).toBeInTheDocument();
|
||||
expect(await rendered.findByText('component3')).toBeInTheDocument();
|
||||
|
||||
@@ -63,6 +63,7 @@ const columns: TableColumn[] = [
|
||||
|
||||
type CatalogTableProps = {
|
||||
components: Component[];
|
||||
titlePreamble: string;
|
||||
loading: boolean;
|
||||
error?: any;
|
||||
};
|
||||
@@ -70,6 +71,7 @@ const CatalogTable: FC<CatalogTableProps> = ({
|
||||
components,
|
||||
loading,
|
||||
error,
|
||||
titlePreamble,
|
||||
}) => {
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
@@ -87,7 +89,7 @@ const CatalogTable: FC<CatalogTableProps> = ({
|
||||
<Table
|
||||
columns={columns}
|
||||
options={{ paging: false }}
|
||||
title={`Owned (${(components && components.length) || 0})`}
|
||||
title={`${titlePreamble} (${(components && components.length) || 0})`}
|
||||
data={components}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {
|
||||
CatalogFilterGroup,
|
||||
CatalogFilterItem,
|
||||
} from '../components/CatalogFilter/CatalogFilter';
|
||||
import SettingsIcon from '@material-ui/icons/Settings';
|
||||
import StarIcon from '@material-ui/icons/Star';
|
||||
|
||||
export const filterGroups: CatalogFilterGroup[] = [
|
||||
{
|
||||
name: 'Personal',
|
||||
items: [
|
||||
{
|
||||
id: 'owned',
|
||||
label: 'Owned',
|
||||
count: 123,
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
{
|
||||
id: 'starred',
|
||||
label: 'Starred',
|
||||
count: 10,
|
||||
icon: StarIcon,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Spotify',
|
||||
items: [
|
||||
{
|
||||
id: 'all',
|
||||
label: 'All Services',
|
||||
count: 123,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const defaultFilter: CatalogFilterItem = filterGroups[0].items[0];
|
||||
@@ -9,7 +9,7 @@
|
||||
dependencies:
|
||||
"@babel/highlight" "^7.0.0"
|
||||
|
||||
"@babel/code-frame@7.8.3", "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.8.3":
|
||||
"@babel/code-frame@7.8.3", "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.5.5", "@babel/code-frame@^7.8.3":
|
||||
version "7.8.3"
|
||||
resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.8.3.tgz#33e25903d7481181534e12ec0a25f16b6fcf419e"
|
||||
integrity sha512-a9gxpmdXtZEInkCSHUJDLHZVBgb1QS0jhss4cPP93EW7s+uC5bikET2twEF3KV+7rDblJcmNvTR7VJejqd2C2g==
|
||||
@@ -3861,6 +3861,13 @@
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/cookie-parser@^1.4.2":
|
||||
version "1.4.2"
|
||||
resolved "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.2.tgz#e4d5c5ffda82b80672a88a4281aaceefb1bd9df5"
|
||||
integrity sha512-uwcY8m6SDQqciHsqcKDGbo10GdasYsPCYkH3hVegj9qAah6pX5HivOnOuI3WYmyQMnOATV39zv/Ybs0bC/6iVg==
|
||||
dependencies:
|
||||
"@types/express" "*"
|
||||
|
||||
"@types/cookiejar@*":
|
||||
version "2.1.1"
|
||||
resolved "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.1.tgz#90b68446364baf9efd8e8349bb36bd3852b75b80"
|
||||
@@ -4181,7 +4188,7 @@
|
||||
resolved "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
|
||||
integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==
|
||||
|
||||
"@types/passport-google-oauth20@2.0.3":
|
||||
"@types/passport-google-oauth20@^2.0.3":
|
||||
version "2.0.3"
|
||||
resolved "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.3.tgz#f554ff6d39f395acff3f1d762e54462194dac8da"
|
||||
integrity sha512-6EUEGzEg4acwowvgR/yVZIj8S2Kkwc6JmlY2/wnM1wJHNz20o7s1TIGrxnah8ymLgJasYDpy95P3TMMqlmetPw==
|
||||
@@ -4190,7 +4197,15 @@
|
||||
"@types/passport" "*"
|
||||
"@types/passport-oauth2" "*"
|
||||
|
||||
"@types/passport-oauth2@*":
|
||||
"@types/passport-oauth2-refresh@^1.1.1":
|
||||
version "1.1.1"
|
||||
resolved "https://registry.npmjs.org/@types/passport-oauth2-refresh/-/passport-oauth2-refresh-1.1.1.tgz#cbe466d4fcac36182fd75bf55279c0b1e953c382"
|
||||
integrity sha512-Tw0JvfDPv9asgFPACd9oOGCaD/0/Uyi+QF7fmrJC74cJKC6I8N8wwhJJHyfd1N2E/qaLgTh431lhOa9jicpNdg==
|
||||
dependencies:
|
||||
"@types/oauth" "*"
|
||||
"@types/passport-oauth2" "*"
|
||||
|
||||
"@types/passport-oauth2@*", "@types/passport-oauth2@^1.4.9":
|
||||
version "1.4.9"
|
||||
resolved "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.4.9.tgz#134007c4b505a82548c9cb19094c5baeb2205c92"
|
||||
integrity sha512-QP0q+NVQOaIu2r0e10QWkiUA0Ya5mOBHRJN0UrI+LolMLOP1/VN4EVIpJ3xVwFo+xqNFRoFvFwJhBvKnk7kpUA==
|
||||
@@ -4199,7 +4214,7 @@
|
||||
"@types/oauth" "*"
|
||||
"@types/passport" "*"
|
||||
|
||||
"@types/passport@*", "@types/passport@1.0.3":
|
||||
"@types/passport@*", "@types/passport@^1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.npmjs.org/@types/passport/-/passport-1.0.3.tgz#e459ed6c262bf0686684d1b05901be0d0b192a9c"
|
||||
integrity sha512-nyztuxtDPQv9utCzU0qW7Gl8BY2Dn8BKlYAFFyxKipFxjaVd96celbkLCV/tRqqBUZ+JB8If3UfgV8347DTo3Q==
|
||||
@@ -4495,10 +4510,10 @@
|
||||
dependencies:
|
||||
source-map "^0.6.1"
|
||||
|
||||
"@types/uuid@^7.0.3":
|
||||
version "7.0.3"
|
||||
resolved "https://registry.npmjs.org/@types/uuid/-/uuid-7.0.3.tgz#45cd03e98e758f8581c79c535afbd4fc27ba7ac8"
|
||||
integrity sha512-PUdqTZVrNYTNcIhLHkiaYzoOIaUi5LFg/XLerAdgvwQrUCx+oSbtoBze1AMyvYbcwzUSNC+Isl58SM4Sm/6COw==
|
||||
"@types/uuid@^8.0.0":
|
||||
version "8.0.0"
|
||||
resolved "https://registry.npmjs.org/@types/uuid/-/uuid-8.0.0.tgz#165aae4819ad2174a17476dbe66feebd549556c0"
|
||||
integrity sha512-xSQfNcvOiE5f9dyd4Kzxbof1aTrLobL278pGLKOZI6esGfZ7ts9Ka16CzIN6Y8hFHE1C7jIBZokULhK1bOgjRw==
|
||||
|
||||
"@types/webpack-dev-server@*", "@types/webpack-dev-server@^3.10.0":
|
||||
version "3.10.1"
|
||||
@@ -7241,6 +7256,14 @@ convert-source-map@^1.4.0, convert-source-map@^1.5.0, convert-source-map@^1.6.0,
|
||||
dependencies:
|
||||
safe-buffer "~5.1.1"
|
||||
|
||||
cookie-parser@^1.4.5:
|
||||
version "1.4.5"
|
||||
resolved "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.5.tgz#3e572d4b7c0c80f9c61daf604e4336831b5d1d49"
|
||||
integrity sha512-f13bPUj/gG/5mDr+xLmSxxDsB9DQiTIfhJS/sqjrmfAWiAN+x2O4i/XguTL9yDZ+/IFDanJ+5x7hC4CXT9Tdzw==
|
||||
dependencies:
|
||||
cookie "0.4.0"
|
||||
cookie-signature "1.0.6"
|
||||
|
||||
cookie-signature@1.0.6:
|
||||
version "1.0.6"
|
||||
resolved "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
|
||||
@@ -9773,11 +9796,11 @@ fork-ts-checker-webpack-plugin@3.1.1:
|
||||
worker-rpc "^0.1.0"
|
||||
|
||||
fork-ts-checker-webpack-plugin@^4.0.5:
|
||||
version "4.1.0"
|
||||
resolved "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-4.1.0.tgz#62bffe704426770fee33f15f0c0d56c86297fefd"
|
||||
integrity sha512-2DLwUVUR/AdNmMD2utfmSR8r4qHRFhnfL6QQDQS5q4g5uBZzXYDgg8MXPIbu0HzyLjyvbogqjBNKILG5fufwzg==
|
||||
version "4.1.5"
|
||||
resolved "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-4.1.5.tgz#780d52c65183742d8c885fff42a9ec9ea7006672"
|
||||
integrity sha512-nuD4IDqoOfkEIlS6shhjLGaLBDSNyVJulAlr5lFbPe0saGqlsTo+/HmhtIrs/cNLFtmaudL10byivhxr+Qhh4w==
|
||||
dependencies:
|
||||
babel-code-frame "^6.22.0"
|
||||
"@babel/code-frame" "^7.5.5"
|
||||
chalk "^2.4.1"
|
||||
micromatch "^3.1.10"
|
||||
minimatch "^3.0.4"
|
||||
@@ -10880,10 +10903,10 @@ html-encoding-sniffer@^2.0.1:
|
||||
dependencies:
|
||||
whatwg-encoding "^1.0.5"
|
||||
|
||||
html-entities@^1.2.0, html-entities@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.npmjs.org/html-entities/-/html-entities-1.2.1.tgz#0df29351f0721163515dfb9e5543e5f6eed5162f"
|
||||
integrity sha1-DfKTUfByEWNRXfueVUPl9u7VFi8=
|
||||
html-entities@^1.2.0, html-entities@^1.3.1:
|
||||
version "1.3.1"
|
||||
resolved "https://registry.npmjs.org/html-entities/-/html-entities-1.3.1.tgz#fb9a1a4b5b14c5daba82d3e34c6ae4fe701a0e44"
|
||||
integrity sha512-rhE/4Z3hIhzHAUKbW8jVcCyuT5oJCXXqhN/6mXXVCpzTmvJnoH2HL/bt3EZ6p55jbFJBeAe1ZNpL5BugLujxNA==
|
||||
|
||||
html-escaper@^2.0.0:
|
||||
version "2.0.1"
|
||||
@@ -13935,10 +13958,10 @@ logform@^2.1.1:
|
||||
ms "^2.1.1"
|
||||
triple-beam "^1.3.0"
|
||||
|
||||
loglevel@^1.6.6:
|
||||
version "1.6.7"
|
||||
resolved "https://registry.npmjs.org/loglevel/-/loglevel-1.6.7.tgz#b3e034233188c68b889f5b862415306f565e2c56"
|
||||
integrity sha512-cY2eLFrQSAfVPhCgH1s7JI73tMbg9YC3v3+ZHVW67sBS7UxWzNEk/ZBbSfLykBWHp33dqqtOv82gjhKEi81T/A==
|
||||
loglevel@^1.6.8:
|
||||
version "1.6.8"
|
||||
resolved "https://registry.npmjs.org/loglevel/-/loglevel-1.6.8.tgz#8a25fb75d092230ecd4457270d80b54e28011171"
|
||||
integrity sha512-bsU7+gc9AJ2SqpzxwU3+1fedl8zAntbtC5XYlt3s2j1hJcn2PsXSmgN8TaLG/J1/2mod4+cE/3vNL70/c1RNCA==
|
||||
|
||||
lolex@^5.0.0:
|
||||
version "5.1.2"
|
||||
@@ -15571,7 +15594,7 @@ os-locale@^2.0.0:
|
||||
lcid "^1.0.0"
|
||||
mem "^1.1.0"
|
||||
|
||||
os-locale@^3.0.0, os-locale@^3.1.0:
|
||||
os-locale@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.npmjs.org/os-locale/-/os-locale-3.1.0.tgz#a802a6ee17f24c10483ab9935719cef4ed16bf1a"
|
||||
integrity sha512-Z8l3R4wYWM40/52Z+S265okfFj8Kt2cC2MKY+xNi3kFs+XGI7WXu/I309QQQYbRW4ijiZ+yxs9pqEhJh0DqW3Q==
|
||||
@@ -15987,14 +16010,19 @@ pascalcase@^0.1.1:
|
||||
resolved "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz#b363e55e8006ca6fe21784d2db22bd15d7917f14"
|
||||
integrity sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=
|
||||
|
||||
passport-google-oauth20@2.0.0:
|
||||
passport-google-oauth20@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz#0d241b2d21ebd3dc7f2b60669ec4d587e3a674ef"
|
||||
integrity sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==
|
||||
dependencies:
|
||||
passport-oauth2 "1.x.x"
|
||||
|
||||
passport-oauth2@1.x.x:
|
||||
passport-oauth2-refresh@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.npmjs.org/passport-oauth2-refresh/-/passport-oauth2-refresh-2.0.0.tgz#7b19c77ff3cc000819c69f6ad9e318450f57b85e"
|
||||
integrity sha512-yXvCB6nem/O+WThhiyI3TlPXpzSGY+9+hy9OTx9QF8e9GInplyRHxHaaOhFylKvnof9UmWHAufQFZk8cO1Fb2g==
|
||||
|
||||
passport-oauth2@1.x.x, passport-oauth2@^1.5.0:
|
||||
version "1.5.0"
|
||||
resolved "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.5.0.tgz#64babbb54ac46a4dcab35e7f266ed5294e3c4108"
|
||||
integrity sha512-kqBt6vR/5VlCK8iCx1/KpY42kQ+NEHZwsSyt4Y6STiNjU+wWICG1i8ucc1FapXDGO15C5O5VZz7+7vRzrDPXXQ==
|
||||
@@ -16010,7 +16038,7 @@ passport-strategy@1.x.x:
|
||||
resolved "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz#b5539aa8fc225a3d1ad179476ddf236b440f52e4"
|
||||
integrity sha1-tVOaqPwiWj0a0XlHbd8ja0QPUuQ=
|
||||
|
||||
passport@0.4.1:
|
||||
passport@^0.4.1:
|
||||
version "0.4.1"
|
||||
resolved "https://registry.npmjs.org/passport/-/passport-0.4.1.tgz#941446a21cb92fc688d97a0861c38ce9f738f270"
|
||||
integrity sha512-IxXgZZs8d7uFSt3eqNjM9NQ3g3uQCW5avD8mRNoXV99Yig50vjuaez6dQK2qC0kVWPRTujxY0dWgGfT09adjYg==
|
||||
@@ -16278,10 +16306,10 @@ popper.js@^1.14.1, popper.js@^1.14.4, popper.js@^1.14.7:
|
||||
resolved "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz#2a223cb3dc7b6213d740e40372be40de43e65b1b"
|
||||
integrity sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==
|
||||
|
||||
portfinder@^1.0.25:
|
||||
version "1.0.25"
|
||||
resolved "https://registry.npmjs.org/portfinder/-/portfinder-1.0.25.tgz#254fd337ffba869f4b9d37edc298059cb4d35eca"
|
||||
integrity sha512-6ElJnHBbxVA1XSLgBp7G1FiCkQdlqGzuF7DswL5tcea+E8UpuvPU7beVAjjRwCioTS9ZluNbu+ZyRvgTsmqEBg==
|
||||
portfinder@^1.0.26:
|
||||
version "1.0.26"
|
||||
resolved "https://registry.npmjs.org/portfinder/-/portfinder-1.0.26.tgz#475658d56ca30bed72ac7f1378ed350bd1b64e70"
|
||||
integrity sha512-Xi7mKxJHHMI3rIUrnm/jjUgwhbYMkp/XKEcZX3aG4BrumLpq3nmoQMX+ClYnDZnZ/New7IatC1no5RX0zo1vXQ==
|
||||
dependencies:
|
||||
async "^2.6.2"
|
||||
debug "^3.1.1"
|
||||
@@ -18342,9 +18370,9 @@ rollup@^0.63.4:
|
||||
"@types/node" "*"
|
||||
|
||||
rollup@^2.3.2:
|
||||
version "2.3.2"
|
||||
resolved "https://registry.npmjs.org/rollup/-/rollup-2.3.2.tgz#afa68e4f3325bcef4e150d082056bef450bcac60"
|
||||
integrity sha512-p66+fbfaUUOGE84sHXAOgfeaYQMslgAazoQMp//nlR519R61213EPFgrMZa48j31jNacJwexSAR1Q8V/BwGKBA==
|
||||
version "2.10.9"
|
||||
resolved "https://registry.npmjs.org/rollup/-/rollup-2.10.9.tgz#17dcc6753c619efcc1be2cf61d73a87827eebdf9"
|
||||
integrity sha512-dY/EbjiWC17ZCUSyk14hkxATAMAShkMsD43XmZGWjLrgFj15M3Dw2kEkA9ns64BiLFm9PKN6vTQw8neHwK74eg==
|
||||
optionalDependencies:
|
||||
fsevents "~2.1.2"
|
||||
|
||||
@@ -18476,15 +18504,7 @@ schema-utils@^1.0.0:
|
||||
ajv-errors "^1.0.0"
|
||||
ajv-keywords "^3.1.0"
|
||||
|
||||
schema-utils@^2.0.1, schema-utils@^2.5.0, schema-utils@^2.6.0, schema-utils@^2.6.4:
|
||||
version "2.6.5"
|
||||
resolved "https://registry.npmjs.org/schema-utils/-/schema-utils-2.6.5.tgz#c758f0a7e624263073d396e29cd40aa101152d8a"
|
||||
integrity sha512-5KXuwKziQrTVHh8j/Uxz+QUbxkaLW9X/86NBlx/gnKgtsZA2GIVMUn17qWhRFwF8jdYb3Dig5hRO/W5mZqy6SQ==
|
||||
dependencies:
|
||||
ajv "^6.12.0"
|
||||
ajv-keywords "^3.4.1"
|
||||
|
||||
schema-utils@^2.6.5, schema-utils@^2.6.6:
|
||||
schema-utils@^2.0.1, schema-utils@^2.5.0, schema-utils@^2.6.0, schema-utils@^2.6.4, schema-utils@^2.6.5, schema-utils@^2.6.6:
|
||||
version "2.6.6"
|
||||
resolved "https://registry.npmjs.org/schema-utils/-/schema-utils-2.6.6.tgz#299fe6bd4a3365dc23d99fd446caff8f1d6c330c"
|
||||
integrity sha512-wHutF/WPSbIi9x6ctjGGk2Hvl0VOz5l3EKEuKbjPlB30mKZUzb9A5k9yEXRX3pwyqVLPvpfZZEllaFq/M718hA==
|
||||
@@ -18914,13 +18934,14 @@ sockjs-client@1.4.0:
|
||||
json3 "^3.3.2"
|
||||
url-parse "^1.4.3"
|
||||
|
||||
sockjs@0.3.19:
|
||||
version "0.3.19"
|
||||
resolved "https://registry.npmjs.org/sockjs/-/sockjs-0.3.19.tgz#d976bbe800af7bd20ae08598d582393508993c0d"
|
||||
integrity sha512-V48klKZl8T6MzatbLlzzRNhMepEys9Y4oGFpypBFFn1gLI/QQ9HtLLyWJNbPlwGLelOVOEijUbTTJeLLI59jLw==
|
||||
sockjs@0.3.20:
|
||||
version "0.3.20"
|
||||
resolved "https://registry.npmjs.org/sockjs/-/sockjs-0.3.20.tgz#b26a283ec562ef8b2687b44033a4eeceac75d855"
|
||||
integrity sha512-SpmVOVpdq0DJc0qArhF3E5xsxvaiqGNb73XfgBpK1y3UD5gs8DSo8aCTsuT5pX8rssdc2NDIzANwP9eCAiSdTA==
|
||||
dependencies:
|
||||
faye-websocket "^0.10.0"
|
||||
uuid "^3.0.1"
|
||||
uuid "^3.4.0"
|
||||
websocket-driver "0.6.5"
|
||||
|
||||
socks-proxy-agent@^4.0.0:
|
||||
version "4.0.2"
|
||||
@@ -19067,10 +19088,10 @@ spdy-transport@^3.0.0:
|
||||
readable-stream "^3.0.6"
|
||||
wbuf "^1.7.3"
|
||||
|
||||
spdy@^4.0.1:
|
||||
version "4.0.1"
|
||||
resolved "https://registry.npmjs.org/spdy/-/spdy-4.0.1.tgz#6f12ed1c5db7ea4f24ebb8b89ba58c87c08257f2"
|
||||
integrity sha512-HeZS3PBdMA+sZSu0qwpCxl3DeALD5ASx8pAX0jZdKXSpPWbQ6SYGnlg3BBmYLx5LtiZrmkAZfErCm2oECBcioA==
|
||||
spdy@^4.0.2:
|
||||
version "4.0.2"
|
||||
resolved "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz#b74f466203a3eda452c02492b91fb9e84a27677b"
|
||||
integrity sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==
|
||||
dependencies:
|
||||
debug "^4.1.0"
|
||||
handle-thing "^2.0.0"
|
||||
@@ -20441,15 +20462,10 @@ typedarray@^0.0.6:
|
||||
resolved "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
|
||||
integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
|
||||
|
||||
typescript@^3.7.4:
|
||||
version "3.8.3"
|
||||
resolved "https://registry.npmjs.org/typescript/-/typescript-3.8.3.tgz#409eb8544ea0335711205869ec458ab109ee1061"
|
||||
integrity sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w==
|
||||
|
||||
typescript@^3.9.2:
|
||||
version "3.9.2"
|
||||
resolved "https://registry.npmjs.org/typescript/-/typescript-3.9.2.tgz#64e9c8e9be6ea583c54607677dd4680a1cf35db9"
|
||||
integrity sha512-q2ktq4n/uLuNNShyayit+DTobV2ApPEo/6so68JaD5ojvc/6GClBipedB9zNWYxRSAlZXAe405Rlijzl6qDiSw==
|
||||
typescript@^3.7.4, typescript@^3.9.2:
|
||||
version "3.9.3"
|
||||
resolved "https://registry.npmjs.org/typescript/-/typescript-3.9.3.tgz#d3ac8883a97c26139e42df5e93eeece33d610b8a"
|
||||
integrity sha512-D/wqnB2xzNFIcoBG9FG8cXRDjiqSTbG2wd8DMZeQyJlP1vfTkIxH4GKveWaEBYySKIg+USu+E+EDIR47SqnaMQ==
|
||||
|
||||
uc.micro@^1.0.1, uc.micro@^1.0.5:
|
||||
version "1.0.6"
|
||||
@@ -20869,7 +20885,7 @@ utils-merge@1.0.1, utils-merge@1.x.x:
|
||||
resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
|
||||
integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=
|
||||
|
||||
uuid@^3.0.1, uuid@^3.3.2, uuid@^3.3.3:
|
||||
uuid@^3.0.1, uuid@^3.3.2, uuid@^3.3.3, uuid@^3.4.0:
|
||||
version "3.4.0"
|
||||
resolved "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
|
||||
integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
|
||||
@@ -21094,9 +21110,9 @@ webpack-dev-middleware@^3.7.0, webpack-dev-middleware@^3.7.2:
|
||||
webpack-log "^2.0.0"
|
||||
|
||||
webpack-dev-server@^3.10.3:
|
||||
version "3.10.3"
|
||||
resolved "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-3.10.3.tgz#f35945036813e57ef582c2420ef7b470e14d3af0"
|
||||
integrity sha512-e4nWev8YzEVNdOMcNzNeCN947sWJNd43E5XvsJzbAL08kGc2frm1tQ32hTJslRS+H65LCb/AaUCYU7fjHCpDeQ==
|
||||
version "3.11.0"
|
||||
resolved "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-3.11.0.tgz#8f154a3bce1bcfd1cc618ef4e703278855e7ff8c"
|
||||
integrity sha512-PUxZ+oSTxogFQgkTtFndEtJIPNmml7ExwufBZ9L2/Xyyd5PnOL5UreWe5ZT7IU25DSdykL9p1MLQzmLh2ljSeg==
|
||||
dependencies:
|
||||
ansi-html "0.0.7"
|
||||
bonjour "^3.5.0"
|
||||
@@ -21106,31 +21122,31 @@ webpack-dev-server@^3.10.3:
|
||||
debug "^4.1.1"
|
||||
del "^4.1.1"
|
||||
express "^4.17.1"
|
||||
html-entities "^1.2.1"
|
||||
html-entities "^1.3.1"
|
||||
http-proxy-middleware "0.19.1"
|
||||
import-local "^2.0.0"
|
||||
internal-ip "^4.3.0"
|
||||
ip "^1.1.5"
|
||||
is-absolute-url "^3.0.3"
|
||||
killable "^1.0.1"
|
||||
loglevel "^1.6.6"
|
||||
loglevel "^1.6.8"
|
||||
opn "^5.5.0"
|
||||
p-retry "^3.0.1"
|
||||
portfinder "^1.0.25"
|
||||
portfinder "^1.0.26"
|
||||
schema-utils "^1.0.0"
|
||||
selfsigned "^1.10.7"
|
||||
semver "^6.3.0"
|
||||
serve-index "^1.9.1"
|
||||
sockjs "0.3.19"
|
||||
sockjs "0.3.20"
|
||||
sockjs-client "1.4.0"
|
||||
spdy "^4.0.1"
|
||||
spdy "^4.0.2"
|
||||
strip-ansi "^3.0.1"
|
||||
supports-color "^6.1.0"
|
||||
url "^0.11.0"
|
||||
webpack-dev-middleware "^3.7.2"
|
||||
webpack-log "^2.0.0"
|
||||
ws "^6.2.1"
|
||||
yargs "12.0.5"
|
||||
yargs "^13.3.2"
|
||||
|
||||
webpack-hot-middleware@^2.25.0:
|
||||
version "2.25.0"
|
||||
@@ -21194,6 +21210,13 @@ webpack@^4.33.0, webpack@^4.38.0, webpack@^4.41.6:
|
||||
watchpack "^1.6.1"
|
||||
webpack-sources "^1.4.1"
|
||||
|
||||
websocket-driver@0.6.5:
|
||||
version "0.6.5"
|
||||
resolved "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.6.5.tgz#5cb2556ceb85f4373c6d8238aa691c8454e13a36"
|
||||
integrity sha1-XLJVbOuF9Dc8bYI4qmkchFThOjY=
|
||||
dependencies:
|
||||
websocket-extensions ">=0.1.1"
|
||||
|
||||
websocket-driver@>=0.5.1:
|
||||
version "0.7.3"
|
||||
resolved "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.3.tgz#a2d4e0d4f4f116f1e6297eba58b05d430100e9f9"
|
||||
@@ -21467,12 +21490,7 @@ ws@^6.1.2, ws@^6.2.1:
|
||||
dependencies:
|
||||
async-limiter "~1.0.0"
|
||||
|
||||
ws@^7.0.0:
|
||||
version "7.2.3"
|
||||
resolved "https://registry.npmjs.org/ws/-/ws-7.2.3.tgz#a5411e1fb04d5ed0efee76d26d5c46d830c39b46"
|
||||
integrity sha512-HTDl9G9hbkNDk98naoR/cHDws7+EyYMOdL1BmjsZXRUjf7d+MficC4B7HLUPlSiho0vg+CWKrGIt/VJBd1xunQ==
|
||||
|
||||
ws@^7.2.3:
|
||||
ws@^7.0.0, ws@^7.2.3:
|
||||
version "7.3.0"
|
||||
resolved "https://registry.npmjs.org/ws/-/ws-7.3.0.tgz#4b2f7f219b3d3737bc1a2fbf145d825b94d38ffd"
|
||||
integrity sha512-iFtXzngZVXPGgpTlP1rBqsUK82p9tKqsWRPg5L56egiljujJT3vGAYnHANvFxBieXrTFavhzhxW52jnaWV+w2w==
|
||||
@@ -21529,7 +21547,7 @@ y18n@^3.2.1:
|
||||
resolved "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz#6d15fba884c08679c0d77e88e7759e811e07fa41"
|
||||
integrity sha1-bRX7qITAhnnA136I53WegR4H+kE=
|
||||
|
||||
"y18n@^3.2.1 || ^4.0.0", y18n@^4.0.0:
|
||||
y18n@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b"
|
||||
integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==
|
||||
@@ -21578,10 +21596,10 @@ yargs-parser@^10.0.0:
|
||||
dependencies:
|
||||
camelcase "^4.1.0"
|
||||
|
||||
yargs-parser@^11.1.1:
|
||||
version "11.1.1"
|
||||
resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-11.1.1.tgz#879a0865973bca9f6bab5cbdf3b1c67ec7d3bcf4"
|
||||
integrity sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ==
|
||||
yargs-parser@^13.1.2:
|
||||
version "13.1.2"
|
||||
resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz#130f09702ebaeef2650d54ce6e3e5706f7a4fb38"
|
||||
integrity sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==
|
||||
dependencies:
|
||||
camelcase "^5.0.0"
|
||||
decamelize "^1.2.0"
|
||||
@@ -21624,24 +21642,6 @@ yargs-parser@^9.0.2:
|
||||
dependencies:
|
||||
camelcase "^4.1.0"
|
||||
|
||||
yargs@12.0.5:
|
||||
version "12.0.5"
|
||||
resolved "https://registry.npmjs.org/yargs/-/yargs-12.0.5.tgz#05f5997b609647b64f66b81e3b4b10a368e7ad13"
|
||||
integrity sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw==
|
||||
dependencies:
|
||||
cliui "^4.0.0"
|
||||
decamelize "^1.2.0"
|
||||
find-up "^3.0.0"
|
||||
get-caller-file "^1.0.1"
|
||||
os-locale "^3.0.0"
|
||||
require-directory "^2.1.1"
|
||||
require-main-filename "^1.0.1"
|
||||
set-blocking "^2.0.0"
|
||||
string-width "^2.0.0"
|
||||
which-module "^2.0.0"
|
||||
y18n "^3.2.1 || ^4.0.0"
|
||||
yargs-parser "^11.1.1"
|
||||
|
||||
yargs@^11.0.0:
|
||||
version "11.1.1"
|
||||
resolved "https://registry.npmjs.org/yargs/-/yargs-11.1.1.tgz#5052efe3446a4df5ed669c995886cc0f13702766"
|
||||
@@ -21660,6 +21660,22 @@ yargs@^11.0.0:
|
||||
y18n "^3.2.1"
|
||||
yargs-parser "^9.0.2"
|
||||
|
||||
yargs@^13.3.2:
|
||||
version "13.3.2"
|
||||
resolved "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd"
|
||||
integrity sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==
|
||||
dependencies:
|
||||
cliui "^5.0.0"
|
||||
find-up "^3.0.0"
|
||||
get-caller-file "^2.0.1"
|
||||
require-directory "^2.1.1"
|
||||
require-main-filename "^2.0.0"
|
||||
set-blocking "^2.0.0"
|
||||
string-width "^3.0.0"
|
||||
which-module "^2.0.0"
|
||||
y18n "^4.0.0"
|
||||
yargs-parser "^13.1.2"
|
||||
|
||||
yargs@^14.2.2:
|
||||
version "14.2.3"
|
||||
resolved "https://registry.npmjs.org/yargs/-/yargs-14.2.3.tgz#1a1c3edced1afb2a2fea33604bc6d1d8d688a414"
|
||||
|
||||
Reference in New Issue
Block a user