Merge branch 'master' of github.com:spotify/backstage into mob/prepare-from-catalog

* 'master' of github.com:spotify/backstage: (57 commits)
  fix: update readme with work-in-progress status
  fix: Techdocs to TechDocs
  fix: rename pulp-fiction to techdocs-core
  feat(techdocs): yarn create-plugin
  chore(backend-common): just tweaked the structure of useHotMemoize
  build(deps): bump @rollup/plugin-json from 4.0.3 to 4.1.0 (#1360)
  build(deps-dev): bump lerna from 3.22.0 to 3.22.1 (#1390)
  build(deps): bump graphql from 15.0.0 to 15.1.0 (#1391)
  core-api: switch IdentityApi id token access to async
  Adding name of signed in user to greeting message (#1387)
  Fix CircleCI plugin (#1384)
  packages/core: update to not use default exports for components
  auth-backend: clean up naming and types of TokenFactory time handling
  auth-backend: some review feedback of oidc bits
  auth-backend: remove logging from DatabaseKeyStore
  auth-backend: more docs for TokenFactory
  auth-backend: added tests for TokenFactory and fix keyDuration being ignored
  auth-backend: added tests for DatabaseKeyStore
  auth-backend: refactor identity to remove logic from storage layer
  auth-backend: document identity types
  ...
This commit is contained in:
blam
2020-06-22 14:30:34 +02:00
129 changed files with 2112 additions and 576 deletions
@@ -0,0 +1,46 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// @ts-check
/**
* @param {import('knex')} knex
*/
exports.up = async function up(knex) {
return knex.schema.createTable('signing_keys', table => {
table.comment(
'Signing keys that are currently in use or have recently been used to issue tokens',
);
table
.string('kid')
.primary()
.notNullable()
.comment('ID of the signing key');
table
.timestamp('created_at', { useTz: false, precision: 0 })
.notNullable()
.defaultTo(knex.fn.now())
.comment('The creation time of the key');
table.string('key').notNullable().comment('The serialized signing key');
});
};
/**
* @param {import('knex')} knex
*/
exports.down = async function down(knex) {
return knex.schema.dropTable('auth_keystore');
};
+5 -1
View File
@@ -32,12 +32,16 @@
"express-promise-router": "^3.0.3",
"fs-extra": "^9.0.0",
"helmet": "^3.22.0",
"jose": "^1.27.1",
"jwt-decode": "2.2.0",
"knex": "^0.21.1",
"moment": "^2.26.0",
"morgan": "^1.10.0",
"passport": "^0.4.1",
"passport-github2": "^0.1.12",
"passport-google-oauth20": "^2.0.0",
"passport-saml": "^1.3.3",
"uuid": "^8.0.0",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
@@ -46,10 +50,10 @@
"@types/body-parser": "^1.19.0",
"@types/cookie-parser": "^1.4.2",
"@types/jwt-decode": "2.2.1",
"@types/passport": "^1.0.3",
"@types/passport-github2": "^1.2.4",
"@types/passport-google-oauth20": "^2.0.3",
"@types/passport-saml": "^1.1.2",
"@types/passport": "^1.0.3",
"jest-fetch-mock": "^3.0.3"
},
"files": [
@@ -0,0 +1,122 @@
/*
* 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 Knex from 'knex';
import moment from 'moment';
import { DatabaseKeyStore } from './DatabaseKeyStore';
function createDB() {
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 knex;
}
const keyBase = {
use: 'sig',
kty: 'plain',
alg: 'Base64',
} as const;
describe('DatabaseKeyStore', () => {
it('should store a key', async () => {
const database = createDB();
const store = await DatabaseKeyStore.create({ database });
const key = {
kid: '123',
...keyBase,
};
await expect(store.listKeys()).resolves.toEqual({ items: [] });
await store.addKey(key);
const { items } = await store.listKeys();
expect(items).toEqual([{ createdAt: expect.anything(), key }]);
expect(Math.abs(items[0].createdAt.diff(moment(), 's'))).toBeLessThan(10);
});
it('should remove stored keys', async () => {
const database = createDB();
const store = await DatabaseKeyStore.create({ database });
const key1 = { kid: '1', ...keyBase };
const key2 = { kid: '2', ...keyBase };
const key3 = { kid: '3', ...keyBase };
await store.addKey(key1);
await store.addKey(key2);
await store.addKey(key3);
await expect(store.listKeys()).resolves.toEqual({
items: [
{ key: key1, createdAt: expect.anything() },
{ key: key2, createdAt: expect.anything() },
{ key: key3, createdAt: expect.anything() },
],
});
store.removeKeys(['1']);
await expect(store.listKeys()).resolves.toEqual({
items: [
{ key: key2, createdAt: expect.anything() },
{ key: key3, createdAt: expect.anything() },
],
});
store.removeKeys(['1', '2']);
await expect(store.listKeys()).resolves.toEqual({
items: [{ key: key3, createdAt: expect.anything() }],
});
store.removeKeys([]);
await expect(store.listKeys()).resolves.toEqual({
items: [{ key: key3, createdAt: expect.anything() }],
});
store.removeKeys(['3', '4']);
await expect(store.listKeys()).resolves.toEqual({
items: [],
});
await store.addKey(key1);
await store.addKey(key2);
await store.addKey(key3);
await expect(store.listKeys()).resolves.toEqual({
items: [
{ key: key1, createdAt: expect.anything() },
{ key: key2, createdAt: expect.anything() },
{ key: key3, createdAt: expect.anything() },
],
});
store.removeKeys(['1', '2', '3']);
await expect(store.listKeys()).resolves.toEqual({
items: [],
});
});
});
@@ -0,0 +1,77 @@
/*
* 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 Knex from 'knex';
import path from 'path';
import { utc } from 'moment';
import { AnyJWK, KeyStore, StoredKey } from './types';
const migrationsDir = path.resolve(
require.resolve('@backstage/plugin-auth-backend/package.json'),
'../migrations',
);
const TABLE = 'signing_keys';
type Row = {
created_at: Date;
kid: string;
key: string;
};
type Options = {
database: Knex;
};
export class DatabaseKeyStore implements KeyStore {
static async create(options: Options): Promise<DatabaseKeyStore> {
const { database } = options;
await database.migrate.latest({
directory: migrationsDir,
});
return new DatabaseKeyStore(options);
}
private readonly database: Knex;
private constructor(options: Options) {
this.database = options.database;
}
async addKey(key: AnyJWK): Promise<void> {
await this.database<Row>(TABLE).insert({
kid: key.kid,
key: JSON.stringify(key),
});
}
async listKeys(): Promise<{ items: StoredKey[] }> {
const rows = await this.database<Row>(TABLE).select();
return {
items: rows.map(row => ({
key: JSON.parse(row.key),
createdAt: utc(row.created_at),
})),
};
}
async removeKeys(kids: string[]): Promise<void> {
await this.database(TABLE).delete().whereIn('kid', kids);
}
}
@@ -0,0 +1,133 @@
/*
* 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 { utc } from 'moment';
import { TokenFactory } from './TokenFactory';
import { getVoidLogger } from '@backstage/backend-common';
import { KeyStore, AnyJWK, StoredKey } from './types';
import { JWKS, JSONWebKey, JWT } from 'jose';
const logger = getVoidLogger();
class MemoryKeyStore implements KeyStore {
private readonly keys = new Map<
string,
{ createdAt: moment.Moment; key: string }
>();
async addKey(key: AnyJWK): Promise<void> {
this.keys.set(key.kid, {
createdAt: utc(),
key: JSON.stringify(key),
});
}
async removeKeys(kids: string[]): Promise<void> {
for (const kid of kids) {
this.keys.delete(kid);
}
}
async listKeys(): Promise<{ items: StoredKey[] }> {
return {
items: Array.from(this.keys).map(([, { createdAt, key: keyStr }]) => ({
createdAt,
key: JSON.parse(keyStr),
})),
};
}
}
function jwtKid(jwt: string): string {
const { header } = JWT.decode(jwt, { complete: true }) as {
header: { kid: string };
};
return header.kid;
}
describe('TokenFactory', () => {
it('should issue valid tokens signed by a listed key', async () => {
const keyDurationSeconds = 5;
const factory = new TokenFactory({
issuer: 'my-issuer',
keyStore: new MemoryKeyStore(),
keyDurationSeconds,
logger,
});
await expect(factory.listPublicKeys()).resolves.toEqual({ keys: [] });
const token = await factory.issueToken({ claims: { sub: 'foo' } });
const { keys } = await factory.listPublicKeys();
const keyStore = JWKS.asKeyStore({
keys: keys.map(key => key as JSONWebKey),
});
const payload = JWT.verify(token, keyStore) as object & {
iat: number;
exp: number;
};
expect(payload).toEqual({
iss: 'my-issuer',
aud: 'backstage',
sub: 'foo',
iat: expect.any(Number),
exp: expect.any(Number),
});
expect(payload.exp).toBe(payload.iat + keyDurationSeconds * 1000);
});
it('should generate new signing keys when the current one expires', async () => {
const fixedTime = Date.now();
jest.spyOn(Date, 'now').mockImplementation(() => fixedTime);
const factory = new TokenFactory({
issuer: 'my-issuer',
keyStore: new MemoryKeyStore(),
keyDurationSeconds: 5,
logger,
});
const token1 = await factory.issueToken({ claims: { sub: 'foo' } });
const token2 = await factory.issueToken({ claims: { sub: 'foo' } });
expect(jwtKid(token1)).toBe(jwtKid(token2));
await expect(factory.listPublicKeys()).resolves.toEqual({
keys: [
expect.objectContaining({
kid: jwtKid(token1),
}),
],
});
jest.spyOn(Date, 'now').mockImplementation(() => fixedTime + 60000);
await expect(factory.listPublicKeys()).resolves.toEqual({
keys: [],
});
const token3 = await factory.issueToken({ claims: { sub: 'foo' } });
expect(jwtKid(token3)).not.toBe(jwtKid(token2));
await expect(factory.listPublicKeys()).resolves.toEqual({
keys: [
expect.objectContaining({
kid: jwtKid(token3),
}),
],
});
});
});
@@ -0,0 +1,163 @@
/*
* 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 moment from 'moment';
import { TokenIssuer, TokenParams, KeyStore, AnyJWK } from './types';
import { JSONWebKey, JWK, JWS } from 'jose';
import { Logger } from 'winston';
import { v4 as uuid } from 'uuid';
const MS_IN_S = 1000;
type Options = {
logger: Logger;
/** Value of the issuer claim in issued tokens */
issuer: string;
/** Key store used for storing signing keys */
keyStore: KeyStore;
/** Expiration time of signing keys in seconds */
keyDurationSeconds: number;
};
/**
* A token issuer that is able to issue tokens in a distributed system
* backed by a single database. Tokens are issued using lazily generated
* signing keys, where each running instance of the auth service uses its own
* signing key.
*
* The public parts of the keys are all stored in the shared key storage,
* and any of the instances of the auth service will return the full list
* of public keys that are currently in storage.
*
* Signing keys are automatically rotated at the same interval as the token
* duration. Expired keys are kept in storage until there are no valid tokens
* in circulation that could have been signed by that key.
*/
export class TokenFactory implements TokenIssuer {
private readonly issuer: string;
private readonly logger: Logger;
private readonly keyStore: KeyStore;
private readonly keyDurationSeconds: number;
private keyExpiry?: moment.Moment;
private privateKeyPromise?: Promise<JSONWebKey>;
constructor(options: Options) {
this.issuer = options.issuer;
this.logger = options.logger;
this.keyStore = options.keyStore;
this.keyDurationSeconds = options.keyDurationSeconds;
}
async issueToken(params: TokenParams): Promise<string> {
const key = await this.getKey();
const iss = this.issuer;
const sub = params.claims.sub;
const aud = 'backstage';
const iat = Math.floor(Date.now() / MS_IN_S);
const exp = iat + this.keyDurationSeconds * MS_IN_S;
this.logger.info(`Issuing token for ${sub}`);
return JWS.sign({ iss, sub, aud, iat, exp }, key, {
alg: key.alg,
kid: key.kid,
});
}
// This will be called by other services that want to verify ID tokens.
// It is important that it returns a list of all public keys that could
// have been used to sign tokens that have not yet expired.
async listPublicKeys(): Promise<{ keys: AnyJWK[] }> {
const { items: keys } = await this.keyStore.listKeys();
const validKeys = [];
const expiredKeys = [];
for (const key of keys) {
// Allow for a grace period of another full key duration before we remove the keys from the database
const expireAt = key.createdAt.add(3 * this.keyDurationSeconds, 's');
if (expireAt.isBefore()) {
expiredKeys.push(key);
} else {
validKeys.push(key);
}
}
// Lazily prune expired keys. This may cause duplicate removals if we have concurrent callers, but w/e
if (expiredKeys.length > 0) {
const kids = expiredKeys.map(({ key }) => key.kid);
this.logger.info(`Removing expired signing keys, '${kids.join("', '")}'`);
// We don't await this, just let it run in the background
this.keyStore.removeKeys(kids).catch(error => {
this.logger.error(`Failed to remove expired keys, ${error}`);
});
}
// NOTE: we're currently only storing public keys, but if we start storing private keys we'd have to convert here
return { keys: validKeys.map(({ key }) => key) };
}
private async getKey(): Promise<JSONWebKey> {
// Make sure that we only generate one key at a time
if (this.privateKeyPromise) {
if (this.keyExpiry?.isAfter()) {
return this.privateKeyPromise;
}
this.logger.info(`Signing key has expired, generating new key`);
delete this.privateKeyPromise;
}
this.keyExpiry = moment().add(this.keyDurationSeconds, 'seconds');
const promise = (async () => {
// This generates a new signing key to be used to sign tokens until the next key rotation
const key = await JWK.generate('EC', 'P-256', {
use: 'sig',
kid: uuid(),
alg: 'ES256',
});
// We're not allowed to use the key until it has been successfully stored
// TODO: some token verification implementations aggressively cache the list of keys, and
// don't attempt to fetch new ones even if they encounter an unknown kid. Therefore we
// may want to keep using the existing key for some period of time until we switch to
// the new one. This also needs to be implemented cross-service though, meaning new services
// that boot up need to be able to grab an existing key to use for signing.
this.logger.info(`Created new signing key ${key.kid}`);
await this.keyStore.addKey((key.toJWK(false) as unknown) as AnyJWK);
// At this point we are allowed to start using the new key
return key as JSONWebKey;
})();
this.privateKeyPromise = promise;
try {
// If we fail to generate a new key, we need to clear the state so that
// the next caller will try to generate another key.
await promise;
} catch (error) {
this.logger.error(`Failed to generate new signing key, ${error}`);
delete this.keyExpiry;
delete this.privateKeyPromise;
}
return promise;
}
}
@@ -0,0 +1,20 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { createOidcRouter } from './router';
export { TokenFactory } from './TokenFactory';
export { DatabaseKeyStore } from './DatabaseKeyStore';
export type { KeyStore, TokenIssuer, TokenParams } from './types';
@@ -0,0 +1,62 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import Router from 'express-promise-router';
import { TokenIssuer } from './types';
export type Options = {
baseUrl: string;
tokenIssuer: TokenIssuer;
};
export function createOidcRouter(options: Options) {
const { baseUrl, tokenIssuer } = options;
const router = Router();
const config = {
issuer: baseUrl,
token_endpoint: `${baseUrl}/v1/token`,
userinfo_endpoint: `${baseUrl}/v1/userinfo`,
jwks_uri: `${baseUrl}/v1/certs`,
response_types_supported: ['id_token'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
scopes_supported: ['openid'],
token_endpoint_auth_methods_supported: [],
claims_supported: ['sub'],
grant_types_supported: [],
};
router.get('/.well-known/openid-configuration', (_req, res) => {
res.json(config);
});
router.get('/.well-known/jwks.json', async (_req, res) => {
const { keys } = await tokenIssuer.listPublicKeys();
res.json({ keys });
});
router.get('/v1/token', (_req, res) => {
res.status(501).send('Not Implemented');
});
router.get('/v1/userinfo', (_req, res) => {
res.status(501).send('Not Implemented');
});
return router;
}
@@ -0,0 +1,76 @@
/*
* 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.
*/
/** Represents any form of serializable JWK */
export interface AnyJWK extends Record<string, string> {
use: 'sig';
alg: string;
kid: string;
kty: string;
}
/** Parameters used to issue new ID Tokens */
export type TokenParams = {
/** The claims that will be embedded within the token */
claims: {
/** The token subject, i.e. User ID */
sub: string;
};
};
/**
* A TokenIssuer is able to issue verifiable ID Tokens on demand.
*/
export type TokenIssuer = {
/**
* Issues a new ID Token
*/
issueToken(params: TokenParams): Promise<string>;
/**
* List all public keys that are currently being used to sign tokens, or have been used
* in the past within the token expiration time, including a grace period.
*/
listPublicKeys(): Promise<{ keys: AnyJWK[] }>;
};
/**
* A JWK stored by a KeyStore
*/
export type StoredKey = {
key: AnyJWK;
createdAt: moment.Moment;
};
/**
* A KeyStore stores JWKs for later and shared use.
*/
export type KeyStore = {
/**
* Store a new key to be used for signing.
*/
addKey(key: AnyJWK): Promise<void>;
/**
* Remove all keys with the provided kids.
*/
removeKeys(kids: string[]): Promise<void>;
/**
* List all stored keys.
*/
listKeys(): Promise<{ items: StoredKey[] }>;
};
@@ -57,6 +57,8 @@ export class EnvironmentHandler implements AuthProviderRouteHandlers {
async logout(req: express.Request, res: express.Response): Promise<void> {
const provider = this.getProviderForEnv(req);
await provider.logout(req, res);
if (provider.logout) {
await provider.logout(req, res);
}
}
}
@@ -180,6 +180,10 @@ describe('OAuthProvider', () => {
disableRefresh: true,
baseUrl: 'http://localhost:7000/auth',
appOrigin: 'http://localhost:3000',
tokenIssuer: {
issueToken: async () => 'my-id-token',
listPublicKeys: async () => ({ keys: [] }),
},
};
it('sets the correct headers in start', async () => {
@@ -23,6 +23,7 @@ import {
OAuthProviderHandlers,
} from '../providers/types';
import { InputError } from '@backstage/backend-common';
import { TokenIssuer } from '../identity';
export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000;
export const TEN_MINUTES_MS = 600 * 1000;
@@ -33,6 +34,7 @@ export type Options = {
disableRefresh?: boolean;
baseUrl: string;
appOrigin: string;
tokenIssuer: TokenIssuer;
};
export const verifyNonce = (req: express.Request, providerId: string) => {
@@ -142,6 +144,10 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
this.setRefreshTokenCookie(res, refreshToken);
}
user.userIdToken = await this.options.tokenIssuer.issueToken({
claims: { sub: user.profile.email },
});
// post message back to popup if successful
return postMessageResponse(res, this.options.appOrigin, {
type: 'auth-result',
@@ -198,6 +204,11 @@ export class OAuthProvider implements AuthProviderRouteHandlers {
refreshToken,
scope,
);
refreshInfo.userIdToken = await this.options.tokenIssuer.issueToken({
claims: { sub: refreshInfo.profile?.email },
});
return res.send(refreshInfo);
} catch (error) {
return res.status(401).send(`${error.message}`);
@@ -21,13 +21,14 @@ import {
RedirectInfo,
RefreshTokenResponse,
ProfileInfo,
ProviderStrategy,
} from '../providers/types';
export const makeProfileInfo = (
profile: passport.Profile,
params: any,
): ProfileInfo => {
const { provider, displayName: name } = profile;
const { displayName: name } = profile;
let email = '';
if (profile.emails) {
@@ -51,7 +52,6 @@ export const makeProfileInfo = (
}
return {
provider,
name,
email,
picture,
@@ -100,12 +100,12 @@ export const executeFrameHandlerStrategy = async (
};
export const executeRefreshTokenStrategy = async (
providerstrategy: passport.Strategy,
providerStrategy: passport.Strategy,
refreshToken: string,
scope: string,
): Promise<RefreshTokenResponse> => {
return new Promise((resolve, reject) => {
const anyStrategy = providerstrategy as any;
const anyStrategy = providerStrategy as any;
const OAuth2 = anyStrategy._oauth2.constructor;
const oauth2 = new OAuth2(
anyStrategy._oauth2._clientId,
@@ -149,12 +149,12 @@ export const executeRefreshTokenStrategy = async (
};
export const executeFetchUserProfileStrategy = async (
providerstrategy: passport.Strategy,
providerStrategy: passport.Strategy,
accessToken: string,
params: any,
): Promise<ProfileInfo> => {
return new Promise((resolve, reject) => {
const anyStrategy = providerstrategy as any;
const anyStrategy = (providerStrategy as unknown) as ProviderStrategy;
anyStrategy.userProfile(
accessToken,
(error: Error, passportProfile: passport.Profile) => {
@@ -20,6 +20,7 @@ import { createGoogleProvider } from './google';
import { createSamlProvider } from './saml';
import { AuthProviderFactory, AuthProviderConfig } from './types';
import { Logger } from 'winston';
import { TokenIssuer } from '../identity';
const factories: { [providerId: string]: AuthProviderFactory } = {
google: createGoogleProvider,
@@ -32,19 +33,22 @@ export const createAuthProviderRouter = (
globalConfig: AuthProviderConfig,
providerConfig: any, // TODO: make this a config reader object of sorts
logger: Logger,
issuer: TokenIssuer,
) => {
const factory = factories[providerId];
if (!factory) {
throw Error(`No auth provider available for '${providerId}'`);
}
const provider = factory(globalConfig, providerConfig, logger);
const provider = factory(globalConfig, providerConfig, logger, issuer);
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.logout) {
router.post('/logout', provider.logout.bind(provider));
}
if (provider.refresh) {
router.get('/refresh', provider.refresh.bind(provider));
}
@@ -36,6 +36,7 @@ import {
EnvironmentHandler,
} from '../../lib/EnvironmentHandler';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
export class GithubAuthProvider implements OAuthProviderHandlers {
private readonly _strategy: GithubStrategy;
@@ -69,6 +70,7 @@ export function createGithubProvider(
{ baseUrl }: AuthProviderConfig,
providerConfig: EnvironmentProviderConfig,
logger: Logger,
tokenIssuer: TokenIssuer,
) {
const envProviders: EnvironmentHandlers = {};
@@ -101,6 +103,7 @@ export function createGithubProvider(
secure,
baseUrl,
appOrigin,
tokenIssuer,
});
}
return new EnvironmentHandler(envProviders);
@@ -41,6 +41,7 @@ import {
EnvironmentHandlers,
} from '../../lib/EnvironmentHandler';
import { Logger } from 'winston';
import { TokenIssuer } from '../../identity';
export class GoogleAuthProvider implements OAuthProviderHandlers {
private readonly _strategy: GoogleStrategy;
@@ -116,6 +117,7 @@ export function createGoogleProvider(
{ baseUrl }: AuthProviderConfig,
providerConfig: EnvironmentProviderConfig,
logger: Logger,
tokenIssuer: TokenIssuer,
) {
const envProviders: EnvironmentHandlers = {};
@@ -148,6 +150,7 @@ export function createGoogleProvider(
secure,
baseUrl,
appOrigin,
tokenIssuer,
});
}
return new EnvironmentHandler(envProviders);
+193 -30
View File
@@ -16,79 +16,211 @@
import express from 'express';
import { Logger } from 'winston';
import { TokenIssuer } from '../identity';
export type OAuthProviderOptions = {
/**
* Client ID of the auth provider.
*/
clientID: string;
/**
* Client Secret of the auth provider.
*/
clientSecret: string;
/**
* Callback URL to be passed to the auth provider to redirect to after the user signs in.
*/
callbackURL: string;
};
export type SAMLProviderConfig = {
entryPoint: string;
issuer: string;
};
export type EnvironmentProviderConfig = {
[key: string]: OAuthProviderConfig | SAMLProviderConfig;
};
export type AuthProviderConfig = {
baseUrl: string;
};
export type OAuthProviderConfig = {
/**
* Cookies can be marked with a secure flag to send cookies only when the request
* is over an encrypted channel (HTTPS).
*
* For development environment we don't mark the cookie as secure since we serve
* localhost over HTTP.
*/
secure: boolean;
appOrigin: string; // http://localhost:3000
/**
* The protocol://domain[:port] where the app (frontend) is hosted. This is used to post messages back
* to the window that initiates an auth request.
*/
appOrigin: string;
/**
* Client ID of the auth provider.
*/
clientId: string;
/**
* Client Secret of the auth provider.
*/
clientSecret: string;
};
export type EnvironmentProviderConfig = {
/**
* key, values are environment names and OAuthProviderConfigs
*
* For e.g
* {
* development: DevelopmentOAuthProviderConfig
* production: ProductionOAuthProviderConfig
* }
*/
[key: string]: OAuthProviderConfig;
};
export type AuthProviderConfig = {
/**
* The protocol://domain[:port] where the app is hosted. This is used to construct the
* callbackURL to redirect to once the user signs in to the auth provider.
*/
baseUrl: string;
};
/**
* Any OAuth provider needs to implement this interface which has provider specific
* handlers for different methods to perform authentication, get access tokens,
* refresh tokens and perform sign out.
*/
export interface OAuthProviderHandlers {
/**
* This method initiates a sign in request with an auth provider.
* @param {express.Request} req
* @param options
*/
start(req: express.Request, options: any): Promise<any>;
/**
* Handles the redirect from the auth provider when the user has signed in.
* @param {express.Request} req
*/
handler(req: express.Request): Promise<any>;
/**
* (Optional) Given a refresh token and scope fetches a new access token from the auth provider.
* @param {string} refreshToken
* @param {string} scope
*/
refresh?(refreshToken: string, scope: string): Promise<any>;
/**
* (Optional) Sign out of the auth provider.
*/
logout?(): Promise<any>;
}
/**
* Any Auth provider needs to implement this interface which handles the routes in the
* auth backend. Any auth API requests from the frontend reaches these methods.
*
* The routes in the auth backend API are tied to these methods like below
*
* /auth/[provider]/start -> start
* /auth/[provider]/handler/frame -> frameHandler
* /auth/[provider]/refresh -> refresh
* /auth/[provider]/logout -> logout
*/
export interface AuthProviderRouteHandlers {
/**
* Handles the start route of the API. This initiates a sign in request with an auth provider.
*
* Request
* - scopes for the auth request (Optional)
* Response
* - redirect to the auth provider for the user to sign in or consent.
* - sets a nonce cookie and also pass the nonce as 'state' query parameter in the redirect request
*
* @param {express.Request} req
* @param {express.Response} res
*/
start(req: express.Request, res: express.Response): Promise<any>;
frameHandler(req: express.Request, res: express.Response): Promise<any>;
refresh?(req: express.Request, res: express.Response): Promise<any>;
logout(req: express.Request, res: express.Response): Promise<any>;
}
export type SAMLEnvironmentProviderConfig = {
[key: string]: SAMLProviderConfig;
};
/**
* Once the user signs in or consents in the OAuth screen, the auth provider redirects to the
* callbackURL which is handled by this method.
*
* Request
* - to contain a nonce cookie and a 'state' query parameter
* Response
* - postMessage to the window with a payload that contains accessToken, expiryInSeconds?, idToken? and scope.
* - sets a refresh token cookie if the auth provider supports refresh tokens
*
* @param {express.Request} req
* @param {express.Response} res
*/
frameHandler(req: express.Request, res: express.Response): Promise<any>;
/**
* (Optional) If the auth provider supports refresh tokens then this method handles
* requests to get a new access token.
*
* Request
* - to contain a refresh token cookie and scope (Optional) query parameter.
* Response
* - payload with accessToken, expiryInSeconds?, idToken?, scope and user profile information.
*
* @param {express.Request} req
* @param {express.Response} res
*/
refresh?(req: express.Request, res: express.Response): Promise<any>;
/**
* (Optional) Handles sign out requests
*
* Response
* - removes the refresh token cookie
*
* @param {express.Request} req
* @param {express.Response} res
*/
logout?(req: express.Request, res: express.Response): Promise<any>;
}
export type AuthProviderFactory = (
globalConfig: AuthProviderConfig,
providerConfig: EnvironmentProviderConfig,
logger: Logger,
issuer: TokenIssuer,
) => AuthProviderRouteHandlers;
export type AuthInfoBase = {
/**
* An access token issued for the signed in user.
*/
accessToken: string;
/**
* (Optional) Id token issued for the signed in user.
*/
idToken?: string;
/**
* Expiry of the access token in seconds.
*/
expiresInSeconds?: number;
/**
* Scopes granted for the access token.
*/
scope: string;
};
export type AuthInfoWithProfile = AuthInfoBase & {
profile:
| {
provider: string;
email: string;
name?: string;
picture?: string;
}
| undefined;
/**
* Profile information of the signed in user.
*/
profile: ProfileInfo | undefined;
};
export type AuthInfoPrivate = {
/**
* A refresh token issued for the signed in user.
*/
refreshToken: string;
};
/**
* Payload sent as a post message after the auth request is complete.
* If successful then has a valid payload with Auth information else contains an error.
*/
export type AuthResponse =
| {
type: 'auth-result';
@@ -100,18 +232,49 @@ export type AuthResponse =
};
export type RedirectInfo = {
/**
* URL to redirect to
*/
url: string;
/**
* Status code to use for the redirect
*/
status?: number;
};
export type ProfileInfo = {
provider: string;
/**
* Email ID of the signed in user.
*/
email: string;
/**
* Display name that can be presented to the signed in user.
*/
name: string;
/**
* URL to an image that can be used as the display image or avatar of the
* signed in user.
*/
picture: string;
};
export type RefreshTokenResponse = {
/**
* An access token issued for the signed in user.
*/
accessToken: string;
params: any;
};
export type ProviderStrategy = {
userProfile(accessToken: string, callback: Function): void;
};
export type SAMLProviderConfig = {
entryPoint: string;
issuer: string;
};
export type SAMLEnvironmentProviderConfig = {
[key: string]: SAMLProviderConfig;
};
+3 -4
View File
@@ -14,15 +14,12 @@
* limitations under the License.
*/
import yn from 'yn';
import { getRootLogger } from '@backstage/backend-common';
import { startStandaloneServer } from './service/standaloneServer';
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 3003;
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
const logger = getRootLogger();
startStandaloneServer({ port, enableCors, logger }).catch(err => {
startStandaloneServer({ logger }).catch(err => {
logger.error(err);
process.exit(1);
});
@@ -31,3 +28,5 @@ process.on('SIGINT', () => {
logger.info('CTRL+C pressed; exiting.');
process.exit(0);
});
module.hot?.accept();
+25 -1
View File
@@ -18,12 +18,15 @@ import express from 'express';
import Router from 'express-promise-router';
import cookieParser from 'cookie-parser';
import bodyParser from 'body-parser';
import Knex from 'knex';
import { Logger } from 'winston';
import { createAuthProviderRouter } from '../providers';
import { Config } from '@backstage/config';
import { DatabaseKeyStore, TokenFactory, createOidcRouter } from '../identity';
export interface RouterOptions {
logger: Logger;
database: Knex;
config: Config;
}
@@ -33,6 +36,19 @@ export async function createRouter(
const router = Router();
const logger = options.logger.child({ plugin: 'auth' });
const baseUrl = `${options.config.getString('backend.baseUrl')}/auth`;
const keyDurationSeconds = 3600;
const keyStore = await DatabaseKeyStore.create({
database: options.database,
});
const tokenIssuer = new TokenFactory({
issuer: baseUrl,
keyStore,
keyDurationSeconds,
logger: logger.child({ component: 'token-factory' }),
});
router.use(cookieParser());
router.use(bodyParser.urlencoded({ extended: false }));
router.use(bodyParser.json());
@@ -79,7 +95,6 @@ export async function createRouter(
const providerConfigs = config.auth.providers;
for (const [providerId, providerConfig] of Object.entries(providerConfigs)) {
const baseUrl = `${options.config.getString('backend.baseUrl')}/auth`;
logger.info(`Configuring provider, ${providerId}`);
try {
const providerRouter = createAuthProviderRouter(
@@ -87,11 +102,20 @@ export async function createRouter(
{ baseUrl },
providerConfig,
logger,
tokenIssuer,
);
router.use(`/${providerId}`, providerRouter);
} catch (e) {
logger.error(e.message);
}
}
router.use(
createOidcRouter({
tokenIssuer,
baseUrl,
}),
);
return router;
}
@@ -1,54 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
errorHandler,
notFoundHandler,
requestLoggingHandler,
} from '@backstage/backend-common';
import { Config } from '@backstage/config';
import compression from 'compression';
import cors from 'cors';
import express from 'express';
import helmet from 'helmet';
import { Logger } from 'winston';
import { createRouter } from './router';
export interface ApplicationOptions {
enableCors: boolean;
logger: Logger;
config: Config;
}
export async function createStandaloneApplication(
options: ApplicationOptions,
): Promise<express.Application> {
const { enableCors, logger, config } = options;
const app = express();
app.use(helmet());
if (enableCors) {
app.use(cors());
}
app.use(compression());
app.use(express.json());
app.use(requestLoggingHandler());
app.use('/', await createRouter({ logger, config }));
app.use(notFoundHandler());
app.use(errorHandler());
return app;
}
@@ -14,15 +14,15 @@
* limitations under the License.
*/
import Knex from 'knex';
import { Server } from 'http';
import { Logger } from 'winston';
import { createStandaloneApplication } from './standaloneApplication';
import { ConfigReader } from '@backstage/config';
import { loadConfig } from '@backstage/config-loader';
import { createRouter } from './router';
import { createServiceBuilder, useHotMemoize } from '@backstage/backend-common';
export interface ServerOptions {
port: number;
enableCors: boolean;
logger: Logger;
}
@@ -32,23 +32,31 @@ export async function startStandaloneServer(
const logger = options.logger.child({ service: 'auth-backend' });
const config = ConfigReader.fromConfigs(await loadConfig());
logger.debug('Creating application...');
const app = await createStandaloneApplication({
enableCors: options.enableCors,
logger,
config,
const database = useHotMemoize(module, () => {
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 knex;
});
logger.debug('Starting application server...');
return await new Promise((resolve, reject) => {
const server = app.listen(options.port, (err?: Error) => {
if (err) {
reject(err);
return;
}
const router = await createRouter({
logger,
config,
database,
});
logger.info(`Listening on port ${options.port}`);
resolve(server);
});
const service = createServiceBuilder(module)
.enableCors({ origin: 'http://localhost:3000', credentials: true })
.addRouter('/auth', router);
return await service.start().catch(err => {
logger.error(err);
process.exit(1);
});
}
@@ -14,13 +14,13 @@
* limitations under the License.
*/
import React from 'react';
import React, { FC } from 'react';
import { useApi } from '@backstage/core';
import { catalogApiRef } from '../../api/types';
import { useAsync } from 'react-use';
import { CircularProgress, useTheme } from '@material-ui/core';
export const AllServicesCount: React.FC<{}> = () => {
export const AllServicesCount: FC<{}> = () => {
const theme = useTheme();
const catalogApi = useApi(catalogApiRef);
const { value, loading } = useAsync(() => catalogApi.getEntities());
@@ -29,5 +29,5 @@ export const AllServicesCount: React.FC<{}> = () => {
return <CircularProgress size={theme.spacing(2)} />;
}
return <span>{value?.length ?? '-'}</span>;
return <span>{value ?? length ?? '-'}</span>;
};
@@ -18,16 +18,57 @@ import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { CatalogFilter, CatalogFilterGroup } from './CatalogFilter';
import { EntityFilterType } from '../../data/filters';
import { EntityGroup } from '../../data/filters';
describe('Catalog Filter', () => {
const comp1 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component-1',
},
spec: {
owner: 'team',
},
};
const comp2 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component-2',
},
spec: {
owner: 'team',
},
};
const comp3 = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component-3',
},
spec: {
owner: '',
},
};
const defaultFilterProps = {
selectedFilter: EntityGroup.ALL,
onFilterChange: (type: EntityGroup) => type,
entitiesByFilter: {
[EntityGroup.ALL]: [comp1, comp2, comp3],
[EntityGroup.STARRED]: [comp1],
[EntityGroup.OWNED]: [comp1],
},
};
it('should render the different groups', async () => {
const mockGroups: CatalogFilterGroup[] = [
{ name: 'Test Group 1', items: [] },
{ name: 'Test Group 2', items: [] },
];
const { findByText } = render(
wrapInTestApp(<CatalogFilter groups={mockGroups} />),
wrapInTestApp(
<CatalogFilter {...defaultFilterProps} groups={mockGroups} />,
),
);
for (const group of mockGroups) {
@@ -41,11 +82,11 @@ describe('Catalog Filter', () => {
name: 'Test Group 1',
items: [
{
id: EntityFilterType.ALL,
id: EntityGroup.ALL,
label: 'First Label',
},
{
id: EntityFilterType.STARRED,
id: EntityGroup.STARRED,
label: 'Second Label',
},
],
@@ -53,7 +94,9 @@ describe('Catalog Filter', () => {
];
const { findByText } = render(
wrapInTestApp(<CatalogFilter groups={mockGroups} />),
wrapInTestApp(
<CatalogFilter {...defaultFilterProps} groups={mockGroups} />,
),
);
const [group] = mockGroups;
@@ -68,26 +111,31 @@ describe('Catalog Filter', () => {
name: 'Test Group 1',
items: [
{
id: EntityFilterType.ALL,
id: EntityGroup.ALL,
label: 'First Label',
count: 100,
count: 3,
},
{
id: EntityFilterType.STARRED,
id: EntityGroup.STARRED,
label: 'Second Label',
count: 400,
count: 1,
},
],
},
];
const { findByText } = render(
wrapInTestApp(<CatalogFilter groups={mockGroups} />),
const { getAllByText } = render(
wrapInTestApp(
<CatalogFilter {...defaultFilterProps} groups={mockGroups} />,
),
);
const [group] = mockGroups;
for (const item of group.items) {
expect(await findByText(item.count!.toString())).toBeInTheDocument();
for (const key of Object.keys(defaultFilterProps.entitiesByFilter)) {
const matcher = new RegExp(
`(${defaultFilterProps.entitiesByFilter[key as EntityGroup].length})`,
);
const items = await getAllByText(matcher);
items.forEach(el => expect(el).toBeInTheDocument());
}
});
@@ -97,12 +145,12 @@ describe('Catalog Filter', () => {
name: 'Test Group 1',
items: [
{
id: EntityFilterType.ALL,
id: EntityGroup.ALL,
label: 'First Label',
count: 100,
},
{
id: EntityFilterType.STARRED,
id: EntityGroup.STARRED,
label: 'Second Label',
count: 400,
},
@@ -115,8 +163,9 @@ describe('Catalog Filter', () => {
const { findByText } = render(
wrapInTestApp(
<CatalogFilter
{...defaultFilterProps}
groups={mockGroups}
onSelectedChange={onSelectedChangeHandler}
onFilterChange={onSelectedChangeHandler}
/>,
),
);
@@ -127,7 +176,7 @@ describe('Catalog Filter', () => {
fireEvent.click(element);
expect(onSelectedChangeHandler).toHaveBeenCalledWith(item);
expect(onSelectedChangeHandler).toHaveBeenCalledWith(item.id);
});
it('should render a component when a function is passed to the count component', async () => {
@@ -136,12 +185,12 @@ describe('Catalog Filter', () => {
name: 'Test Group 1',
items: [
{
id: EntityFilterType.ALL,
id: EntityGroup.ALL,
label: 'First Label',
count: () => <b>BACKSTAGE!</b>,
},
{
id: EntityFilterType.STARRED,
id: EntityGroup.STARRED,
label: 'Second Label',
count: 400,
},
@@ -149,9 +198,11 @@ describe('Catalog Filter', () => {
},
];
const { findByText } = render(
wrapInTestApp(<CatalogFilter groups={mockGroups} />),
wrapInTestApp(
<CatalogFilter {...defaultFilterProps} groups={mockGroups} />,
),
);
expect(await findByText('BACKSTAGE!')).toBeInTheDocument();
expect(await findByText('Test Group 1')).toBeInTheDocument();
});
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React from 'react';
import React, { FC } from 'react';
import {
Card,
List,
@@ -26,12 +26,14 @@ import {
makeStyles,
} from '@material-ui/core';
import type { IconComponent } from '@backstage/core';
import { EntityFilterType } from '../../data/filters';
import { EntityGroup } from '../../data/filters';
import { EntitiesByFilter } from '../../hooks/useEntities';
export type CatalogFilterItem = {
id: EntityFilterType;
id: EntityGroup;
label: string;
icon?: IconComponent;
count?: number | React.FC;
count?: number | FC;
};
export type CatalogFilterGroup = {
@@ -39,12 +41,6 @@ export type CatalogFilterGroup = {
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)',
@@ -71,10 +67,16 @@ const useStyles = makeStyles<Theme>(theme => ({
},
}));
export const CatalogFilter: React.FC<CatalogFilterProps> = ({
export const CatalogFilter: FC<{
selectedFilter: EntityGroup;
onFilterChange: (type: EntityGroup) => void;
entitiesByFilter: EntitiesByFilter;
groups: CatalogFilterGroup[];
}> = ({
selectedFilter: selectedId,
onFilterChange: setSelectedFilter,
entitiesByFilter,
groups,
selectedId,
onSelectedChange,
}) => {
const classes = useStyles();
return (
@@ -91,7 +93,9 @@ export const CatalogFilter: React.FC<CatalogFilterProps> = ({
key={item.id}
button
divider
onClick={() => onSelectedChange?.(item)}
onClick={() => {
setSelectedFilter(item.id);
}}
selected={item.id === selectedId}
className={classes.menuItem}
>
@@ -105,11 +109,7 @@ export const CatalogFilter: React.FC<CatalogFilterProps> = ({
{item.label}
</Typography>
</ListItemText>
{typeof item.count === 'function' ? (
<item.count />
) : (
item.count
)}
{entitiesByFilter[item.id]?.length ?? '-'}
</MenuItem>
))}
</List>
@@ -15,23 +15,25 @@
*/
import React, { FC } from 'react';
import { Header, HomepageTimer, Page, pageTheme } from '@backstage/core';
import {
Header,
HomepageTimer,
Page,
pageTheme,
identityApiRef,
useApi,
} from '@backstage/core';
import { getTimeBasedGreeting } from './utils/timeUtil';
const CatalogLayout: FC<{}> = props => {
const { children } = props;
// const profile = useProfile();
const profile = { givenName: 'friend' };
const greeting = getTimeBasedGreeting();
const identityApi = useApi(identityApiRef);
return (
<Page theme={pageTheme.home}>
<Header
title={
profile
? `${greeting.greeting}, ${profile.givenName}!`
: greeting.greeting
}
title={`${greeting.greeting}, ${identityApi.getUserId()}!`}
subtitle="Backstage Service Catalog"
tooltip={greeting.language}
pageTitleOverride="Home"
@@ -20,9 +20,11 @@ import {
errorApiRef,
storageApiRef,
WebStorage,
IdentityApi,
identityApiRef,
} from '@backstage/core';
import { MockErrorApi, wrapInTestApp } from '@backstage/test-utils';
import { render } from '@testing-library/react';
import { render, fireEvent } from '@testing-library/react';
import React from 'react';
import { catalogApiRef } from '../..';
import { CatalogApi } from '../../api/types';
@@ -40,31 +42,69 @@ describe('CatalogPage', () => {
},
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
spec: {
owner: 'tools@example.com',
type: 'service',
},
},
{
metadata: {
name: 'Entity2',
},
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
spec: {
owner: 'not-tools@example.com',
type: 'service',
},
},
] as Entity[]),
getLocationByEntity: () =>
Promise.resolve({ id: 'id', type: 'github', target: 'url' }),
};
const mockIndentityApi: Partial<IdentityApi> = {
getUserId: () => 'tools@example.com',
};
// 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(
const { findByText } = render(
wrapInTestApp(
<ApiProvider
apis={ApiRegistry.from([
[errorApiRef, mockErrorApi],
[catalogApiRef, catalogApi],
[storageApiRef, new WebStorage('@mock', mockErrorApi)],
[identityApiRef, mockIndentityApi],
])}
>
<CatalogPage />
</ApiProvider>,
),
);
expect(
await rendered.findByText('Backstage Service Catalog'),
).toBeInTheDocument();
const items = await findByText(/All Services \(2\)/);
expect(items).toBeInTheDocument();
});
it('should filter by owner', async () => {
const { findByText, getByText } = render(
wrapInTestApp(
<ApiProvider
apis={ApiRegistry.from([
[errorApiRef, mockErrorApi],
[catalogApiRef, catalogApi],
[storageApiRef, new WebStorage('@mock', mockErrorApi)],
[identityApiRef, mockIndentityApi],
])}
>
<CatalogPage />
</ApiProvider>,
),
);
fireEvent.click(getByText(/Owned/));
const items = await findByText(/Owned \(1\)/);
expect(items).toBeInTheDocument();
});
});
@@ -21,7 +21,6 @@ import {
DismissableBanner,
HeaderTabs,
SupportButton,
useApi,
} from '@backstage/core';
import CatalogLayout from './CatalogLayout';
import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder';
@@ -36,47 +35,18 @@ import Edit from '@material-ui/icons/Edit';
import GitHub from '@material-ui/icons/GitHub';
import Star from '@material-ui/icons/Star';
import StarOutline from '@material-ui/icons/StarBorder';
import React, { FC, useCallback, useState, useMemo } from 'react';
import React, { FC } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { catalogApiRef } from '../..';
import {
defaultFilter,
entityFilters,
filterGroups,
EntityFilterType,
} from '../../data/filters';
import { findLocationForEntityMeta } from '../../data/utils';
import { useStarredEntities } from '../../hooks/useStarredEntites';
import {
CatalogFilter,
CatalogFilterItem,
} from '../CatalogFilter/CatalogFilter';
import { CatalogFilter } from '../CatalogFilter/CatalogFilter';
import { CatalogTable } from '../CatalogTable/CatalogTable';
import useStaleWhileRevalidate from 'swr';
// TODO: replace me with the proper tabs implemntation
const tabs = [
{
id: 'service',
label: 'Services',
},
{
id: 'website',
label: 'Websites',
},
{
id: 'lib',
label: 'Libraries',
},
{
id: 'documentation',
label: 'Documentation',
},
{
id: 'other',
label: 'Other',
},
];
import { useEntities } from '../../hooks/useEntities';
import { findLocationForEntityMeta } from '../../data/utils';
import {
getCatalogFilterItemByType,
EntityGroup,
filterGroups,
labeledEntityTypes,
} from '../../data/filters';
const useStyles = makeStyles(theme => ({
contentWrapper: {
@@ -92,30 +62,18 @@ const useStyles = makeStyles(theme => ({
}));
export const CatalogPage: FC<{}> = () => {
const catalogApi = useApi(catalogApiRef);
const [selectedTab, setSelectedTab] = useState<string>(tabs[0].id);
const { toggleStarredEntity, isStarredEntity } = useStarredEntities();
const [selectedFilter, setSelectedFilter] = useState<CatalogFilterItem>(
defaultFilter,
);
const {
entitiesByFilter,
error,
loading,
selectedFilter,
setSelectedFilter,
toggleStarredEntity,
isStarredEntity,
selectTypeFilter,
} = useEntities();
const { data: entities, error } = useStaleWhileRevalidate(
['catalog/all', entityFilters[selectedFilter.id]],
async () => catalogApi.getEntities(),
);
const onFilterSelected = useCallback(
selected => setSelectedFilter(selected),
[],
);
const filteredEntities = useMemo(() => {
const typeFilter = entityFilters[EntityFilterType.TYPE];
const leftMenuFilter = entityFilters[selectedFilter.id];
return entities
?.filter(e => leftMenuFilter(e, { isStarred: isStarredEntity(e) }))
.filter(e => typeFilter(e, { type: selectedTab }));
}, [selectedFilter.id, selectedTab, isStarredEntity, entities?.filter]);
const filteredEntities = entitiesByFilter[selectedFilter ?? EntityGroup.ALL];
const styles = useStyles();
@@ -174,9 +132,9 @@ export const CatalogPage: FC<{}> = () => {
return (
<CatalogLayout>
<HeaderTabs
tabs={tabs}
onChange={index => {
setSelectedTab(tabs[index as number].id);
tabs={labeledEntityTypes}
onChange={(index: Number) => {
selectTypeFilter(labeledEntityTypes[index as number].id);
}}
/>
<Content>
@@ -212,14 +170,18 @@ export const CatalogPage: FC<{}> = () => {
<div>
<CatalogFilter
groups={filterGroups}
selectedId={selectedFilter.id}
onSelectedChange={onFilterSelected}
selectedFilter={selectedFilter ?? EntityGroup.ALL}
onFilterChange={setSelectedFilter}
entitiesByFilter={entitiesByFilter}
/>
</div>
<CatalogTable
titlePreamble={selectedFilter.label}
titlePreamble={
getCatalogFilterItemByType(selectedFilter ?? EntityGroup.ALL)
?.label ?? ''
}
entities={filteredEntities || []}
loading={!entities && !error}
loading={loading && !error}
error={error}
actions={actions}
/>
@@ -23,7 +23,7 @@ type Props = {
};
export const EntityMetadataCard: FC<Props> = ({ entity }) => (
<InfoCard title="Metadata">
<InfoCard title="Information">
<StructuredMetadataTable metadata={entity.metadata} />
</InfoCard>
);
@@ -149,11 +149,11 @@ export const EntityPage: FC<{}> = () => {
<HeaderTabs tabs={tabs} />
<Content>
<Grid container spacing={3} direction="column">
<Grid item>
<Grid container spacing={3}>
<Grid item sm={4}>
<EntityMetadataCard entity={entity} />
</Grid>
<Grid item>
<Grid item sm={8}>
<SentryIssuesWidget
sentryProjectId="sample-sentry-project-id"
statsFor="24h"
+62 -18
View File
@@ -17,18 +17,15 @@
import { Entity } from '@backstage/catalog-model';
import SettingsIcon from '@material-ui/icons/Settings';
import StarIcon from '@material-ui/icons/Star';
import { AllServicesCount } from '../components/CatalogFilter/AllServicesCount';
import {
CatalogFilterGroup,
CatalogFilterItem,
} from '../components/CatalogFilter/CatalogFilter';
import { StarredCount } from '../components/CatalogFilter/StarredCount';
export enum EntityFilterType {
export enum EntityGroup {
ALL = 'ALL',
STARRED = 'STARRED',
OWNED = 'OWNED',
TYPE = 'TYPE',
}
export const filterGroups: CatalogFilterGroup[] = [
@@ -36,15 +33,13 @@ export const filterGroups: CatalogFilterGroup[] = [
name: 'Personal',
items: [
{
id: EntityFilterType.OWNED,
id: EntityGroup.OWNED,
label: 'Owned',
count: 0,
icon: SettingsIcon,
},
{
id: EntityFilterType.STARRED,
id: EntityGroup.STARRED,
label: 'Starred',
count: StarredCount,
icon: StarIcon,
},
],
@@ -54,26 +49,75 @@ export const filterGroups: CatalogFilterGroup[] = [
name: 'Company',
items: [
{
id: EntityFilterType.ALL,
label: 'All Entities',
count: AllServicesCount,
id: EntityGroup.ALL,
label: 'All Services',
},
],
},
];
export const getCatalogFilterItemByType = (filterType: EntityGroup) => {
for (const group of filterGroups) {
for (const filter of group.items) {
if (filter.id === filterType) {
return filter;
}
}
}
return null;
};
type EntityFilter = (entity: Entity, options: EntityFilterOptions) => boolean;
type EntityFilterOptions = {
isStarred?: boolean;
type?: string;
type EntityFilterOptions = Partial<{
isStarred: boolean;
userId: string;
}>;
type Owned = {
owner: string;
};
export const entityFilters: Record<string, EntityFilter> = {
[EntityFilterType.OWNED]: () => false,
[EntityFilterType.ALL]: () => true,
[EntityFilterType.STARRED]: (_, { isStarred }) => !!isStarred,
[EntityFilterType.TYPE]: (e, { type }) => (e.spec as any)?.type === type,
[EntityGroup.OWNED]: (e, { userId }) => {
const owner = (e.spec! as Owned).owner;
return owner === userId;
},
[EntityGroup.ALL]: () => true,
[EntityGroup.STARRED]: (_, { isStarred }) => !!isStarred,
};
export const entityTypeFilter = (e: Entity, type: string) =>
(e.spec as any)?.type === type;
type EntityType = 'service' | 'website' | 'lib' | 'documentation' | 'other';
type LabeledEntityType = {
id: EntityType;
label: string;
};
export const labeledEntityTypes: LabeledEntityType[] = [
{
id: 'service',
label: 'Services',
},
{
id: 'website',
label: 'Websites',
},
{
id: 'lib',
label: 'Libraries',
},
{
id: 'documentation',
label: 'Documentation',
},
{
id: 'other',
label: 'Other',
},
];
export const defaultFilter: CatalogFilterItem = filterGroups[0].items[0];
+103
View File
@@ -0,0 +1,103 @@
/*
* 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 { useState, useMemo } from 'react';
import {
EntityGroup,
entityFilters,
entityTypeFilter,
labeledEntityTypes,
} from '../data/filters';
import { useApi, identityApiRef } from '@backstage/core';
import { catalogApiRef } from '..';
import { useStarredEntities } from './useStarredEntites';
import { Entity } from '@backstage/catalog-model';
import useStaleWhileRevalidate from 'swr';
export type EntitiesByFilter = Record<EntityGroup, Entity[] | undefined>;
type UseEntities = {
selectedFilter: EntityGroup | undefined;
setSelectedFilter: (f: EntityGroup) => void;
error: Error | null;
toggleStarredEntity: any;
isStarredEntity: (e: Entity) => boolean;
entitiesByFilter: EntitiesByFilter;
loading: boolean;
selectedTypeFilter: string;
selectTypeFilter: (id: string) => void;
};
export const useEntities = (): UseEntities => {
const [selectedFilter, setSelectedFilter] = useState<
EntityGroup | undefined
>();
const catalogApi = useApi(catalogApiRef);
const { toggleStarredEntity, isStarredEntity } = useStarredEntities();
const { data: entities, error } = useStaleWhileRevalidate(
['catalog/all', entityFilters[selectedFilter ?? EntityGroup.ALL]],
async () => catalogApi.getEntities(),
);
const indentityApi = useApi(identityApiRef);
const userId = indentityApi.getUserId();
const [selectedTypeFilter, selectTypeFilter] = useState<string>(
labeledEntityTypes[0].id,
);
const entitiesByFilter = useMemo(() => {
const filterEntities = (
ents: Entity[] | undefined,
filterId: EntityGroup,
isStarred: (e: Entity) => boolean,
user: string,
) => {
return ents
?.filter((e: Entity) =>
entityFilters[filterId](e, {
isStarred: isStarred(e),
userId: user,
}),
)
.filter(e => entityTypeFilter(e, selectedTypeFilter));
};
const data = Object.keys(EntityGroup).reduce(
(res, key) => ({
...res,
[key]: filterEntities(
entities,
key as EntityGroup,
isStarredEntity,
userId,
),
}),
{} as EntitiesByFilter,
);
return data;
}, [entities, isStarredEntity, userId, selectedTypeFilter]);
return {
selectedFilter,
setSelectedFilter,
error,
toggleStarredEntity,
isStarredEntity,
entitiesByFilter,
loading: entities === undefined,
selectedTypeFilter,
selectTypeFilter,
};
};
+2 -5
View File
@@ -25,11 +25,8 @@ export const App = () => {
<AppStateProvider>
<>
<Routes>
<Route path="/circleci" element={<BuildsPage />} />
<Route
path="/circleci/build/:buildId"
element={<DetailedViewPage />}
/>
<Route path="*" element={<BuildsPage />} />
<Route path="/build/:buildId" element={<DetailedViewPage />} />
</Routes>
<Settings />
</>
@@ -44,13 +44,13 @@ const Settings = () => {
useEffect(() => {
if (tokenFromStore !== token) {
setToken(tokenFromStore);
setToken(token);
}
if (ownerFromStore !== owner) {
setOwner(ownerFromStore);
setOwner(owner);
}
if (repoFromStore !== repo) {
setRepo(repoFromStore);
setRepo(repo);
}
}, [ownerFromStore, repoFromStore, tokenFromStore, token, owner, repo]);
+1 -1
View File
@@ -37,7 +37,7 @@
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"graphiql": "^1.0.0-alpha.10",
"graphql": "15.0.0",
"graphql": "15.1.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-use": "^14.2.0"
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+13
View File
@@ -0,0 +1,13 @@
# TechDocs Plugin
Welcome to the TechDocs plugin - Spotify's docs-like-code approach built directly into [Backstage](https://backstage.io). Watch [a video of our approach on YouTube](https://www.youtube.com/watch?v=uFGCaZmA6d4) to learn more.
**WIP: This plugin is a work in progress. It is not ready for use yet. Follow our progress on [the Backstage Discord](https://discord.gg/MUpMjP2) under #docs-like-code or on [our GitHub Milestone](https://github.com/spotify/backstage/milestone/15).**
## Getting started
Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/techdocs](http://localhost:3000/techdocs).
You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
It is only meant for local development, and the setup for it can be found inside the [/dev](/dev) directory.
@@ -14,10 +14,7 @@
* limitations under the License.
*/
import React from 'react';
import { useStarredEntities } from '../../hooks/useStarredEntites';
import { createDevApp } from '@backstage/dev-utils';
import { plugin } from '../src/plugin';
export const StarredCount: React.FC<{}> = () => {
const { starredEntities } = useStarredEntities();
return <span>{starredEntities.size}</span>;
};
createDevApp().registerPlugin(plugin).render();
+47
View File
@@ -0,0 +1,47 @@
{
"name": "@backstage/plugin-techdocs",
"version": "0.1.1-alpha.9",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.9",
"@backstage/theme": "^0.1.1-alpha.9",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-use": "^14.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.9",
"@backstage/dev-utils": "^0.1.1-alpha.9",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"@types/testing-library__jest-dom": "^5.0.4",
"jest-fetch-mock": "^3.0.3"
},
"files": [
"dist/**/*.{js,d.ts}"
]
}
@@ -16,20 +16,19 @@
import React from 'react';
import { render } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { StarredCount } from './StarredCount';
import * as Hooks from '../../hooks/useStarredEntites';
import mockFetch from 'jest-fetch-mock';
import ExampleComponent from './ExampleComponent';
import { ThemeProvider } from '@material-ui/core';
import { lightTheme } from '@backstage/theme';
describe('Starred Count', () => {
it('should render the count returned from the hook', async () => {
jest.spyOn(Hooks, 'useStarredEntities').mockReturnValue({
starredEntities: new Set(['id1', 'id2', 'id3', 'id4']),
isStarredEntity: () => false,
toggleStarredEntity: () => undefined,
});
const { findByText } = render(wrapInTestApp(<StarredCount />));
expect(await findByText('4')).toBeInTheDocument();
describe('ExampleComponent', () => {
it('should render', () => {
mockFetch.mockResponse(() => new Promise(() => {}));
const rendered = render(
<ThemeProvider theme={lightTheme}>
<ExampleComponent />
</ThemeProvider>,
);
expect(rendered.getByText('Welcome to techdocs!')).toBeInTheDocument();
});
});
@@ -0,0 +1,57 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC } from 'react';
import { Typography, Grid } from '@material-ui/core';
import {
InfoCard,
Header,
Page,
pageTheme,
Content,
ContentHeader,
HeaderLabel,
SupportButton,
} from '@backstage/core';
import ExampleFetchComponent from '../ExampleFetchComponent';
const ExampleComponent: FC<{}> = () => (
<Page theme={pageTheme.tool}>
<Header title="Welcome to techdocs!" subtitle="Optional subtitle">
<HeaderLabel label="Owner" value="Team X" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
<Content>
<ContentHeader title="Plugin title">
<SupportButton>A description of your plugin goes here.</SupportButton>
</ContentHeader>
<Grid container spacing={3} direction="column">
<Grid item>
<InfoCard title="Information card">
<Typography variant="body1">
All content should be wrapped in a card like this.
</Typography>
</InfoCard>
</Grid>
<Grid item>
<ExampleFetchComponent />
</Grid>
</Grid>
</Content>
</Page>
);
export default ExampleComponent;
@@ -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 { default } from './ExampleComponent';
@@ -0,0 +1,28 @@
/*
* 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 } from '@testing-library/react';
import mockFetch from 'jest-fetch-mock';
import ExampleFetchComponent from './ExampleFetchComponent';
describe('ExampleFetchComponent', () => {
it('should render', async () => {
mockFetch.mockResponse(() => new Promise(() => {}));
const rendered = render(<ExampleFetchComponent />);
expect(await rendered.findByTestId('progress')).toBeInTheDocument();
});
});
@@ -0,0 +1,108 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC } from 'react';
import { makeStyles } from '@material-ui/core/styles';
import { Table, TableColumn, Progress } from '@backstage/core';
import Alert from '@material-ui/lab/Alert';
import { useAsync } from 'react-use';
const useStyles = makeStyles({
avatar: {
height: 32,
width: 32,
borderRadius: '50%',
},
});
type User = {
gender: string; // "male"
name: {
title: string; // "Mr",
first: string; // "Duane",
last: string; // "Reed"
};
location: object; // {street: {number: 5060, name: "Hickory Creek Dr"}, city: "Albany", state: "New South Wales",…}
email: string; // "duane.reed@example.com"
login: object; // {uuid: "4b785022-9a23-4ab9-8a23-cb3fb43969a9", username: "blackdog796", password: "patch",…}
dob: object; // {date: "1983-06-22T12:30:23.016Z", age: 37}
registered: object; // {date: "2006-06-13T18:48:28.037Z", age: 14}
phone: string; // "07-2154-5651"
cell: string; // "0405-592-879"
id: {
name: string; // "TFN",
value: string; // "796260432"
};
picture: { medium: string }; // {medium: "https://randomuser.me/api/portraits/men/95.jpg",…}
nat: string; // "AU"
};
type DenseTableProps = {
users: User[];
};
export const DenseTable: FC<DenseTableProps> = ({ users }) => {
const classes = useStyles();
const columns: TableColumn[] = [
{ title: 'Avatar', field: 'avatar' },
{ title: 'Name', field: 'name' },
{ title: 'Email', field: 'email' },
{ title: 'Nationality', field: 'nationality' },
];
const data = users.map(user => {
return {
avatar: (
<img
src={user.picture.medium}
className={classes.avatar}
alt={user.name.first}
/>
),
name: `${user.name.first} ${user.name.last}`,
email: user.email,
nationality: user.nat,
};
});
return (
<Table
title="Example User List (fetching data from randomuser.me)"
options={{ search: false, paging: false }}
columns={columns}
data={data}
/>
);
};
const ExampleFetchComponent: FC<{}> = () => {
const { value, loading, error } = useAsync(async (): Promise<User[]> => {
const response = await fetch('https://randomuser.me/api/?results=20');
const data = await response.json();
return data.results;
}, []);
if (loading) {
return <Progress />;
} else if (error) {
return <Alert severity="error">{error.message}</Alert>;
}
return <DenseTable users={value || []} />;
};
export default ExampleFetchComponent;
@@ -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 { default } from './ExampleFetchComponent';
+17
View File
@@ -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 { plugin } from './plugin';
+23
View File
@@ -0,0 +1,23 @@
/*
* 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 { plugin } from './plugin';
describe('techdocs', () => {
it('should export plugin', () => {
expect(plugin).toBeDefined();
});
});
+45
View File
@@ -0,0 +1,45 @@
/*
* 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.
*/
/*
* 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 { createPlugin, createRouteRef } from '@backstage/core';
import ExampleComponent from './components/ExampleComponent';
export const rootRouteRef = createRouteRef({
path: '/techdocs',
title: 'techdocs',
});
export const plugin = createPlugin({
id: 'techdocs',
register({ router }) {
router.addRoute(rootRouteRef, ExampleComponent);
},
});
+18
View File
@@ -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.
*/
import '@testing-library/jest-dom';
require('jest-fetch-mock').enableMocks();