Merge branch 'master' of github.com:spotify/backstage into mob/register-unregister-components
This commit is contained in:
@@ -19,6 +19,14 @@ read -r AUTH_GOOGLE_CLIENT_SECRET
|
||||
export AUTH_GOOGLE_CLIENT_SECRET
|
||||
run `yarn start` in packages/backend folder
|
||||
|
||||
### SAML
|
||||
|
||||
To try out SAML, you can use the mock identity provider:
|
||||
|
||||
```bash
|
||||
./scripts/start-saml-idp.sh
|
||||
```
|
||||
|
||||
## Links
|
||||
|
||||
- (The Backstage homepage)[https://backstage.io]
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@types/passport": "^1.0.3",
|
||||
"@types/passport-github2": "^1.2.4",
|
||||
"@types/passport-google-oauth20": "^2.0.3",
|
||||
"body-parser": "^1.19.0",
|
||||
"compression": "^1.7.4",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"cors": "^2.8.5",
|
||||
@@ -31,11 +32,14 @@
|
||||
"passport": "^0.4.1",
|
||||
"passport-github2": "^0.1.12",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-saml": "^1.3.3",
|
||||
"winston": "^3.2.1",
|
||||
"yn": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.6",
|
||||
"@types/body-parser": "^1.19.0",
|
||||
"@types/passport-saml": "^1.1.2",
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"tsc-watch": "^4.2.3"
|
||||
},
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
*.pem
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
|
||||
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
|
||||
|
||||
cd "$DIR"
|
||||
|
||||
if [[ ! -f idp-public-cert.pem ]]; then
|
||||
echo "Generating new SAML Certificates"
|
||||
openssl req \
|
||||
-x509 \
|
||||
-newkey rsa:1024 \
|
||||
-days 3650 \
|
||||
-nodes \
|
||||
-subj '/CN=localhost' \
|
||||
-keyout "idp-private-key.pem" \
|
||||
-out "idp-public-cert.pem"
|
||||
fi
|
||||
|
||||
echo "Downloading and starting SAML-IdP"
|
||||
export NPM_CONFIG_REGISTRY=https://registry.npmjs.org
|
||||
exec npx saml-idp --acsUrl "http://localhost:7000/auth/saml/handler/frame" --audience "http://localhost:7000" --port 7001
|
||||
@@ -0,0 +1,364 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import {
|
||||
ensuresXRequestedWith,
|
||||
postMessageResponse,
|
||||
removeRefreshTokenCookie,
|
||||
setRefreshTokenCookie,
|
||||
THOUSAND_DAYS_MS,
|
||||
setNonceCookie,
|
||||
TEN_MINUTES_MS,
|
||||
verifyNonce,
|
||||
OAuthProvider,
|
||||
} from './OAuthProvider';
|
||||
import { AuthResponse, OAuthProviderHandlers } from './types';
|
||||
|
||||
describe('OAuthProvider Utils', () => {
|
||||
describe('verifyNonce', () => {
|
||||
it('should throw error if cookie nonce missing', () => {
|
||||
const mockRequest = ({
|
||||
cookies: {},
|
||||
query: {
|
||||
state: 'NONCE',
|
||||
},
|
||||
} as unknown) as express.Request;
|
||||
expect(() => {
|
||||
verifyNonce(mockRequest, 'providera');
|
||||
}).toThrowError('Missing nonce');
|
||||
});
|
||||
it('should throw error if state nonce missing', () => {
|
||||
const mockRequest = ({
|
||||
cookies: {
|
||||
'providera-nonce': 'NONCE',
|
||||
},
|
||||
query: {},
|
||||
} as unknown) as express.Request;
|
||||
expect(() => {
|
||||
verifyNonce(mockRequest, 'providera');
|
||||
}).toThrowError('Missing nonce');
|
||||
});
|
||||
it('should throw error if nonce mismatch', () => {
|
||||
const mockRequest = ({
|
||||
cookies: {
|
||||
'providera-nonce': 'NONCEA',
|
||||
},
|
||||
query: {
|
||||
state: 'NONCEB',
|
||||
},
|
||||
} as unknown) as express.Request;
|
||||
expect(() => {
|
||||
verifyNonce(mockRequest, 'providera');
|
||||
}).toThrowError('Invalid nonce');
|
||||
});
|
||||
it('should not throw any error if nonce matches', () => {
|
||||
const mockRequest = ({
|
||||
cookies: {
|
||||
'providera-nonce': 'NONCE',
|
||||
},
|
||||
query: {
|
||||
state: 'NONCE',
|
||||
},
|
||||
} as unknown) as express.Request;
|
||||
expect(() => {
|
||||
verifyNonce(mockRequest, 'providera');
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setNonceCookie', () => {
|
||||
it('should set nonce cookie', () => {
|
||||
const mockResponse = ({
|
||||
cookie: jest.fn().mockReturnThis(),
|
||||
} as unknown) as express.Response;
|
||||
setNonceCookie(mockResponse, 'providera');
|
||||
expect(mockResponse.cookie).toBeCalledTimes(1);
|
||||
expect(mockResponse.cookie).toBeCalledWith(
|
||||
'providera-nonce',
|
||||
expect.any(String),
|
||||
expect.objectContaining({ maxAge: TEN_MINUTES_MS }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setRefreshTokenCookie', () => {
|
||||
it('should set refresh token cookie', () => {
|
||||
const mockResponse = ({
|
||||
cookie: jest.fn().mockReturnThis(),
|
||||
} as unknown) as express.Response;
|
||||
setRefreshTokenCookie(mockResponse, 'providera', 'REFRESH_TOKEN');
|
||||
expect(mockResponse.cookie).toBeCalledTimes(1);
|
||||
expect(mockResponse.cookie).toBeCalledWith(
|
||||
'providera-refresh-token',
|
||||
'REFRESH_TOKEN',
|
||||
expect.objectContaining({ maxAge: THOUSAND_DAYS_MS }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeRefreshTokenCookie', () => {
|
||||
it('should remove refresh token cookie', () => {
|
||||
const mockResponse = ({
|
||||
cookie: jest.fn().mockReturnThis(),
|
||||
} as unknown) as express.Response;
|
||||
removeRefreshTokenCookie(mockResponse, 'providera');
|
||||
expect(mockResponse.cookie).toBeCalledTimes(1);
|
||||
expect(mockResponse.cookie).toBeCalledWith(
|
||||
'providera-refresh-token',
|
||||
'',
|
||||
expect.objectContaining({ maxAge: 0 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('postMessageResponse', () => {
|
||||
it('should post a message back with payload success', () => {
|
||||
const mockResponse = ({
|
||||
end: jest.fn().mockReturnThis(),
|
||||
setHeader: jest.fn().mockReturnThis(),
|
||||
} as unknown) as express.Response;
|
||||
|
||||
const data: AuthResponse = {
|
||||
type: 'auth-result',
|
||||
payload: {
|
||||
accessToken: 'ACCESS_TOKEN',
|
||||
idToken: 'ID_TOKEN',
|
||||
expiresInSeconds: 10,
|
||||
scope: 'email',
|
||||
},
|
||||
};
|
||||
const jsonData = JSON.stringify(data);
|
||||
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
|
||||
|
||||
postMessageResponse(mockResponse, data);
|
||||
expect(mockResponse.setHeader).toBeCalledTimes(2);
|
||||
expect(mockResponse.end).toBeCalledTimes(1);
|
||||
expect(mockResponse.end).toBeCalledWith(
|
||||
expect.stringContaining(base64Data),
|
||||
);
|
||||
});
|
||||
|
||||
it('should post a message back with payload error', () => {
|
||||
const mockResponse = ({
|
||||
end: jest.fn().mockReturnThis(),
|
||||
setHeader: jest.fn().mockReturnThis(),
|
||||
} as unknown) as express.Response;
|
||||
|
||||
const data: AuthResponse = {
|
||||
type: 'auth-result',
|
||||
error: new Error('Unknown error occured'),
|
||||
};
|
||||
const jsonData = JSON.stringify(data);
|
||||
const base64Data = Buffer.from(jsonData, 'utf8').toString('base64');
|
||||
|
||||
postMessageResponse(mockResponse, data);
|
||||
expect(mockResponse.setHeader).toBeCalledTimes(2);
|
||||
expect(mockResponse.end).toBeCalledTimes(1);
|
||||
expect(mockResponse.end).toBeCalledWith(
|
||||
expect.stringContaining(base64Data),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensuresXRequestedWith', () => {
|
||||
it('should return false if no header present', () => {
|
||||
const mockRequest = ({
|
||||
header: () => jest.fn(),
|
||||
} as unknown) as express.Request;
|
||||
expect(ensuresXRequestedWith(mockRequest)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false if header present with incorrect value', () => {
|
||||
const mockRequest = ({
|
||||
header: () => 'INVALID',
|
||||
} as unknown) as express.Request;
|
||||
expect(ensuresXRequestedWith(mockRequest)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true if header present with correct value', () => {
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
} as unknown) as express.Request;
|
||||
expect(ensuresXRequestedWith(mockRequest)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('OAuthProvider', () => {
|
||||
class MyAuthProvider implements OAuthProviderHandlers {
|
||||
async start() {
|
||||
return {
|
||||
url: '/url',
|
||||
status: 301,
|
||||
};
|
||||
}
|
||||
async handler() {
|
||||
return {
|
||||
user: {},
|
||||
info: {
|
||||
refreshToken: 'token',
|
||||
},
|
||||
};
|
||||
}
|
||||
async refresh() {
|
||||
return {
|
||||
accessToken: 'token',
|
||||
};
|
||||
}
|
||||
}
|
||||
const providerInstance = new MyAuthProvider();
|
||||
const providerId = 'test-provider';
|
||||
|
||||
it('sets the correct headers in start', async () => {
|
||||
const oauthProvider = new OAuthProvider(providerInstance, providerId);
|
||||
const mockRequest = ({
|
||||
query: {
|
||||
scope: 'user',
|
||||
},
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const mockResponse = ({
|
||||
cookie: jest.fn().mockReturnThis(),
|
||||
end: jest.fn().mockReturnThis(),
|
||||
setHeader: jest.fn().mockReturnThis(),
|
||||
statusCode: jest.fn().mockReturnThis(),
|
||||
} as unknown) as express.Response;
|
||||
|
||||
await oauthProvider.start(mockRequest, mockResponse);
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledTimes(2);
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledWith('Location', '/url');
|
||||
expect(mockResponse.setHeader).toHaveBeenCalledWith('Content-Length', '0');
|
||||
expect(mockResponse.statusCode).toEqual(301);
|
||||
expect(mockResponse.end).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sets the refresh cookie if refresh is enabled', async () => {
|
||||
const oauthProvider = new OAuthProvider(providerInstance, providerId);
|
||||
|
||||
const mockRequest = ({
|
||||
cookies: {
|
||||
'test-provider-nonce': 'nonce',
|
||||
},
|
||||
query: {
|
||||
state: 'nonce',
|
||||
},
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const mockResponse = ({
|
||||
cookie: jest.fn().mockReturnThis(),
|
||||
setHeader: jest.fn().mockReturnThis(),
|
||||
end: jest.fn().mockReturnThis(),
|
||||
} as unknown) as express.Response;
|
||||
|
||||
await oauthProvider.frameHandler(mockRequest, mockResponse);
|
||||
expect(mockResponse.cookie).toHaveBeenCalledTimes(1);
|
||||
expect(mockResponse.cookie).toHaveBeenCalledWith(
|
||||
expect.stringContaining('test-provider-refresh-token'),
|
||||
expect.stringContaining('token'),
|
||||
expect.objectContaining({ path: '/auth/test-provider' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does no set the refresh cookie if refresh is disabled', async () => {
|
||||
const oauthProvider = new OAuthProvider(providerInstance, providerId, true);
|
||||
|
||||
const mockRequest = ({
|
||||
cookies: {
|
||||
'test-provider-nonce': 'nonce',
|
||||
},
|
||||
query: {
|
||||
state: 'nonce',
|
||||
},
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const mockResponse = ({
|
||||
cookie: jest.fn().mockReturnThis(),
|
||||
setHeader: jest.fn().mockReturnThis(),
|
||||
end: jest.fn().mockReturnThis(),
|
||||
} as unknown) as express.Response;
|
||||
|
||||
await oauthProvider.frameHandler(mockRequest, mockResponse);
|
||||
expect(mockResponse.cookie).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('removes refresh cookie when logging out', async () => {
|
||||
const oauthProvider = new OAuthProvider(providerInstance, providerId);
|
||||
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const mockResponse = ({
|
||||
cookie: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
} as unknown) as express.Response;
|
||||
|
||||
await oauthProvider.logout(mockRequest, mockResponse);
|
||||
expect(mockResponse.cookie).toHaveBeenCalledTimes(1);
|
||||
expect(mockResponse.cookie).toHaveBeenCalledWith(
|
||||
expect.stringContaining('test-provider-refresh-token'),
|
||||
'',
|
||||
expect.objectContaining({ path: '/auth/test-provider' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('gets new access-token when refreshing', async () => {
|
||||
const oauthProvider = new OAuthProvider(providerInstance, providerId);
|
||||
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
cookies: {
|
||||
'test-provider-refresh-token': 'token',
|
||||
},
|
||||
query: {},
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const mockResponse = ({
|
||||
send: jest.fn().mockReturnThis(),
|
||||
} as unknown) as express.Response;
|
||||
|
||||
await oauthProvider.refresh(mockRequest, mockResponse);
|
||||
expect(mockResponse.send).toHaveBeenCalledTimes(1);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accessToken: 'token',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('handles refresh without capabilities', async () => {
|
||||
const oauthProvider = new OAuthProvider(providerInstance, providerId, true);
|
||||
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
cookies: {
|
||||
'test-provider-refresh-token': 'token',
|
||||
},
|
||||
query: {},
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const mockResponse = ({
|
||||
send: jest.fn().mockReturnThis(),
|
||||
} as unknown) as express.Response;
|
||||
|
||||
await oauthProvider.refresh(mockRequest, mockResponse);
|
||||
expect(mockResponse.send).toHaveBeenCalledTimes(1);
|
||||
expect(mockResponse.send).toHaveBeenCalledWith(
|
||||
'Refresh token not supported for provider: test-provider',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -55,7 +55,7 @@ export const executeFrameHandlerStrategy = async (
|
||||
reject(new Error('Unexpected redirect'));
|
||||
};
|
||||
|
||||
strategy.authenticate(req);
|
||||
strategy.authenticate(req, {});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -32,4 +32,12 @@ export const providers = [
|
||||
},
|
||||
disableRefresh: true,
|
||||
},
|
||||
{
|
||||
provider: 'saml',
|
||||
options: {
|
||||
path: '/auth/saml/handler/frame',
|
||||
entryPoint: 'http://localhost:7001/',
|
||||
issuer: 'passport-saml',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -14,37 +14,37 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
AuthProviderFactories,
|
||||
AuthProviderRouteHandlers,
|
||||
AuthProviderConfig,
|
||||
} from './types';
|
||||
import { GoogleAuthProvider } from './google';
|
||||
import { GithubAuthProvider } from './github';
|
||||
import { OAuthProvider } from './OAuthProvider';
|
||||
import Router from 'express-promise-router';
|
||||
import { createGithubProvider } from './github';
|
||||
import { createGoogleProvider } from './google';
|
||||
import { createSamlProvider } from './saml';
|
||||
import { AuthProviderFactory, AuthProviderConfig } from './types';
|
||||
|
||||
export class ProviderFactories {
|
||||
private static readonly providerFactories: AuthProviderFactories = {
|
||||
google: GoogleAuthProvider,
|
||||
github: GithubAuthProvider,
|
||||
};
|
||||
const factories: { [providerId: string]: AuthProviderFactory } = {
|
||||
google: createGoogleProvider,
|
||||
github: createGithubProvider,
|
||||
saml: createSamlProvider,
|
||||
};
|
||||
|
||||
public static getProviderFactory(
|
||||
config: AuthProviderConfig,
|
||||
): AuthProviderRouteHandlers {
|
||||
const providerId = config.provider;
|
||||
const ProviderImpl = ProviderFactories.providerFactories[providerId];
|
||||
if (!ProviderImpl) {
|
||||
throw Error(
|
||||
`Provider Implementation missing for : ${providerId} auth provider`,
|
||||
);
|
||||
}
|
||||
const providerInstance = new ProviderImpl(config);
|
||||
const oauthProvider = new OAuthProvider(
|
||||
providerInstance,
|
||||
providerId,
|
||||
config.disableRefresh,
|
||||
);
|
||||
return oauthProvider;
|
||||
export function createAuthProvider(providerId: string, config: any) {
|
||||
const factory = factories[providerId];
|
||||
if (!factory) {
|
||||
throw Error(`No auth provider available for '${providerId}'`);
|
||||
}
|
||||
return factory(config);
|
||||
}
|
||||
|
||||
export const createAuthProviderRouter = (config: AuthProviderConfig) => {
|
||||
const providerId = config.provider;
|
||||
const provider = createAuthProvider(providerId, config);
|
||||
|
||||
const router = Router();
|
||||
router.get('/start', provider.start.bind(provider));
|
||||
router.get('/handler/frame', provider.frameHandler.bind(provider));
|
||||
router.post('/handler/frame', provider.frameHandler.bind(provider));
|
||||
router.post('/logout', provider.logout.bind(provider));
|
||||
if (provider.refresh) {
|
||||
router.get('/refresh', provider.refresh.bind(provider));
|
||||
}
|
||||
return router;
|
||||
};
|
||||
|
||||
@@ -14,4 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { GithubAuthProvider } from './provider';
|
||||
export { createGithubProvider } from './provider';
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
AuthInfoBase,
|
||||
AuthInfoPrivate,
|
||||
} from '../types';
|
||||
import { OAuthProvider } from '../OAuthProvider';
|
||||
|
||||
export class GithubAuthProvider implements OAuthProviderHandlers {
|
||||
private readonly providerConfig: AuthProviderConfig;
|
||||
@@ -57,3 +58,9 @@ export class GithubAuthProvider implements OAuthProviderHandlers {
|
||||
return await executeFrameHandlerStrategy(req, this._strategy);
|
||||
}
|
||||
}
|
||||
|
||||
export function createGithubProvider(config: AuthProviderConfig) {
|
||||
const provider = new GithubAuthProvider(config);
|
||||
const oauthProvider = new OAuthProvider(provider, config.provider, true);
|
||||
return oauthProvider;
|
||||
}
|
||||
|
||||
@@ -14,4 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { GoogleAuthProvider } from './provider';
|
||||
export { createGoogleProvider } from './provider';
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
RedirectInfo,
|
||||
AuthProviderConfig,
|
||||
} from '../types';
|
||||
import { OAuthProvider } from '../OAuthProvider';
|
||||
|
||||
export class GoogleAuthProvider implements OAuthProviderHandlers {
|
||||
private readonly providerConfig: AuthProviderConfig;
|
||||
@@ -87,3 +88,9 @@ export class GoogleAuthProvider implements OAuthProviderHandlers {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createGoogleProvider(config: AuthProviderConfig) {
|
||||
const provider = new GoogleAuthProvider(config);
|
||||
const oauthProvider = new OAuthProvider(provider, config.provider);
|
||||
return oauthProvider;
|
||||
}
|
||||
|
||||
@@ -14,24 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import Router from 'express-promise-router';
|
||||
import { AuthProviderRouteHandlers, AuthProviderConfig } from './types';
|
||||
import { ProviderFactories } from './factories';
|
||||
|
||||
export const defaultRouter = (provider: AuthProviderRouteHandlers) => {
|
||||
const router = Router();
|
||||
router.get('/start', provider.start.bind(provider));
|
||||
router.get('/handler/frame', provider.frameHandler.bind(provider));
|
||||
router.get('/logout', provider.logout.bind(provider));
|
||||
if (provider.refresh) {
|
||||
router.get('/refresh', provider.refresh.bind(provider));
|
||||
}
|
||||
return router;
|
||||
};
|
||||
|
||||
export const makeProvider = (config: AuthProviderConfig) => {
|
||||
const providerId = config.provider;
|
||||
const oauthProvider = ProviderFactories.getProviderFactory(config);
|
||||
const providerRouter = defaultRouter(oauthProvider);
|
||||
return { providerId, providerRouter };
|
||||
};
|
||||
export { createAuthProviderRouter } from './factories';
|
||||
|
||||
+1
-7
@@ -14,10 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { defaultRouter } from '.';
|
||||
|
||||
describe('test', () => {
|
||||
it('unbreaks the test runner', () => {
|
||||
expect(defaultRouter).toBeDefined();
|
||||
});
|
||||
});
|
||||
export { createSamlProvider } from './provider';
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import express from 'express';
|
||||
import { Strategy as SamlStrategy } from 'passport-saml';
|
||||
import {
|
||||
executeFrameHandlerStrategy,
|
||||
executeRedirectStrategy,
|
||||
} from '../PassportStrategyHelper';
|
||||
import { AuthProviderConfig, AuthProviderRouteHandlers } from '../types';
|
||||
import { postMessageResponse } from '../OAuthProvider';
|
||||
|
||||
export class SamlAuthProvider implements AuthProviderRouteHandlers {
|
||||
private readonly strategy: SamlStrategy;
|
||||
|
||||
constructor(providerConfig: AuthProviderConfig) {
|
||||
this.strategy = new SamlStrategy(
|
||||
{ ...providerConfig.options },
|
||||
(profile: any, done: any) => {
|
||||
// TODO: There's plenty more validation and profile handling to do here,
|
||||
// this provider is currently only intended to validate the provider pattern
|
||||
// for non-oauth auth flows.
|
||||
// TODO: This flow doesn't issue an identity token that can be used to validate
|
||||
// the identity of the user in other backends, which we need in some form.
|
||||
done(undefined, {
|
||||
email: profile.email,
|
||||
firstName: profile.firstName,
|
||||
lastName: profile.lastName,
|
||||
displayName: profile.displayName,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async start(req: express.Request, res: express.Response): Promise<any> {
|
||||
const { url } = await executeRedirectStrategy(req, this.strategy, {});
|
||||
res.redirect(url);
|
||||
}
|
||||
|
||||
async frameHandler(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
): Promise<any> {
|
||||
try {
|
||||
const { user } = await executeFrameHandlerStrategy(req, this.strategy);
|
||||
|
||||
return postMessageResponse(res, {
|
||||
type: 'auth-result',
|
||||
payload: user,
|
||||
});
|
||||
} catch (error) {
|
||||
return postMessageResponse(res, {
|
||||
type: 'auth-result',
|
||||
error: {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async logout(_req: express.Request, res: express.Response): Promise<any> {
|
||||
res.send('noop');
|
||||
}
|
||||
}
|
||||
|
||||
export function createSamlProvider(config: AuthProviderConfig) {
|
||||
return new SamlAuthProvider(config);
|
||||
}
|
||||
@@ -37,13 +37,9 @@ export interface AuthProviderRouteHandlers {
|
||||
logout(req: express.Request, res: express.Response): Promise<any>;
|
||||
}
|
||||
|
||||
export type AuthProviderFactories = {
|
||||
[key: string]: AuthProviderFactory;
|
||||
};
|
||||
|
||||
export type AuthProviderFactory = {
|
||||
new (providerConfig: any): OAuthProviderHandlers;
|
||||
};
|
||||
export type AuthProviderFactory = (
|
||||
config: AuthProviderConfig,
|
||||
) => AuthProviderRouteHandlers;
|
||||
|
||||
export type AuthInfoBase = {
|
||||
accessToken: string;
|
||||
|
||||
@@ -17,9 +17,10 @@
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import bodyParser from 'body-parser';
|
||||
import { Logger } from 'winston';
|
||||
import { providers } from './../providers/config';
|
||||
import { makeProvider } from '../providers';
|
||||
import { createAuthProviderRouter } from '../providers';
|
||||
|
||||
export interface RouterOptions {
|
||||
logger: Logger;
|
||||
@@ -32,12 +33,15 @@ export async function createRouter(
|
||||
const logger = options.logger.child({ plugin: 'auth' });
|
||||
|
||||
router.use(cookieParser());
|
||||
router.use(bodyParser.urlencoded({ extended: false }));
|
||||
router.use(bodyParser.json());
|
||||
|
||||
// configure all the providers
|
||||
for (const providerConfig of providers) {
|
||||
const { providerId, providerRouter } = makeProvider(providerConfig);
|
||||
logger.info(`Configuring provider, ${providerId}`);
|
||||
router.use(`/${providerId}`, providerRouter);
|
||||
const { provider } = providerConfig;
|
||||
const providerRouter = createAuthProviderRouter(providerConfig);
|
||||
logger.info(`Configuring provider, ${provider}`);
|
||||
router.use(`/${provider}`, providerRouter);
|
||||
}
|
||||
|
||||
return router;
|
||||
|
||||
@@ -6,19 +6,21 @@
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "tsc-watch --onFirstSuccess \"cross-env NODE_ENV=development nodemon dist/run.js\"",
|
||||
"start": "backstage-cli watch-deps --build -- tsc-watch --onFirstSuccess \\\"cross-env NODE_ENV=development nodemon -r esm dist/run.js\\\"",
|
||||
"build": "tsc",
|
||||
"lint": "backstage-cli lint",
|
||||
"test": "backstage-cli test",
|
||||
"prepack": "backstage-cli prepack",
|
||||
"postpack": "backstage-cli postpack",
|
||||
"clean": "backstage-cli clean"
|
||||
"clean": "backstage-cli clean",
|
||||
"mock-data": "./scripts/mock-data"
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/backend-common": "^0.1.1-alpha.6",
|
||||
"@backstage/catalog-model": "^0.1.1-alpha.6",
|
||||
"compression": "^1.7.4",
|
||||
"cors": "^2.8.5",
|
||||
"esm": "^3.2.25",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^3.0.3",
|
||||
"fs-extra": "^9.0.0",
|
||||
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/env sh
|
||||
curl \
|
||||
--location \
|
||||
--request POST 'localhost:3003/locations' \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data-raw '{
|
||||
"type": "github",
|
||||
"target": "https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/fixtures/two_components.yaml"
|
||||
}'
|
||||
@@ -18,7 +18,7 @@ import Knex from 'knex';
|
||||
import path from 'path';
|
||||
import { Logger } from 'winston';
|
||||
import { CommonDatabase } from './CommonDatabase';
|
||||
import type { Database } from './types';
|
||||
import { Database } from './types';
|
||||
|
||||
export class DatabaseManager {
|
||||
public static async createDatabase(
|
||||
@@ -31,4 +31,18 @@ export class DatabaseManager {
|
||||
});
|
||||
return new CommonDatabase(knex, logger);
|
||||
}
|
||||
|
||||
public static async createInMemoryDatabase(
|
||||
logger: Logger,
|
||||
): Promise<Database> {
|
||||
const knex = Knex({
|
||||
client: 'sqlite3',
|
||||
connection: ':memory:',
|
||||
useNullAsDefault: true,
|
||||
});
|
||||
knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
|
||||
resource.run('PRAGMA foreign_keys = ON', () => {});
|
||||
});
|
||||
return DatabaseManager.createDatabase(knex, logger);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,4 +18,4 @@ export * from './descriptor';
|
||||
export { HigherOrderOperations } from './HigherOrderOperations';
|
||||
export { IngestionModels } from './IngestionModels';
|
||||
export * from './source';
|
||||
export type { IngestionModel } from './types';
|
||||
export type { HigherOrderOperation, IngestionModel } from './types';
|
||||
|
||||
@@ -28,4 +28,5 @@ export type IngestionModel = {
|
||||
|
||||
export type HigherOrderOperation = {
|
||||
addLocation(spec: LocationSpec): Promise<AddLocationResult>;
|
||||
refreshAllLocations(): Promise<void>;
|
||||
};
|
||||
|
||||
@@ -48,6 +48,7 @@ describe('createRouter', () => {
|
||||
};
|
||||
higherOrderOperation = {
|
||||
addLocation: jest.fn(),
|
||||
refreshAllLocations: jest.fn(),
|
||||
};
|
||||
const router = await createRouter({
|
||||
entitiesCatalog,
|
||||
@@ -80,6 +81,7 @@ describe('createRouter', () => {
|
||||
const response = await request(app).get('/entities?a=1&a=&a=3&b=4&c=');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1);
|
||||
expect(entitiesCatalog.entities).toHaveBeenCalledWith([
|
||||
{ key: 'a', values: ['1', null, '3'] },
|
||||
{ key: 'b', values: ['4'] },
|
||||
@@ -101,14 +103,19 @@ describe('createRouter', () => {
|
||||
|
||||
const response = await request(app).get('/entities/by-uid/zzz');
|
||||
|
||||
expect(entitiesCatalog.entityByUid).toHaveBeenCalledTimes(1);
|
||||
expect(entitiesCatalog.entityByUid).toHaveBeenCalledWith('zzz');
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(expect.objectContaining(entity));
|
||||
});
|
||||
|
||||
it('responds with a 404 for missing entities', async () => {
|
||||
entitiesCatalog.entityByUid.mockResolvedValue(undefined);
|
||||
|
||||
const response = await request(app).get('/entities/by-uid/zzz');
|
||||
|
||||
expect(entitiesCatalog.entityByUid).toHaveBeenCalledTimes(1);
|
||||
expect(entitiesCatalog.entityByUid).toHaveBeenCalledWith('zzz');
|
||||
expect(response.status).toEqual(404);
|
||||
expect(response.text).toMatch(/uid/);
|
||||
});
|
||||
@@ -118,16 +125,18 @@ describe('createRouter', () => {
|
||||
it('can fetch entity by name', async () => {
|
||||
const entity: Entity = {
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
kind: 'k',
|
||||
metadata: {
|
||||
name: 'c',
|
||||
namespace: 'd',
|
||||
name: 'n',
|
||||
namespace: 'ns',
|
||||
},
|
||||
};
|
||||
entitiesCatalog.entityByName.mockResolvedValue(entity);
|
||||
|
||||
const response = await request(app).get('/entities/by-name/b/d/c');
|
||||
const response = await request(app).get('/entities/by-name/k/ns/n');
|
||||
|
||||
expect(entitiesCatalog.entityByName).toHaveBeenCalledTimes(1);
|
||||
expect(entitiesCatalog.entityByName).toHaveBeenCalledWith('k', 'ns', 'n');
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(expect.objectContaining(entity));
|
||||
});
|
||||
@@ -135,8 +144,10 @@ describe('createRouter', () => {
|
||||
it('responds with a 404 for missing entities', async () => {
|
||||
entitiesCatalog.entityByName.mockResolvedValue(undefined);
|
||||
|
||||
const response = await request(app).get('/entities/by-name//b/d/c');
|
||||
const response = await request(app).get('/entities/by-name/b/d/c');
|
||||
|
||||
expect(entitiesCatalog.entityByName).toHaveBeenCalledTimes(1);
|
||||
expect(entitiesCatalog.entityByName).toHaveBeenCalledWith('b', 'd', 'c');
|
||||
expect(response.status).toEqual(404);
|
||||
expect(response.text).toMatch(/name/);
|
||||
});
|
||||
@@ -149,9 +160,9 @@ describe('createRouter', () => {
|
||||
.set('Content-Type', 'application/json')
|
||||
.send();
|
||||
|
||||
expect(entitiesCatalog.addOrUpdateEntity).not.toHaveBeenCalled();
|
||||
expect(response.status).toEqual(400);
|
||||
expect(response.text).toMatch(/body/);
|
||||
expect(entitiesCatalog.addOrUpdateEntity).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the body down', async () => {
|
||||
@@ -171,13 +182,13 @@ describe('createRouter', () => {
|
||||
.send(entity)
|
||||
.set('Content-Type', 'application/json');
|
||||
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(entity);
|
||||
expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenCalledTimes(1);
|
||||
expect(entitiesCatalog.addOrUpdateEntity).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
entity,
|
||||
);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toEqual(entity);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -187,8 +198,9 @@ describe('createRouter', () => {
|
||||
|
||||
const response = await request(app).delete('/entities/by-uid/apa');
|
||||
|
||||
expect(response.status).toEqual(204);
|
||||
expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledTimes(1);
|
||||
expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledWith('apa');
|
||||
expect(response.status).toEqual(204);
|
||||
});
|
||||
|
||||
it('responds with a 404 for missing entities', async () => {
|
||||
@@ -198,8 +210,9 @@ describe('createRouter', () => {
|
||||
|
||||
const response = await request(app).delete('/entities/by-uid/apa');
|
||||
|
||||
expect(response.status).toEqual(404);
|
||||
expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledTimes(1);
|
||||
expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledWith('apa');
|
||||
expect(response.status).toEqual(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -229,8 +242,8 @@ describe('createRouter', () => {
|
||||
|
||||
const response = await request(app).post('/locations').send(spec);
|
||||
|
||||
expect(response.status).toEqual(400);
|
||||
expect(higherOrderOperation.addLocation).not.toHaveBeenCalled();
|
||||
expect(response.status).toEqual(400);
|
||||
});
|
||||
|
||||
it('passes the body down', async () => {
|
||||
@@ -246,9 +259,14 @@ describe('createRouter', () => {
|
||||
|
||||
const response = await request(app).post('/locations').send(spec);
|
||||
|
||||
expect(response.status).toEqual(201);
|
||||
expect(higherOrderOperation.addLocation).toHaveBeenCalledTimes(1);
|
||||
expect(higherOrderOperation.addLocation).toHaveBeenCalledWith(spec);
|
||||
expect(response.status).toEqual(201);
|
||||
expect(response.body).toEqual(
|
||||
expect.objectContaining({
|
||||
location: { id: 'a', ...spec },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,19 +25,27 @@ import express from 'express';
|
||||
import helmet from 'helmet';
|
||||
import { Logger } from 'winston';
|
||||
import { EntitiesCatalog, LocationsCatalog } from '../catalog';
|
||||
import { HigherOrderOperation } from '../ingestion';
|
||||
import { createRouter } from './router';
|
||||
|
||||
export interface ApplicationOptions {
|
||||
enableCors: boolean;
|
||||
entitiesCatalog: EntitiesCatalog;
|
||||
locationsCatalog?: LocationsCatalog;
|
||||
higherOrderOperation?: HigherOrderOperation;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export async function createStandaloneApplication(
|
||||
options: ApplicationOptions,
|
||||
): Promise<express.Application> {
|
||||
const { enableCors, entitiesCatalog, locationsCatalog, logger } = options;
|
||||
const {
|
||||
enableCors,
|
||||
entitiesCatalog,
|
||||
locationsCatalog,
|
||||
higherOrderOperation,
|
||||
logger,
|
||||
} = options;
|
||||
const app = express();
|
||||
|
||||
app.use(helmet());
|
||||
@@ -49,7 +57,12 @@ export async function createStandaloneApplication(
|
||||
app.use(requestLoggingHandler());
|
||||
app.use(
|
||||
'/',
|
||||
await createRouter({ entitiesCatalog, locationsCatalog, logger }),
|
||||
await createRouter({
|
||||
entitiesCatalog,
|
||||
locationsCatalog,
|
||||
higherOrderOperation,
|
||||
logger,
|
||||
}),
|
||||
);
|
||||
app.use(notFoundHandler());
|
||||
app.use(errorHandler());
|
||||
|
||||
@@ -16,8 +16,13 @@
|
||||
|
||||
import { Server } from 'http';
|
||||
import { Logger } from 'winston';
|
||||
import { StaticEntitiesCatalog } from '../catalog';
|
||||
import { createStandaloneApplication } from './standaloneApplication';
|
||||
import { DatabaseEntitiesCatalog } from '../catalog/DatabaseEntitiesCatalog';
|
||||
import { DatabaseManager } from '../database/DatabaseManager';
|
||||
import { DatabaseLocationsCatalog } from '../catalog/DatabaseLocationsCatalog';
|
||||
import { LocationReaders } from '../ingestion/source/LocationReaders';
|
||||
import { IngestionModels, DescriptorParsers, HigherOrderOperations } from '..';
|
||||
import { EntityPolicies } from '@backstage/catalog-model';
|
||||
|
||||
export interface ServerOptions {
|
||||
port: number;
|
||||
@@ -30,25 +35,28 @@ export async function startStandaloneServer(
|
||||
): Promise<Server> {
|
||||
const logger = options.logger.child({ service: 'catalog-backend' });
|
||||
|
||||
const entitiesCatalog = new StaticEntitiesCatalog([
|
||||
{
|
||||
apiVersion: 'backstage.io/v1beta1',
|
||||
kind: 'Component',
|
||||
metadata: { name: 'c1' },
|
||||
spec: { type: 'service' },
|
||||
},
|
||||
{
|
||||
apiVersion: 'backstage.io/v1beta1',
|
||||
kind: 'Component',
|
||||
metadata: { name: 'c2' },
|
||||
spec: { type: 'service' },
|
||||
},
|
||||
]);
|
||||
const db = await DatabaseManager.createInMemoryDatabase(logger);
|
||||
|
||||
const entitiesCatalog = new DatabaseEntitiesCatalog(db);
|
||||
const locationsCatalog = new DatabaseLocationsCatalog(db);
|
||||
const ingestionModel = new IngestionModels(
|
||||
new LocationReaders(),
|
||||
new DescriptorParsers(),
|
||||
new EntityPolicies(),
|
||||
);
|
||||
const higherOrderOperation = new HigherOrderOperations(
|
||||
entitiesCatalog,
|
||||
locationsCatalog,
|
||||
ingestionModel,
|
||||
logger,
|
||||
);
|
||||
|
||||
logger.debug('Creating application...');
|
||||
const app = await createStandaloneApplication({
|
||||
enableCors: options.enableCors,
|
||||
entitiesCatalog,
|
||||
locationsCatalog,
|
||||
higherOrderOperation,
|
||||
logger,
|
||||
});
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import { CatalogApi } from './types';
|
||||
import { DescriptorEnvelope } from '../types';
|
||||
import { Entity, Location } from '@backstage/catalog-model';
|
||||
|
||||
export class CatalogClient implements CatalogApi {
|
||||
private apiOrigin: string;
|
||||
@@ -69,4 +70,22 @@ export class CatalogClient implements CatalogApi {
|
||||
entities,
|
||||
};
|
||||
}
|
||||
|
||||
async getLocationByEntity(entity: Entity): Promise<Location | undefined> {
|
||||
const findLocationIdInEntity = (e: Entity): string | undefined =>
|
||||
e.metadata.annotations?.['backstage.io/managed-by-location'];
|
||||
|
||||
const locationId = findLocationIdInEntity(entity);
|
||||
if (!locationId) return undefined;
|
||||
|
||||
const response = await fetch(
|
||||
`${this.apiOrigin}${this.basePath}/locations/${locationId}`,
|
||||
);
|
||||
if (response.ok) {
|
||||
const location = await response.json();
|
||||
if (location) return location.data;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface CatalogApi {
|
||||
getEntities(): Promise<Entity[]>;
|
||||
getEntityByName(name: string): Promise<Entity>;
|
||||
addLocation(type: string, target: string): Promise<AddLocationResponse>;
|
||||
getLocationByEntity(entity: Entity): Promise<Location | undefined>;
|
||||
}
|
||||
|
||||
export type AddLocationResponse = { location: Location; entities: Entity[] };
|
||||
|
||||
@@ -35,6 +35,8 @@ const catalogApi: Partial<CatalogApi> = {
|
||||
kind: 'Component',
|
||||
},
|
||||
] as Entity[]),
|
||||
getLocationByEntity: () =>
|
||||
Promise.resolve({ id: 'id', type: 'github', target: 'url' }),
|
||||
};
|
||||
|
||||
describe('CatalogPage', () => {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import React, { FC, useCallback, useState, useEffect } from 'react';
|
||||
import {
|
||||
Content,
|
||||
ContentHeader,
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
pageTheme,
|
||||
useApi,
|
||||
} from '@backstage/core';
|
||||
import { useAsync } from 'react-use';
|
||||
import { useAsync, useMountedState } from 'react-use';
|
||||
import CatalogTable from '../CatalogTable/CatalogTable';
|
||||
import {
|
||||
CatalogFilter,
|
||||
@@ -36,6 +36,8 @@ import { Button, makeStyles, Typography, Link } from '@material-ui/core';
|
||||
import { filterGroups, defaultFilter } from '../../data/filters';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder';
|
||||
import GitHub from '@material-ui/icons/GitHub';
|
||||
import { Entity, Location } from '@backstage/catalog-model';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
contentWrapper: {
|
||||
@@ -48,20 +50,64 @@ const useStyles = makeStyles(theme => ({
|
||||
|
||||
import { catalogApiRef } from '../..';
|
||||
import { envelopeToComponent } from '../../data/utils';
|
||||
import { Component } from '../../data/component';
|
||||
|
||||
const CatalogPage: FC<{}> = () => {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const { value, error, loading } = useAsync(() => catalogApi.getEntities());
|
||||
const [selectedFilter, setSelectedFilter] = React.useState<CatalogFilterItem>(
|
||||
const [locations, setLocations] = useState<Location[]>([]);
|
||||
const [selectedFilter, setSelectedFilter] = useState<CatalogFilterItem>(
|
||||
defaultFilter,
|
||||
);
|
||||
const isMounted = useMountedState();
|
||||
|
||||
const onFilterSelected = React.useCallback(
|
||||
const onFilterSelected = useCallback(
|
||||
selected => setSelectedFilter(selected),
|
||||
[],
|
||||
);
|
||||
const styles = useStyles();
|
||||
|
||||
useEffect(() => {
|
||||
const getLocationDataForEntities = async (entities: Entity[]) => {
|
||||
return Promise.all(
|
||||
entities.map(entity => catalogApi.getLocationByEntity(entity)),
|
||||
);
|
||||
};
|
||||
|
||||
if (value) {
|
||||
getLocationDataForEntities(value)
|
||||
.then(
|
||||
(location): Location[] =>
|
||||
location.filter(l => !!l) as Array<Location>,
|
||||
)
|
||||
.then(location => {
|
||||
if (isMounted()) setLocations(location);
|
||||
});
|
||||
}
|
||||
}, [value, catalogApi, isMounted]);
|
||||
|
||||
const actions = [
|
||||
(rowData: Component) => ({
|
||||
icon: GitHub,
|
||||
tooltip: 'View on GitHub',
|
||||
onClick: () => {
|
||||
if (!rowData || !rowData.location) return;
|
||||
window.open(rowData.location.target, '_blank');
|
||||
},
|
||||
hidden:
|
||||
rowData && rowData.location ? rowData.location.type !== 'github' : true,
|
||||
}),
|
||||
];
|
||||
|
||||
const findLocationForEntity = (
|
||||
entity: Entity,
|
||||
l: Location[],
|
||||
): Location | undefined => {
|
||||
const entityLocationId =
|
||||
entity.metadata.annotations?.['backstage.io/managed-by-location'];
|
||||
return l.find(location => location.id === entityLocationId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.home}>
|
||||
<Header title="Service Catalog" subtitle="Keep track of your software">
|
||||
@@ -105,9 +151,19 @@ const CatalogPage: FC<{}> = () => {
|
||||
</div>
|
||||
<CatalogTable
|
||||
titlePreamble={selectedFilter.label}
|
||||
components={(value && value.map(envelopeToComponent)) || []}
|
||||
components={
|
||||
(value &&
|
||||
value.map(val =>
|
||||
envelopeToComponent(
|
||||
val,
|
||||
findLocationForEntity(val, locations),
|
||||
),
|
||||
)) ||
|
||||
[]
|
||||
}
|
||||
loading={loading}
|
||||
error={error}
|
||||
actions={actions}
|
||||
/>
|
||||
</div>
|
||||
</Content>
|
||||
|
||||
@@ -49,12 +49,14 @@ type CatalogTableProps = {
|
||||
titlePreamble: string;
|
||||
loading: boolean;
|
||||
error?: any;
|
||||
actions?: any;
|
||||
};
|
||||
const CatalogTable: FC<CatalogTableProps> = ({
|
||||
components,
|
||||
loading,
|
||||
error,
|
||||
titlePreamble,
|
||||
actions,
|
||||
}) => {
|
||||
if (loading) {
|
||||
return <Progress />;
|
||||
@@ -71,9 +73,10 @@ const CatalogTable: FC<CatalogTableProps> = ({
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
options={{ paging: false }}
|
||||
options={{ paging: false, actionsColumnIndex: -1 }}
|
||||
title={`${titlePreamble} (${(components && components.length) || 0})`}
|
||||
data={components}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,8 +13,11 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Location } from '@backstage/catalog-model';
|
||||
|
||||
export type Component = {
|
||||
name: string;
|
||||
kind: string;
|
||||
description: string;
|
||||
location?: Location;
|
||||
};
|
||||
|
||||
@@ -14,12 +14,16 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { Component } from './component';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { Entity, Location } from '@backstage/catalog-model';
|
||||
|
||||
export function envelopeToComponent(envelope: Entity): Component {
|
||||
export function envelopeToComponent(
|
||||
envelope: Entity,
|
||||
location?: Location,
|
||||
): Component {
|
||||
return {
|
||||
name: envelope.metadata?.name ?? '',
|
||||
kind: envelope.kind ?? 'unknown',
|
||||
description: envelope.metadata?.annotations?.description ?? 'placeholder',
|
||||
location,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"@testing-library/jest-dom": "^5.7.0",
|
||||
"@testing-library/react": "^9.3.2",
|
||||
"@testing-library/user-event": "^10.2.4",
|
||||
"@types/codemirror": "^0.0.93",
|
||||
"@types/codemirror": "^0.0.95",
|
||||
"@types/jest": "^25.2.2",
|
||||
"@types/node": "^12.0.0",
|
||||
"@types/testing-library__jest-dom": "^5.0.4",
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: get yarn cache
|
||||
id: yarn-cache
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
- uses: actions/cache@v1
|
||||
- uses: actions/cache@v2
|
||||
with:
|
||||
path: ${{ steps.yarn-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
|
||||
Reference in New Issue
Block a user