Merge branch 'master' into scaffolder-improvement

This commit is contained in:
Brian Fletcher
2021-10-22 10:44:33 +01:00
committed by GitHub
561 changed files with 12045 additions and 2988 deletions
+9
View File
@@ -1,5 +1,14 @@
# @backstage/plugin-allure
## 0.1.6
### Patch Changes
- Updated dependencies
- @backstage/plugin-catalog-react@0.6.0
- @backstage/core-components@0.7.0
- @backstage/theme@0.2.11
## 0.1.5
### Patch Changes
+10 -10
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/plugin-allure",
"description": "A Backstage plugin that integrates with Allure",
"version": "0.1.5",
"version": "0.1.6",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -22,11 +22,11 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.9.4",
"@backstage/core-components": "^0.6.1",
"@backstage/core-plugin-api": "^0.1.10",
"@backstage/plugin-catalog-react": "^0.5.2",
"@backstage/theme": "^0.2.10",
"@backstage/catalog-model": "^0.9.5",
"@backstage/core-components": "^0.7.1",
"@backstage/core-plugin-api": "^0.1.11",
"@backstage/plugin-catalog-react": "^0.6.1",
"@backstage/theme": "^0.2.11",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
@@ -36,10 +36,10 @@
"react-use": "^17.2.4"
},
"devDependencies": {
"@backstage/cli": "^0.7.15",
"@backstage/core-app-api": "^0.1.16",
"@backstage/dev-utils": "^0.2.11",
"@backstage/test-utils": "^0.1.18",
"@backstage/cli": "^0.8.0",
"@backstage/core-app-api": "^0.1.18",
"@backstage/dev-utils": "^0.2.12",
"@backstage/test-utils": "^0.1.19",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^13.1.8",
+9
View File
@@ -0,0 +1,9 @@
# @backstage/plugin-analytics-module-ga
## 0.1.2
### Patch Changes
- Updated dependencies
- @backstage/core-components@0.7.0
- @backstage/theme@0.2.11
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-analytics-module-ga",
"version": "0.1.1",
"version": "0.1.2",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -22,9 +22,9 @@
},
"dependencies": {
"@backstage/config": "^0.1.5",
"@backstage/core-components": "^0.6.1",
"@backstage/core-plugin-api": "^0.1.10",
"@backstage/theme": "^0.2.10",
"@backstage/core-components": "^0.7.1",
"@backstage/core-plugin-api": "^0.1.11",
"@backstage/theme": "^0.2.11",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
@@ -34,10 +34,10 @@
"react-use": "^17.2.4"
},
"devDependencies": {
"@backstage/cli": "^0.7.15",
"@backstage/core-app-api": "^0.1.16",
"@backstage/dev-utils": "^0.2.11",
"@backstage/test-utils": "^0.1.18",
"@backstage/cli": "^0.8.0",
"@backstage/core-app-api": "^0.1.18",
"@backstage/dev-utils": "^0.2.12",
"@backstage/test-utils": "^0.1.19",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^13.1.8",
+14
View File
@@ -1,5 +1,19 @@
# @backstage/plugin-api-docs
## 0.6.12
### Patch Changes
- d03ee85ff8: Bump `swagger-ui-react` to `^4.0.0-rc.3`.
- 3c50c86604: Remove unused dependency on material-icons/font
- 6ed423c139: lock down the `swagger-client`
- b6ab93c421: Add actions props to the ApiExplorerPage
- Updated dependencies
- @backstage/plugin-catalog-react@0.6.0
- @backstage/plugin-catalog@0.7.1
- @backstage/core-components@0.7.0
- @backstage/theme@0.2.11
## 0.6.11
### Patch Changes
+11 -11
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/plugin-api-docs",
"description": "A Backstage plugin that helps represent API entities in the frontend",
"version": "0.6.11",
"version": "0.6.12",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -31,12 +31,12 @@
},
"dependencies": {
"@asyncapi/react-component": "^0.23.0",
"@backstage/catalog-model": "^0.9.4",
"@backstage/core-components": "^0.6.1",
"@backstage/core-plugin-api": "^0.1.10",
"@backstage/plugin-catalog": "^0.7.0",
"@backstage/plugin-catalog-react": "^0.5.2",
"@backstage/theme": "^0.2.10",
"@backstage/catalog-model": "^0.9.5",
"@backstage/core-components": "^0.7.1",
"@backstage/core-plugin-api": "^0.1.11",
"@backstage/plugin-catalog": "^0.7.2",
"@backstage/plugin-catalog-react": "^0.6.1",
"@backstage/theme": "^0.2.11",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
@@ -53,10 +53,10 @@
"swagger-client": "3.16.1"
},
"devDependencies": {
"@backstage/cli": "^0.7.15",
"@backstage/core-app-api": "^0.1.16",
"@backstage/dev-utils": "^0.2.11",
"@backstage/test-utils": "^0.1.18",
"@backstage/cli": "^0.8.0",
"@backstage/core-app-api": "^0.1.18",
"@backstage/dev-utils": "^0.2.12",
"@backstage/test-utils": "^0.1.19",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^13.1.8",
+8
View File
@@ -1,5 +1,13 @@
# @backstage/plugin-app-backend
## 0.3.17
### Patch Changes
- Updated dependencies
- @backstage/config-loader@0.7.0
- @backstage/backend-common@0.9.7
## 0.3.16
### Patch Changes
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/plugin-app-backend",
"description": "A Backstage backend plugin that serves the Backstage frontend app",
"version": "0.3.16",
"version": "0.3.17",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -30,8 +30,8 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.9.0",
"@backstage/config-loader": "^0.6.5",
"@backstage/backend-common": "^0.9.7",
"@backstage/config-loader": "^0.7.0",
"@backstage/config": "^0.1.8",
"@types/express": "^4.17.6",
"express": "^4.17.1",
@@ -41,7 +41,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.7.4",
"@backstage/cli": "^0.8.0",
"@types/supertest": "^2.0.8",
"msw": "^0.29.0",
"supertest": "^6.1.3"
+33
View File
@@ -1,5 +1,38 @@
# @backstage/plugin-auth-backend
## 0.4.5
### Patch Changes
- 9322e632e9: Require that audience URLs for Okta authentication start with https
- de3e26aecc: Fix a bug preventing an access token to be refreshed a second time with the GitHub provider.
- ab9b4a6ea6: Add Firestore as key-store provider.
Add `auth.keyStore` section to application config.
- 202f322927: Atlassian auth provider
- AtlassianAuth added to core-app-api
- Atlassian provider added to plugin-auth-backend
- Updated user-settings with Atlassian connection
- 36e67d2f24: Internal updates to apply more strict checks to throw errors.
- Updated dependencies
- @backstage/backend-common@0.9.7
- @backstage/errors@0.1.3
- @backstage/catalog-model@0.9.5
## 0.4.4
### Patch Changes
- 0cfeea8f8f: AWS-ALB: update provider to the latest changes described [here](https://backstage.io/docs/auth/identity-resolver).
This removes the `ExperimentalIdentityResolver` type in favor of `SignInResolver` and `AuthHandler`.
The AWS ALB provider can now be configured in the same way as the Google provider in the example.
- defae8f579: Added extra configuration parameters for active directory file system identity
- Updated dependencies
- @backstage/test-utils@0.1.19
## 0.4.3
### Patch Changes
+38 -3
View File
@@ -16,6 +16,35 @@ import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { Profile } from 'passport';
import { UserEntity } from '@backstage/catalog-model';
// Warning: (ae-missing-release-tag) "AtlassianAuthProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export class AtlassianAuthProvider implements OAuthHandlers {
// Warning: (ae-forgotten-export) The symbol "AtlassianAuthProviderOptions" needs to be exported by the entry point index.d.ts
constructor(options: AtlassianAuthProviderOptions);
// (undocumented)
handler(req: express.Request): Promise<{
response: OAuthResponse;
refreshToken: string;
}>;
// (undocumented)
refresh(req: OAuthRefreshRequest): Promise<OAuthResponse>;
// Warning: (ae-forgotten-export) The symbol "RedirectInfo" needs to be exported by the entry point index.d.ts
//
// (undocumented)
start(req: OAuthStartRequest): Promise<RedirectInfo>;
}
// Warning: (ae-missing-release-tag) "AtlassianProviderOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export type AtlassianProviderOptions = {
authHandler?: AuthHandler<OAuthResult>;
signIn?: {
resolver: SignInResolver<OAuthResult>;
};
};
// Warning: (ae-missing-release-tag) "AuthProviderFactory" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -146,6 +175,13 @@ export const bitbucketUserIdSignInResolver: SignInResolver<BitbucketOAuthResult>
// @public (undocumented)
export const bitbucketUsernameSignInResolver: SignInResolver<BitbucketOAuthResult>;
// Warning: (ae-missing-release-tag) "createAtlassianProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const createAtlassianProvider: (
options?: AtlassianProviderOptions | undefined,
) => AuthProviderFactory;
// Warning: (ae-missing-release-tag) "createAwsAlbProvider" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -388,7 +424,6 @@ export interface OAuthHandlers {
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// Warning: (tsdoc-param-tag-with-invalid-type) The @param block should not include a JSDoc-style '{type}'
// Warning: (tsdoc-param-tag-missing-hyphen) The @param block should be followed by a parameter name and then a hyphen
// Warning: (ae-forgotten-export) The symbol "RedirectInfo" needs to be exported by the entry point index.d.ts
start(req: OAuthStartRequest): Promise<RedirectInfo>;
}
@@ -544,9 +579,9 @@ export type WebMessageResponse =
//
// src/identity/types.d.ts:25:5 - (ae-forgotten-export) The symbol "TokenParams" needs to be exported by the entry point index.d.ts
// src/identity/types.d.ts:31:9 - (ae-forgotten-export) The symbol "AnyJWK" needs to be exported by the entry point index.d.ts
// src/providers/aws-alb/provider.d.ts:77:5 - (ae-forgotten-export) The symbol "AuthHandler" needs to be exported by the entry point index.d.ts
// src/providers/atlassian/provider.d.ts:37:5 - (ae-forgotten-export) The symbol "AuthHandler" needs to be exported by the entry point index.d.ts
// src/providers/atlassian/provider.d.ts:42:9 - (ae-forgotten-export) The symbol "SignInResolver" needs to be exported by the entry point index.d.ts
// src/providers/aws-alb/provider.d.ts:77:5 - (ae-forgotten-export) The symbol "AwsAlbResult" needs to be exported by the entry point index.d.ts
// src/providers/aws-alb/provider.d.ts:85:9 - (ae-forgotten-export) The symbol "SignInResolver" needs to be exported by the entry point index.d.ts
// src/providers/types.d.ts:99:5 - (ae-forgotten-export) The symbol "AuthProviderConfig" needs to be exported by the entry point index.d.ts
// src/providers/types.d.ts:121:8 - (tsdoc-missing-deprecation-message) The @deprecated block must include a deprecation message, e.g. describing the recommended alternative
```
+26
View File
@@ -31,6 +31,32 @@ export interface Config {
secret?: string;
};
/** To control how to store JWK data in auth-backend */
keyStore?: {
provider?: 'database' | 'memory' | 'firestore';
firestore?: {
/** The host to connect to */
host?: string;
/** The port to connect to */
port?: number;
/** Whether to use SSL when connecting. */
ssl?: boolean;
/** The Google Cloud Project ID */
projectId?: string;
/**
* Local file containing the Service Account credentials.
* You can omit this value to automatically read from
* GOOGLE_APPLICATION_CREDENTIALS env which is useful for local
* development.
*/
keyFilename?: string;
/** The path to use for the collection. Defaults to 'sessions' */
path?: string;
/** Timeout used for database operations. Defaults to 10000ms */
timeout?: number;
};
};
/**
* The available auth-provider options and attributes
*/
+7 -6
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/plugin-auth-backend",
"description": "A Backstage backend plugin that handles authentication",
"version": "0.4.3",
"version": "0.4.5",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -30,12 +30,13 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.9.6",
"@backstage/backend-common": "^0.9.7",
"@backstage/catalog-client": "^0.5.0",
"@backstage/catalog-model": "^0.9.4",
"@backstage/catalog-model": "^0.9.5",
"@backstage/config": "^0.1.10",
"@backstage/errors": "^0.1.1",
"@backstage/test-utils": "^0.1.18",
"@backstage/errors": "^0.1.3",
"@backstage/test-utils": "^0.1.19",
"@google-cloud/firestore": "^4.15.1",
"@types/express": "^4.17.6",
"@types/passport": "^1.0.3",
"compression": "^1.7.4",
@@ -72,7 +73,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.7.15",
"@backstage/cli": "^0.8.0",
"@types/body-parser": "^1.19.0",
"@types/cookie-parser": "^1.4.2",
"@types/express-session": "^1.17.2",
@@ -0,0 +1,184 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Firestore } from '@google-cloud/firestore';
import {
DEFAULT_DOCUMENT_PATH,
DEFAULT_TIMEOUT_MS,
FirestoreKeyStore,
FirestoreKeyStoreSettings,
} from './FirestoreKeyStore';
import { AnyJWK } from './types';
const data = jest.fn().mockReturnValue('data');
const toDate = jest.fn().mockReturnValue('date');
const get = jest.fn().mockReturnValue({
docs: [{ data, createTime: { toDate } }],
});
const set = jest.fn();
const firestoreMock = {
limit: jest.fn().mockReturnThis(),
collection: jest.fn().mockReturnThis(),
delete: jest.fn(),
doc: jest.fn().mockReturnThis(),
set,
get,
};
jest.mock('@google-cloud/firestore', () => ({
Firestore: jest.fn().mockImplementation(() => firestoreMock),
}));
describe('FirestoreKeyStore', () => {
const key = {
kid: '123',
use: 'sig',
kty: 'plain',
alg: 'Base64',
} as AnyJWK;
const settings = {
projectId: 'my-project',
host: 'my-host',
port: 8080,
ssl: false,
keyFilename: 'cred.json',
};
const path = 'my-path';
const timeout = 10;
const firestoreSettings = {
...settings,
path,
timeout,
} as FirestoreKeyStoreSettings;
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
it('can create an instance without settings', async () => {
const keyStore = await FirestoreKeyStore.create();
expect(keyStore).toBeInstanceOf(FirestoreKeyStore);
expect(Firestore).toHaveBeenCalledWith({});
});
it('can create an instance with settings', async () => {
await FirestoreKeyStore.create(firestoreSettings);
expect(Firestore).toHaveBeenCalledWith(settings);
});
it('can verify that is has a connection to the database', async () => {
const keyStore = await FirestoreKeyStore.create();
await expect(
FirestoreKeyStore.verifyConnection(keyStore),
).resolves.not.toThrow();
});
it('can verify that it can not connect to the database', async () => {
const keyStore = await FirestoreKeyStore.create();
firestoreMock.get = jest.fn().mockRejectedValue(new Error());
await expect(
FirestoreKeyStore.verifyConnection(keyStore),
).rejects.toThrow();
firestoreMock.get = get;
});
it('can use a default timeout and path', async () => {
const keyStore = await FirestoreKeyStore.create();
await keyStore.addKey(key);
expect(setTimeout).toBeCalledWith(expect.any(Function), DEFAULT_TIMEOUT_MS);
expect(firestoreMock.collection).toBeCalledWith(DEFAULT_DOCUMENT_PATH);
});
it('can handle a timeout', async () => {
firestoreMock.set = jest
.fn()
.mockImplementation(
() => new Promise(resolve => setTimeout(resolve, 20)),
);
const keyStore = await FirestoreKeyStore.create(firestoreSettings);
const add = keyStore.addKey(key);
jest.advanceTimersByTime(50);
await expect(add).rejects.toEqual(
new Error(`Operation timed out after ${timeout}ms`),
);
firestoreMock.set = set;
});
it('can add keys', async () => {
const keyStore = await FirestoreKeyStore.create(firestoreSettings);
await keyStore.addKey(key);
expect(setTimeout).toBeCalledTimes(1);
expect(firestoreMock.collection).toBeCalledWith(path);
expect(firestoreMock.doc).toBeCalledWith(key.kid);
expect(firestoreMock.set).toHaveBeenCalledWith({
kid: key.kid,
key: JSON.stringify(key),
});
});
it('can delete a single key', async () => {
const keyStore = await FirestoreKeyStore.create(firestoreSettings);
await keyStore.removeKeys(['123']);
expect(setTimeout).toBeCalledTimes(1);
expect(firestoreMock.collection).toBeCalledWith(path);
expect(firestoreMock.doc).toBeCalledWith('123');
expect(firestoreMock.delete).toBeCalledTimes(1);
});
it('can delete a multiple keys', async () => {
const keyStore = await FirestoreKeyStore.create(firestoreSettings);
await keyStore.removeKeys(['123', '456']);
expect(setTimeout).toBeCalledTimes(2);
expect(firestoreMock.collection).toBeCalledWith(path);
expect(firestoreMock.doc).toBeCalledWith('123');
expect(firestoreMock.doc).toBeCalledWith('456');
expect(firestoreMock.delete).toBeCalledTimes(2);
});
it('can list keys', async () => {
const keyStore = await FirestoreKeyStore.create(firestoreSettings);
const items = await keyStore.listKeys();
expect(setTimeout).toBeCalledTimes(1);
expect(firestoreMock.collection).toBeCalledWith(path);
expect(firestoreMock.get).toBeCalledTimes(1);
expect(data).toBeCalledTimes(1);
expect(toDate).toBeCalledTimes(1);
expect(items).toMatchObject({
items: [{ key: 'data', createdAt: 'date' }],
});
});
});
@@ -0,0 +1,157 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Logger } from 'winston';
import {
DocumentData,
Firestore,
QuerySnapshot,
Settings,
WriteResult,
} from '@google-cloud/firestore';
import { AnyJWK, KeyStore, StoredKey } from './types';
export type FirestoreKeyStoreSettings = Settings & Options;
type Options = {
path?: string;
timeout?: number;
};
export const DEFAULT_TIMEOUT_MS = 10000;
export const DEFAULT_DOCUMENT_PATH = 'sessions';
export class FirestoreKeyStore implements KeyStore {
static async create(
settings?: FirestoreKeyStoreSettings,
): Promise<FirestoreKeyStore> {
const { path, timeout, ...firestoreSettings } = settings ?? {};
const database = new Firestore(firestoreSettings);
return new FirestoreKeyStore(
database,
path ?? DEFAULT_DOCUMENT_PATH,
timeout ?? DEFAULT_TIMEOUT_MS,
);
}
private constructor(
private readonly database: Firestore,
private readonly path: string,
private readonly timeout: number,
) {}
static async verifyConnection(
keyStore: FirestoreKeyStore,
logger?: Logger,
): Promise<void> {
try {
await keyStore.verify();
} catch (error) {
if (process.env.NODE_ENV !== 'development') {
throw new Error(
`Failed to connect to database: ${(error as Error).message}`,
);
}
logger?.warn(
`Failed to connect to database: ${(error as Error).message}`,
);
}
}
async addKey(key: AnyJWK): Promise<void> {
await this.withTimeout<WriteResult>(
this.database
.collection(this.path)
.doc(key.kid)
.set({
kid: key.kid,
key: JSON.stringify(key),
}),
);
}
async listKeys(): Promise<{ items: StoredKey[] }> {
const keys = await this.withTimeout<QuerySnapshot<DocumentData>>(
this.database.collection(this.path).get(),
);
return {
items: keys.docs.map(key => ({
key: key.data() as AnyJWK,
createdAt: key.createTime.toDate(),
})),
};
}
async removeKeys(kids: string[]): Promise<void> {
// This is probably really slow, but it's done async in the background
for (const kid of kids) {
await this.withTimeout<WriteResult>(
this.database.collection(this.path).doc(kid).delete(),
);
}
/**
* This could be achieved with batching but there's a couple of limitations with that:
*
* - A batched write can contain a maximum of 500 operations
* https://firebase.google.com/docs/firestore/manage-data/transactions#batched-writes
*
* - The "in" operator can combine a maximum of 10 equality clauses
* https://firebase.google.com/docs/firestore/query-data/queries#in_not-in_and_array-contains-any
*
* Example:
*
* const batch = this.database.batch();
* const docs = await this.database
* .collection(this.path)
* .where('kid', 'in', kids)
* .get();
* docs.forEach(doc => {
* batch.delete(doc.ref);
* });
* await batch.commit();
*
*/
}
/**
* Helper function to allow us to modify the timeout used when
* performing Firestore database operations.
*
* The reason for this is that it seems that there's no other
* practical solution to change the default timeout of 10mins
* that Firestore has.
*
*/
private async withTimeout<T>(operation: Promise<T>): Promise<T> {
const timer = new Promise<never>((_, reject) =>
setTimeout(() => {
reject(new Error(`Operation timed out after ${this.timeout}ms`));
}, this.timeout),
);
return Promise.race<T>([operation, timer]);
}
/**
* Used to verify that the database is reachable.
*/
private async verify(): Promise<void> {
await this.withTimeout(this.database.collection(this.path).limit(1).get());
}
}
@@ -0,0 +1,98 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DatabaseManager } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { MemoryKeyStore } from './MemoryKeyStore';
import { DatabaseKeyStore } from './DatabaseKeyStore';
import { FirestoreKeyStore } from './FirestoreKeyStore';
import { KeyStores } from './KeyStores';
describe('KeyStores', () => {
const defaultConfigOptions = {
auth: {
keyStore: {
provider: 'memory',
},
},
};
const defaultConfig = new ConfigReader(defaultConfigOptions);
it('reads auth section from config', async () => {
const configSpy = jest.spyOn(defaultConfig, 'getOptionalConfig');
const keyStore = await KeyStores.fromConfig(defaultConfig);
expect(keyStore).toBeInstanceOf(MemoryKeyStore);
expect(configSpy).toHaveBeenCalledWith('auth.keyStore');
expect(
defaultConfig
.getOptionalConfig('auth.keyStore')
?.getOptionalString('provider'),
).toBe(defaultConfigOptions.auth.keyStore.provider);
});
it('can handle without auth config', async () => {
const config = new ConfigReader({
backend: {
database: {
client: 'sqlite3',
connection: ':memory:',
},
},
});
const database =
DatabaseManager.fromConfig(config).forPlugin('auth-backend');
const keyStore = await KeyStores.fromConfig(config, { database });
expect(keyStore).toBeInstanceOf(DatabaseKeyStore);
});
it('can handle additional provider config', async () => {
jest.spyOn(FirestoreKeyStore, 'verifyConnection').mockResolvedValue();
const createSpy = jest.spyOn(FirestoreKeyStore, 'create');
const configOptions = {
auth: {
keyStore: {
provider: 'firestore',
firestore: {
projectId: 'my-project',
keyFilename: 'cred.json',
path: 'my-path',
timeout: 100,
host: 'localhost',
port: 8088,
ssl: false,
},
},
},
};
const config = new ConfigReader(configOptions);
const keyStore = await KeyStores.fromConfig(config);
expect(keyStore).toBeInstanceOf(FirestoreKeyStore);
expect(createSpy).toHaveBeenCalledWith(
configOptions.auth.keyStore.firestore,
);
expect(
config
.getOptionalConfig('auth.keyStore')
?.getOptionalConfig('firestore')
?.getOptionalString('projectId'),
).toBe(configOptions.auth.keyStore.firestore.projectId);
});
});
@@ -0,0 +1,84 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Logger } from 'winston';
import { PluginDatabaseManager } from '@backstage/backend-common';
import { Config } from '@backstage/config';
import { DatabaseKeyStore } from './DatabaseKeyStore';
import { MemoryKeyStore } from './MemoryKeyStore';
import { FirestoreKeyStore } from './FirestoreKeyStore';
import { KeyStore } from './types';
type Options = {
logger?: Logger;
database?: PluginDatabaseManager;
};
export class KeyStores {
/**
* Looks at the `auth.keyStore` section in the application configuration
* and returns a KeyStore store. Defaults to `database`
*
* @returns a KeyStore store
*/
static async fromConfig(
config: Config,
options?: Options,
): Promise<KeyStore> {
const { logger, database } = options ?? {};
const ks = config.getOptionalConfig('auth.keyStore');
const provider = ks?.getOptionalString('provider') ?? 'database';
logger?.info(`Configuring "${provider}" as KeyStore provider`);
if (provider === 'database') {
if (!database) {
throw new Error('This KeyStore provider requires a database');
}
return await DatabaseKeyStore.create({
database: await database.getClient(),
});
}
if (provider === 'memory') {
return new MemoryKeyStore();
}
if (provider === 'firestore') {
const settings = ks?.getConfig(provider);
const keyStore = await FirestoreKeyStore.create({
projectId: settings?.getOptionalString('projectId'),
keyFilename: settings?.getOptionalString('keyFilename'),
host: settings?.getOptionalString('host'),
port: settings?.getOptionalNumber('port'),
ssl: settings?.getOptionalBoolean('ssl'),
path: settings?.getOptionalString('path'),
timeout: settings?.getOptionalNumber('timeout'),
});
await FirestoreKeyStore.verifyConnection(keyStore, logger);
return keyStore;
}
throw new Error(`Unknown KeyStore provider: ${provider}`);
}
}
@@ -18,4 +18,7 @@ export { createOidcRouter } from './router';
export { IdentityClient } from './IdentityClient';
export { TokenFactory } from './TokenFactory';
export { DatabaseKeyStore } from './DatabaseKeyStore';
export { MemoryKeyStore } from './MemoryKeyStore';
export { FirestoreKeyStore } from './FirestoreKeyStore';
export { KeyStores } from './KeyStores';
export type { KeyStore, TokenIssuer, TokenParams } from './types';
@@ -22,7 +22,7 @@ import {
BackstageIdentity,
AuthProviderConfig,
} from '../../providers/types';
import { InputError, NotAllowedError } from '@backstage/errors';
import { InputError, isError, NotAllowedError } from '@backstage/errors';
import { TokenIssuer } from '../../identity/types';
import { readState, verifyNonce } from './helpers';
import { postMessageResponse, ensuresXRequestedWith } from '../flow';
@@ -153,13 +153,13 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
response,
});
} catch (error) {
const { name, message } = isError(error)
? error
: new Error('Encountered invalid error'); // Being a bit safe and not forwarding the bad value
// post error message back to popup if failure
return postMessageResponse(res, appOrigin, {
type: 'authorization_response',
error: {
name: error.name,
message: error.message,
},
error: { name, message },
});
}
}
@@ -220,7 +220,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers {
res.status(200).json(response);
} catch (error) {
res.status(401).send(`${error.message}`);
res.status(401).send(String(error));
}
}
@@ -14,10 +14,8 @@
* limitations under the License.
*/
/**
* An old Backstage plugin that provides a welcome page
*
* @packageDocumentation
*/
export { welcomePlugin, welcomePlugin as plugin, WelcomePage } from './plugin';
export { createAtlassianProvider } from './provider';
export type {
AtlassianAuthProvider,
AtlassianProviderOptions,
} from './provider';
@@ -0,0 +1,146 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AtlassianAuthProvider } from './provider';
import * as helpers from '../../lib/passport/PassportStrategyHelper';
import { getVoidLogger } from '@backstage/backend-common';
import { TokenIssuer } from '../../identity';
import { CatalogIdentityClient } from '../../lib/catalog';
import { OAuthResult } from '../../lib/oauth';
import { PassportProfile } from '../../lib/passport/types';
const mockFrameHandler = jest.spyOn(
helpers,
'executeFrameHandlerStrategy',
) as unknown as jest.MockedFunction<() => Promise<{ result: OAuthResult }>>;
describe('createAtlassianProvider', () => {
const tokenIssuer = {
issueToken: jest.fn(),
listPublicKeys: jest.fn(),
};
const catalogIdentityClient = {
findUser: jest.fn(),
};
const provider = new AtlassianAuthProvider({
logger: getVoidLogger(),
catalogIdentityClient:
catalogIdentityClient as unknown as CatalogIdentityClient,
tokenIssuer: tokenIssuer as unknown as TokenIssuer,
authHandler: async ({ fullProfile }) => ({
profile: {
email: fullProfile.emails![0]!.value,
displayName: fullProfile.displayName,
picture: 'http://google.com/lols',
},
}),
clientId: 'mock',
clientSecret: 'mock',
callbackUrl: 'mock',
scopes: 'scope',
});
it('should auth', async () => {
mockFrameHandler.mockResolvedValueOnce({
result: {
fullProfile: {
photos: [
{
value:
'https://a1cf74336522e87f135f-2f21ace9a6cf0052456644b80fa06d4f.ssl.cf2.rackcdn.com/images/characters_opt/p-mystic-river-sean-penn.jpg',
},
],
emails: [{ value: 'conrad@example.com' }],
displayName: 'Conrad',
id: 'conrad',
provider: 'google',
},
params: {
id_token: 'idToken',
scope: 'scope',
expires_in: 123,
},
accessToken: 'accessToken',
refreshToken: 'wacka',
},
});
const { response } = await provider.handler({} as any);
expect(response).toEqual({
providerInfo: {
accessToken: 'accessToken',
expiresInSeconds: 123,
idToken: 'idToken',
scope: 'scope',
refreshToken: 'wacka',
},
profile: {
email: 'conrad@example.com',
displayName: 'Conrad',
picture: 'http://google.com/lols',
},
});
});
it('should forward a new refresh token on refresh', async () => {
const mockRefreshToken = jest.spyOn(
helpers,
'executeRefreshTokenStrategy',
) as unknown as jest.MockedFunction<() => Promise<{}>>;
mockRefreshToken.mockResolvedValueOnce({
accessToken: 'a.b.c',
refreshToken: 'dont-forget-to-send-refresh',
params: {
id_token: 'my-id',
scope: 'read_user',
},
});
const mockUserProfile = jest.spyOn(
helpers,
'executeFetchUserProfileStrategy',
) as unknown as jest.MockedFunction<() => Promise<PassportProfile>>;
mockUserProfile.mockResolvedValueOnce({
id: 'uid-my-id',
username: 'mockuser',
provider: 'atlassian',
displayName: 'Mocked User',
emails: [
{
value: 'mockuser@gmail.com',
},
],
});
const response = await provider.refresh({} as any);
expect(response).toEqual({
profile: {
displayName: 'Mocked User',
email: 'mockuser@gmail.com',
picture: 'http://google.com/lols',
},
providerInfo: {
accessToken: 'a.b.c',
idToken: 'my-id',
refreshToken: 'dont-forget-to-send-refresh',
scope: 'read_user',
},
});
});
});
@@ -0,0 +1,238 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import AtlassianStrategy from './strategy';
import {
encodeState,
OAuthAdapter,
OAuthEnvironmentHandler,
OAuthHandlers,
OAuthProviderOptions,
OAuthRefreshRequest,
OAuthResponse,
OAuthResult,
OAuthStartRequest,
} from '../../lib/oauth';
import passport from 'passport';
import {
executeFetchUserProfileStrategy,
executeFrameHandlerStrategy,
executeRedirectStrategy,
executeRefreshTokenStrategy,
makeProfileInfo,
PassportDoneCallback,
} from '../../lib/passport';
import {
AuthHandler,
AuthProviderFactory,
RedirectInfo,
SignInResolver,
} from '../types';
import express from 'express';
import { TokenIssuer } from '../../identity';
import { CatalogIdentityClient } from '../../lib/catalog';
import { Logger } from 'winston';
export type AtlassianAuthProviderOptions = OAuthProviderOptions & {
scopes: string;
signInResolver?: SignInResolver<OAuthResult>;
authHandler: AuthHandler<OAuthResult>;
tokenIssuer: TokenIssuer;
catalogIdentityClient: CatalogIdentityClient;
logger: Logger;
};
export const atlassianDefaultAuthHandler: AuthHandler<OAuthResult> = async ({
fullProfile,
params,
}) => ({
profile: makeProfileInfo(fullProfile, params.id_token),
});
export class AtlassianAuthProvider implements OAuthHandlers {
private readonly _strategy: AtlassianStrategy;
private readonly signInResolver?: SignInResolver<OAuthResult>;
private readonly authHandler: AuthHandler<OAuthResult>;
private readonly tokenIssuer: TokenIssuer;
private readonly catalogIdentityClient: CatalogIdentityClient;
private readonly logger: Logger;
constructor(options: AtlassianAuthProviderOptions) {
this.catalogIdentityClient = options.catalogIdentityClient;
this.logger = options.logger;
this.tokenIssuer = options.tokenIssuer;
this.authHandler = options.authHandler;
this.signInResolver = options.signInResolver;
this._strategy = new AtlassianStrategy(
{
clientID: options.clientId,
clientSecret: options.clientSecret,
callbackURL: options.callbackUrl,
scope: options.scopes,
},
(
accessToken: any,
refreshToken: any,
params: any,
fullProfile: passport.Profile,
done: PassportDoneCallback<OAuthResult>,
) => {
done(undefined, {
fullProfile,
accessToken,
refreshToken,
params,
});
},
);
}
async start(req: OAuthStartRequest): Promise<RedirectInfo> {
return await executeRedirectStrategy(req, this._strategy, {
state: encodeState(req.state),
});
}
async handler(
req: express.Request,
): Promise<{ response: OAuthResponse; refreshToken: string }> {
const { result } = await executeFrameHandlerStrategy<OAuthResult>(
req,
this._strategy,
);
return {
response: await this.handleResult(result),
refreshToken: result.refreshToken ?? '',
};
}
private async handleResult(result: OAuthResult): Promise<OAuthResponse> {
const { profile } = await this.authHandler(result);
const response: OAuthResponse = {
providerInfo: {
idToken: result.params.id_token,
accessToken: result.accessToken,
refreshToken: result.refreshToken,
scope: result.params.scope,
expiresInSeconds: result.params.expires_in,
},
profile,
};
if (this.signInResolver) {
response.backstageIdentity = await this.signInResolver(
{
result,
profile,
},
{
tokenIssuer: this.tokenIssuer,
catalogIdentityClient: this.catalogIdentityClient,
logger: this.logger,
},
);
}
return response;
}
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
const {
accessToken,
params,
refreshToken: newRefreshToken,
} = await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
);
const fullProfile = await executeFetchUserProfileStrategy(
this._strategy,
accessToken,
);
return this.handleResult({
fullProfile,
params,
accessToken,
refreshToken: newRefreshToken,
});
}
}
export type AtlassianProviderOptions = {
/**
* The profile transformation function used to verify and convert the auth response
* into the profile that will be presented to the user.
*/
authHandler?: AuthHandler<OAuthResult>;
/**
* Configure sign-in for this provider, without it the provider can not be used to sign users in.
*/
signIn?: {
resolver: SignInResolver<OAuthResult>;
};
};
export const createAtlassianProvider = (
options?: AtlassianProviderOptions,
): AuthProviderFactory => {
return ({
providerId,
globalConfig,
config,
tokenIssuer,
catalogApi,
logger,
}) =>
OAuthEnvironmentHandler.mapConfig(config, envConfig => {
const clientId = envConfig.getString('clientId');
const clientSecret = envConfig.getString('clientSecret');
const scopes = envConfig.getString('scopes');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
const catalogIdentityClient = new CatalogIdentityClient({
catalogApi,
tokenIssuer,
});
const authHandler: AuthHandler<OAuthResult> =
options?.authHandler ?? atlassianDefaultAuthHandler;
const provider = new AtlassianAuthProvider({
clientId,
clientSecret,
scopes,
callbackUrl,
authHandler,
signInResolver: options?.signIn?.resolver,
catalogIdentityClient,
logger,
tokenIssuer,
});
return OAuthAdapter.fromConfig(globalConfig, provider, {
disableRefresh: true,
providerId,
tokenIssuer,
});
});
};
@@ -0,0 +1,113 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import OAuth2Strategy, { InternalOAuthError } from 'passport-oauth2';
import { Profile } from 'passport';
interface ProfileResponse {
account_id: string;
email: string;
name: string;
picture: string;
nickname: string;
}
interface AtlassianStrategyOptions {
clientID: string;
clientSecret: string;
callbackURL: string;
scope: string;
}
const defaultScopes = ['offline_access', 'read:me'];
export default class AtlassianStrategy extends OAuth2Strategy {
private readonly profileURL: string;
constructor(
options: AtlassianStrategyOptions,
verify: OAuth2Strategy.VerifyFunction,
) {
if (!options.scope) {
throw new TypeError('Atlassian requires a scope option');
}
const scopes = options.scope.split(' ');
const optionsWithURLs = {
...options,
authorizationURL: `https://auth.atlassian.com/authorize`,
tokenURL: `https://auth.atlassian.com/oauth/token`,
scope: Array.from(new Set([...defaultScopes, ...scopes])),
};
super(optionsWithURLs, verify);
this.profileURL = 'https://api.atlassian.com/me';
this.name = 'atlassian';
this._oauth2.useAuthorizationHeaderforGET(true);
}
authorizationParams() {
return {
audience: 'api.atlassian.com',
prompt: 'consent',
};
}
userProfile(
accessToken: string,
done: (err?: Error | null, profile?: any) => void,
): void {
this._oauth2.get(this.profileURL, accessToken, (err, body) => {
if (err) {
return done(
new InternalOAuthError(
'Failed to fetch user profile',
err.statusCode,
),
);
}
if (!body) {
return done(
new Error('Failed to fetch user profile, body cannot be empty'),
);
}
try {
const json = typeof body !== 'string' ? body.toString() : body;
const profile = AtlassianStrategy.parse(json);
return done(null, profile);
} catch (e) {
return done(new Error('Failed to parse user profile'));
}
});
}
static parse(json: string): Profile {
const resp = JSON.parse(json) as ProfileResponse;
return {
id: resp.account_id,
provider: 'atlassian',
username: resp.nickname,
displayName: resp.name,
emails: [{ value: resp.email }],
photos: [{ value: resp.picture }],
};
}
}
@@ -27,6 +27,7 @@ import { createOneLoginProvider } from './onelogin';
import { AuthProviderFactory } from './types';
import { createAwsAlbProvider } from './aws-alb';
import { createBitbucketProvider } from './bitbucket';
import { createAtlassianProvider } from './atlassian';
export const factories: { [providerId: string]: AuthProviderFactory } = {
google: createGoogleProvider(),
@@ -41,4 +42,5 @@ export const factories: { [providerId: string]: AuthProviderFactory } = {
onelogin: createOneLoginProvider(),
awsalb: createAwsAlbProvider(),
bitbucket: createBitbucketProvider(),
atlassian: createAtlassianProvider(),
};
@@ -278,5 +278,59 @@ describe('GithubAuthProvider', () => {
refreshToken: 'refresh-me',
});
});
it('should forward a new refresh token on refresh', async () => {
const mockRefreshToken = jest.spyOn(
helpers,
'executeRefreshTokenStrategy',
) as unknown as jest.MockedFunction<() => Promise<{}>>;
mockRefreshToken.mockResolvedValueOnce({
accessToken: 'a.b.c',
refreshToken: 'dont-forget-to-send-refresh',
params: {
id_token: 'my-id',
expires_in: '123',
scope: 'read_user',
},
});
const mockUserProfile = jest.spyOn(
helpers,
'executeFetchUserProfileStrategy',
) as unknown as jest.MockedFunction<() => Promise<PassportProfile>>;
mockUserProfile.mockResolvedValueOnce({
id: 'mockid',
username: 'mockuser',
provider: 'github',
displayName: 'Mocked User',
emails: [
{
value: 'mockuser@gmail.com',
},
],
});
const response = await provider.refresh({} as any);
expect(response).toEqual({
backstageIdentity: {
id: 'mockuser',
token: 'token-for-mockuser',
},
profile: {
displayName: 'Mocked User',
email: 'mockuser@gmail.com',
picture: undefined,
},
providerInfo: {
accessToken: 'a.b.c',
refreshToken: 'dont-forget-to-send-refresh',
expiresInSeconds: 123,
scope: 'read_user',
},
});
});
});
});
@@ -126,7 +126,11 @@ export class GithubAuthProvider implements OAuthHandlers {
}
async refresh(req: OAuthRefreshRequest): Promise<OAuthResponse> {
const { accessToken, params } = await executeRefreshTokenStrategy(
const {
accessToken,
refreshToken: newRefreshToken,
params,
} = await executeRefreshTokenStrategy(
this._strategy,
req.refreshToken,
req.scope,
@@ -139,7 +143,7 @@ export class GithubAuthProvider implements OAuthHandlers {
fullProfile,
params,
accessToken,
refreshToken: req.refreshToken,
refreshToken: newRefreshToken,
});
}
@@ -150,6 +154,7 @@ export class GithubAuthProvider implements OAuthHandlers {
const response: OAuthResponse = {
providerInfo: {
accessToken: result.accessToken,
refreshToken: result.refreshToken, // GitHub expires the old refresh token when used
scope: result.params.scope,
expiresInSeconds:
expiresInStr === undefined ? undefined : Number(expiresInStr),
@@ -21,6 +21,7 @@ export * from './microsoft';
export * from './oauth2';
export * from './okta';
export * from './bitbucket';
export * from './atlassian';
export * from './aws-alb';
export { factories as defaultAuthProviderFactories } from './factories';
@@ -275,6 +275,13 @@ export const createOktaProvider = (
const audience = envConfig.getString('audience');
const callbackUrl = `${globalConfig.baseUrl}/${providerId}/handler/frame`;
// This is a safe assumption as `passport-okta-oauth` uses the audience
// as the base for building the authorization, token, and user info URLs.
// https://github.com/fischerdan/passport-okta-oauth/blob/ea9ac42d/lib/passport-okta-oauth/oauth2.js#L12-L14
if (!audience.startsWith('https://')) {
throw new Error("URL for 'audience' must start with 'https://'.");
}
const catalogIdentityClient = new CatalogIdentityClient({
catalogApi,
tokenIssuer,
@@ -29,6 +29,7 @@ import {
import { AuthProviderRouteHandlers, AuthProviderFactory } from '../types';
import { postMessageResponse } from '../../lib/flow';
import { TokenIssuer } from '../../identity/types';
import { isError } from '@backstage/errors';
type SamlInfo = {
fullProfile: any;
@@ -93,12 +94,12 @@ export class SamlAuthProvider implements AuthProviderRouteHandlers {
},
});
} catch (error) {
const { name, message } = isError(error)
? error
: new Error('Encountered invalid error'); // Being a bit safe and not forwarding the bad value
return postMessageResponse(res, this.appUrl, {
type: 'authorization_response',
error: {
name: error.name,
message: error.message,
},
error: { name, message },
});
}
}
+4 -5
View File
@@ -26,10 +26,10 @@ import {
PluginDatabaseManager,
PluginEndpointDiscovery,
} from '@backstage/backend-common';
import { NotFoundError } from '@backstage/errors';
import { assertError, NotFoundError } from '@backstage/errors';
import { CatalogClient } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { createOidcRouter, DatabaseKeyStore, TokenFactory } from '../identity';
import { createOidcRouter, TokenFactory, KeyStores } from '../identity';
import session from 'express-session';
import passport from 'passport';
import { Minimatch } from 'minimatch';
@@ -56,11 +56,9 @@ export async function createRouter({
const appUrl = config.getString('app.baseUrl');
const authUrl = await discovery.getExternalBaseUrl('auth');
const keyStore = await KeyStores.fromConfig(config, { logger, database });
const keyDurationSeconds = 3600;
const keyStore = await DatabaseKeyStore.create({
database: await database.getClient(),
});
const tokenIssuer = new TokenFactory({
issuer: authUrl,
keyStore,
@@ -121,6 +119,7 @@ export async function createRouter({
router.use(`/${providerId}`, r);
} catch (e) {
assertError(e);
if (process.env.NODE_ENV !== 'development') {
throw new Error(
`Failed to initialize ${providerId} auth provider, ${e.message}`,
+15
View File
@@ -1,5 +1,20 @@
# @backstage/plugin-azure-devops-backend
## 0.1.3
### Patch Changes
- f67dff0d20: Re-exported types from azure-devops-node-api in @backstage/plugin-azure-devops-backend.
- Updated dependencies
- @backstage/backend-common@0.9.7
## 0.1.2
### Patch Changes
- a23206049f: Updates function for mapping RepoBuilds to handle undefined properties
- b7c0585471: Expands the Azure DevOps backend plugin to provide pull request data to be used by the front end plugin
## 0.1.1
### Patch Changes
+49 -2
View File
@@ -4,7 +4,11 @@ Simple plugin that proxies requests to the [Azure DevOps](https://docs.microsoft
## Setup
The following values are read from the configuration file:
The following sections will help you get the Azure DevOps Backend plugin setup and running.
### Configuration
The Azure DevOps plugin requires the following YAML to be added to your app-config.yaml:
```yaml
azureDevOps:
@@ -17,8 +21,51 @@ Configuration Details:
- `host` and `token` can be the same as the ones used for the `integration` section
- `AZURE_TOKEN` environment variable must be set to a [Personal Access Token](https://docs.microsoft.com/en-us/azure/devops/organizations/accounts/use-personal-access-tokens-to-authenticate?view=azure-devops&tabs=preview-page) with read access to both Code and Build
- `organization` is your Azure DevOps Organization name or for Azure DevOps Server (on-premise) this will be your Collection name
- `organization` is your Azure DevOps Services (cloud) Organization name or for Azure DevOps Server (on-premise) this will be your Collection name
### Up and Running
Here's how to get the backend up and running:
1. First we need to add the `@backstage/plugin-azure-devops-backend` package to your backend:
```sh
# From the Backstage root directory
cd packages/backend
yarn add @backstage/plugin-azure-devops-backend
```
2. Then we will create a new file named `packages/backend/src/plugins/azure-devops.ts`, and add the
following to it:
```ts
import { createRouter } from '@backstage/plugin-azure-devops-backend';
import { Router } from 'express';
import type { PluginEnvironment } from '../types';
export default function createPlugin({
logger,
config,
}: PluginEnvironment): Promise<Router> {
return createRouter({ logger, config });
}
```
3. Next we wire this into the overall backend router, edit `packages/backend/src/index.ts`:
```ts
import azureDevOps from './plugins/azure-devops';
// ...
async function main() {
// ...
const azureDevOpsEnv = useHotMemoize(module, () => createEnv('azure-devops'));
apiRouter.use('/azure-devops', await azureDevOps(azureDevOpsEnv));
```
4. Now run `yarn start-backend` from the repo root
5. Finally open `http://localhost:7000/api/azure-devops/health` in a browser and it should return `{"status":"ok"}`
## Links
- [Frontend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/azure-devops)
- [The Backstage homepage](https://backstage.io)
@@ -45,6 +45,10 @@ export class AzureDevOpsApi {
): Promise<RepoBuild[]>;
}
export { BuildResult };
export { BuildStatus };
// Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-azure-devops-backend",
"version": "0.1.1",
"version": "0.1.3",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -20,7 +20,7 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.9.5",
"@backstage/backend-common": "^0.9.7",
"@backstage/config": "^0.1.9",
"@types/express": "^4.17.6",
"azure-devops-node-api": "^11.0.1",
@@ -30,7 +30,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.7.14",
"@backstage/cli": "^0.8.0",
"@types/supertest": "^2.0.8",
"supertest": "^4.0.2",
"msw": "^0.29.0"
@@ -14,42 +14,47 @@
* limitations under the License.
*/
import { Logger } from 'winston';
import { WebApi } from 'azure-devops-node-api';
import {
Build,
BuildResult,
BuildStatus,
} from 'azure-devops-node-api/interfaces/BuildInterfaces';
import {
GitPullRequest,
GitPullRequestSearchCriteria,
} from 'azure-devops-node-api/interfaces/GitInterfaces';
import { PullRequest, PullRequestOptions, RepoBuild } from './types';
GitRepository,
PullRequest,
PullRequestOptions,
RepoBuild,
} from './types';
import { Logger } from 'winston';
import { WebApi } from 'azure-devops-node-api';
export class AzureDevOpsApi {
constructor(
public constructor(
private readonly logger: Logger,
private readonly webApi: WebApi,
) {}
async getGitRepository(projectName: string, repoName: string) {
if (this.logger) {
this.logger.debug(
`Calling Azure DevOps REST API, getting Repository ${repoName} for Project ${projectName}`,
);
}
public async getGitRepository(
projectName: string,
repoName: string,
): Promise<GitRepository> {
this.logger?.debug(
`Calling Azure DevOps REST API, getting Repository ${repoName} for Project ${projectName}`,
);
const client = await this.webApi.getGitApi();
return client.getRepository(repoName, projectName);
}
async getBuildList(projectName: string, repoId: string, top: number) {
if (this.logger) {
this.logger.debug(
`Calling Azure DevOps REST API, getting up to ${top} Builds for Repository Id ${repoId} for Project ${projectName}`,
);
}
public async getBuildList(
projectName: string,
repoId: string,
top: number,
): Promise<Build[]> {
this.logger?.debug(
`Calling Azure DevOps REST API, getting up to ${top} Builds for Repository Id ${repoId} for Project ${projectName}`,
);
const client = await this.webApi.getBuildApi();
return client.getBuilds(
@@ -77,12 +82,14 @@ export class AzureDevOpsApi {
);
}
async getRepoBuilds(projectName: string, repoName: string, top: number) {
if (this.logger) {
this.logger.debug(
`Calling Azure DevOps REST API, getting up to ${top} Builds for Repository ${repoName} for Project ${projectName}`,
);
}
public async getRepoBuilds(
projectName: string,
repoName: string,
top: number,
) {
this.logger?.debug(
`Calling Azure DevOps REST API, getting up to ${top} Builds for Repository ${repoName} for Project ${projectName}`,
);
const gitRepository = await this.getGitRepository(projectName, repoName);
const buildList = await this.getBuildList(
@@ -98,16 +105,14 @@ export class AzureDevOpsApi {
return repoBuilds;
}
async getPullRequests(
public async getPullRequests(
projectName: string,
repoName: string,
options: PullRequestOptions,
) {
if (this.logger) {
this.logger.debug(
`Calling Azure DevOps REST API, getting up to ${top} Pull Requests for Repository ${repoName} for Project ${projectName}`,
);
}
): Promise<PullRequest[]> {
this.logger?.debug(
`Calling Azure DevOps REST API, getting up to ${top} Pull Requests for Repository ${repoName} for Project ${projectName}`,
);
const gitRepository = await this.getGitRepository(projectName, repoName);
const client = await this.webApi.getGitApi();
@@ -133,7 +138,7 @@ export class AzureDevOpsApi {
}
}
export function mappedRepoBuild(build: Build) {
export function mappedRepoBuild(build: Build): RepoBuild {
return {
id: build.id,
title: [build.definition?.name, build.buildNumber]
@@ -150,7 +155,7 @@ export function mappedRepoBuild(build: Build) {
export function mappedPullRequest(
pullRequest: GitPullRequest,
linkBaseUrl: string,
) {
): PullRequest {
return {
pullRequestId: pullRequest.pullRequestId,
repoName: pullRequest.repository?.name,
@@ -15,4 +15,5 @@
*/
export { AzureDevOpsApi } from './AzureDevOpsApi';
export { BuildResult, BuildStatus } from './types';
export type { RepoBuild, PullRequest } from './types';
+15 -1
View File
@@ -15,10 +15,24 @@
*/
import {
Build,
BuildResult,
BuildStatus,
} from 'azure-devops-node-api/interfaces/BuildInterfaces';
import { PullRequestStatus } from 'azure-devops-node-api/interfaces/GitInterfaces';
import {
GitPullRequest,
GitPullRequestSearchCriteria,
GitRepository,
PullRequestStatus,
} from 'azure-devops-node-api/interfaces/GitInterfaces';
export { BuildResult, BuildStatus, PullRequestStatus };
export type {
Build,
GitPullRequest,
GitPullRequestSearchCriteria,
GitRepository,
};
export type RepoBuild = {
id?: number;
+1 -1
View File
@@ -13,6 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { AzureDevOpsApi } from './api';
export { AzureDevOpsApi, BuildResult, BuildStatus } from './api';
export type { RepoBuild, PullRequest } from './api';
export * from './service/router';
@@ -14,15 +14,15 @@
* limitations under the License.
*/
import { PullRequestOptions, PullRequestStatus } from '../api/types';
import { WebApi, getPersonalAccessTokenHandler } from 'azure-devops-node-api';
import { AzureDevOpsApi } from '../api';
import { Config } from '@backstage/config';
import { Logger } from 'winston';
import Router from 'express-promise-router';
import { errorHandler } from '@backstage/backend-common';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import { Config } from '@backstage/config';
import { getPersonalAccessTokenHandler, WebApi } from 'azure-devops-node-api';
import { AzureDevOpsApi } from '../api';
import { PullRequestStatus } from 'azure-devops-node-api/interfaces/GitInterfaces';
import { PullRequestOptions } from '../api/types';
const DEFAULT_TOP: number = 10;
+10
View File
@@ -0,0 +1,10 @@
# @backstage/plugin-azure-devops
## 0.1.1
### Patch Changes
- Updated dependencies
- @backstage/plugin-catalog-react@0.6.0
- @backstage/core-components@0.7.0
- @backstage/theme@0.2.11
+11 -11
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-azure-devops",
"version": "0.1.0",
"version": "0.1.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -27,12 +27,12 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.9.3",
"@backstage/core-components": "^0.6.1",
"@backstage/core-plugin-api": "^0.1.10",
"@backstage/errors": "^0.1.2",
"@backstage/plugin-catalog-react": "^0.5.1",
"@backstage/theme": "^0.2.10",
"@backstage/catalog-model": "^0.9.5",
"@backstage/core-components": "^0.7.1",
"@backstage/core-plugin-api": "^0.1.11",
"@backstage/errors": "^0.1.3",
"@backstage/plugin-catalog-react": "^0.6.1",
"@backstage/theme": "^0.2.11",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
@@ -44,10 +44,10 @@
"react-use": "^17.2.4"
},
"devDependencies": {
"@backstage/cli": "^0.7.15",
"@backstage/core-app-api": "^0.1.16",
"@backstage/dev-utils": "^0.2.11",
"@backstage/test-utils": "^0.1.18",
"@backstage/cli": "^0.8.0",
"@backstage/core-app-api": "^0.1.18",
"@backstage/dev-utils": "^0.2.12",
"@backstage/test-utils": "^0.1.19",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^13.1.8",
@@ -14,26 +14,27 @@
* limitations under the License.
*/
import React from 'react';
import { DateTime } from 'luxon';
import {
Link,
Table,
TableColumn,
StatusError,
StatusOK,
StatusWarning,
StatusAborted,
StatusRunning,
StatusPending,
ResponseErrorPanel,
} from '@backstage/core-components';
import { Box, Typography } from '@material-ui/core';
import { RepoBuild } from '../../api/types';
import {
BuildResult,
BuildStatus,
} from 'azure-devops-node-api/interfaces/BuildInterfaces';
import {
Link,
ResponseErrorPanel,
StatusAborted,
StatusError,
StatusOK,
StatusPending,
StatusRunning,
StatusWarning,
Table,
TableColumn,
} from '@backstage/core-components';
import { DateTime } from 'luxon';
import React from 'react';
import { RepoBuild } from '../../api/types';
const getBuildResultComponent = (result: number | undefined) => {
switch (result) {
@@ -154,13 +155,13 @@ const columns: TableColumn[] = [
},
];
type Props = {
type BuildTableProps = {
items?: RepoBuild[];
loading: boolean;
error?: any;
error?: Error;
};
export const BuildTable = ({ items, loading, error }: Props) => {
export const BuildTable = ({ items, loading, error }: BuildTableProps) => {
if (error) {
return (
<div>
@@ -180,7 +181,7 @@ export const BuildTable = ({ items, loading, error }: Props) => {
showEmptyDataSourceMessage: !loading,
}}
title={`Builds (${items ? items.length : 0})`}
data={items || []}
data={items ?? []}
/>
);
};
@@ -14,28 +14,31 @@
* limitations under the License.
*/
import { useAsync } from 'react-use';
import { Entity } from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { azureDevOpsApiRef } from '../api';
import { RepoBuild, RepoBuildOptions } from '../api/types';
import { useProjectRepoFromEntity } from './useProjectRepoFromEntity';
import { AZURE_DEVOPS_DEFAULT_TOP } from '../constants';
import { Entity } from '@backstage/catalog-model';
import { azureDevOpsApiRef } from '../api';
import { useApi } from '@backstage/core-plugin-api';
import { useAsync } from 'react-use';
import { useProjectRepoFromEntity } from './useProjectRepoFromEntity';
export function useRepoBuilds(
entity: Entity,
defaultLimit?: number,
): {
items: RepoBuild[] | undefined;
items?: RepoBuild[];
loading: boolean;
error: any;
error?: Error;
} {
const top = defaultLimit ?? AZURE_DEVOPS_DEFAULT_TOP;
const options: RepoBuildOptions = {
top: top,
};
const api = useApi(azureDevOpsApiRef);
const { project, repo } = useProjectRepoFromEntity(entity);
const { value, loading, error } = useAsync(() => {
return api.getRepoBuilds(project, repo, options);
}, [api, project, repo, entity]);
+4 -4
View File
@@ -31,11 +31,11 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.9.6",
"@backstage/backend-common": "^0.9.7",
"@backstage/catalog-client": "^0.5.0",
"@backstage/catalog-model": "^0.9.4",
"@backstage/catalog-model": "^0.9.5",
"@backstage/config": "^0.1.10",
"@backstage/errors": "^0.1.1",
"@backstage/errors": "^0.1.3",
"@types/express": "^4.17.6",
"badge-maker": "^3.3.0",
"cors": "^2.8.5",
@@ -46,7 +46,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.7.15",
"@backstage/cli": "^0.8.0",
"@types/supertest": "^2.0.8",
"supertest": "^6.1.3"
},
+9
View File
@@ -1,5 +1,14 @@
# @backstage/plugin-badges
## 0.2.13
### Patch Changes
- Updated dependencies
- @backstage/plugin-catalog-react@0.6.0
- @backstage/core-components@0.7.0
- @backstage/theme@0.2.11
## 0.2.12
### Patch Changes
+11 -11
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/plugin-badges",
"description": "A Backstage plugin that generates README badges for your entities",
"version": "0.2.12",
"version": "0.2.13",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -27,12 +27,12 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.9.4",
"@backstage/core-components": "^0.6.1",
"@backstage/core-plugin-api": "^0.1.10",
"@backstage/errors": "^0.1.1",
"@backstage/plugin-catalog-react": "^0.5.2",
"@backstage/theme": "^0.2.10",
"@backstage/catalog-model": "^0.9.5",
"@backstage/core-components": "^0.7.1",
"@backstage/core-plugin-api": "^0.1.11",
"@backstage/errors": "^0.1.3",
"@backstage/plugin-catalog-react": "^0.6.1",
"@backstage/theme": "^0.2.11",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
@@ -42,10 +42,10 @@
"react-use": "^17.2.4"
},
"devDependencies": {
"@backstage/cli": "^0.7.15",
"@backstage/core-app-api": "^0.1.16",
"@backstage/dev-utils": "^0.2.11",
"@backstage/test-utils": "^0.1.18",
"@backstage/cli": "^0.8.0",
"@backstage/core-app-api": "^0.1.18",
"@backstage/dev-utils": "^0.2.12",
"@backstage/test-utils": "^0.1.19",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^11.2.5",
"@testing-library/user-event": "^13.1.8",
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
};
+49
View File
@@ -0,0 +1,49 @@
# Bazaar Backend
Welcome to the Bazaar backend plugin!
# Installation
## Install the package
```bash
# From your Backstage root directory
cd packages/backend
yarn add @backstage/plugin-bazaar-backend
```
## Adding the plugin to your `packages/backend`
You'll need to add the plugin to the router in your `backend` package. You can do this by creating a file called `packages/backend/src/plugins/bazaar.ts`
```tsx
import { PluginEnvironment } from '../types';
import { createRouter } from '@backstage/plugin-bazaar-backend';
export default async function createPlugin({
logger,
database,
config,
}: PluginEnvironment) {
return await createRouter({ logger, config, database });
}
```
With the `bazaar.ts` router setup in place, add the router to `packages/backend/src/index.ts`:
```diff
+ import bazaar from './plugins/bazaar';
async function main() {
...
const createEnv = makeCreateEnv(config);
const catalogEnv = useHotMemoize(module, () => createEnv('catalog'));
+ const bazaarEnv = useHotMemoize(module, () => createEnv('bazaar'));
const apiRouter = Router();
+ apiRouter.use('/bazaar', await bazaar(bazaarEnv));
...
apiRouter.use(notFoundHandler());
```
+29
View File
@@ -0,0 +1,29 @@
## API Report File for "@backstage/plugin-bazaar-backend"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { Config } from '@backstage/config';
import express from 'express';
import { Logger as Logger_2 } from 'winston';
import { PluginDatabaseManager } from '@backstage/backend-common';
// Warning: (ae-missing-release-tag) "createRouter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export function createRouter(options: RouterOptions): Promise<express.Router>;
// Warning: (ae-missing-release-tag) "RouterOptions" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export interface RouterOptions {
// (undocumented)
config: Config;
// (undocumented)
database: PluginDatabaseManager;
// (undocumented)
logger: Logger_2;
}
// (No @packageDocumentation comment for this package)
```
@@ -0,0 +1,65 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
exports.up = async function up(knex) {
await knex.schema.createTable('metadata', table => {
table.comment('The table of Bazaar metadata');
table
.text('entity_ref')
.notNullable()
.unique()
.comment('The ref of the entity');
table.text('name').notNullable().comment('The name of the entity');
table
.text('community')
.comment('Link to where the community can discuss ideas');
table
.text('announcement')
.notNullable()
.comment('The announcement of the Bazaar project');
table
.text('status')
.defaultTo('proposed')
.notNullable()
.comment('The status of the Bazaar project');
table
.text('updated_at')
.notNullable()
.comment('Timestamp on ISO 8601 format when entity was last updated');
});
await knex.schema.createTable('members', table => {
table.comment('The table of Bazaar members');
table
.text('entity_ref')
.notNullable()
.references('metadata.entity_ref')
.onDelete('CASCADE')
.comment('The ref of the entity');
table.text('user_id').notNullable().comment('The user id of the member');
table
.dateTime('join_date')
.defaultTo(knex.fn.now())
.notNullable()
.comment('The timestamp when this member joined');
table.text('picture').comment('Link to profile picture');
});
};
exports.down = async function down(knex) {
await knex.schema.dropTable('metadata');
await knex.schema.dropTable('members');
};
+40
View File
@@ -0,0 +1,40 @@
{
"name": "@backstage/plugin-bazaar-backend",
"version": "0.1.1",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"scripts": {
"start": "backstage-cli backend:dev",
"build": "backstage-cli backend:build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.9.7",
"@backstage/backend-test-utils": "^0.1.8",
"@backstage/config": "^0.1.5",
"@types/express": "^4.17.6",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"knex": "^0.95.1",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.8.0"
},
"files": [
"dist",
"migrations/**/*.{js,d.ts}"
]
}
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { default } from './WelcomePage';
export * from './service/router';
@@ -14,27 +14,20 @@
* limitations under the License.
*/
import {
createPlugin,
createRoutableExtension,
createRouteRef,
} from '@backstage/core-plugin-api';
import { getRootLogger } from '@backstage/backend-common';
import yn from 'yn';
import { startStandaloneServer } from './service/standaloneServer';
export const rootRouteRef = createRouteRef({
title: 'Welcome',
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000;
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
const logger = getRootLogger();
startStandaloneServer({ port, enableCors, logger }).catch(err => {
logger.error(err);
process.exit(1);
});
export const welcomePlugin = createPlugin({
id: 'welcome',
register({ featureFlags }) {
featureFlags.register('enable-welcome-box');
},
process.on('SIGINT', () => {
logger.info('CTRL+C pressed; exiting.');
process.exit(0);
});
export const WelcomePage = welcomePlugin.provide(
createRoutableExtension({
name: 'WelcomePage',
component: () => import('./components/WelcomePage').then(m => m.default),
mountPoint: rootRouteRef,
}),
);
@@ -0,0 +1,51 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DatabaseHandler } from './DatabaseHandler';
import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils';
const bazaarProject: any = {
name: 'name',
entityRef: 'ref',
community: '',
status: 'proposed',
announcement: 'a',
membersCount: 0,
};
describe('DatabaseHandler', () => {
const databases = TestDatabases.create({
ids: ['POSTGRES_13', 'POSTGRES_9', 'SQLITE_3'],
});
async function createDatabaseHandler(databaseId: TestDatabaseId) {
const knex = await databases.init(databaseId);
return await DatabaseHandler.create({ database: knex });
}
it.each(databases.eachSupportedId())(
'should do a full sync with the locations on connect, %p',
async databaseId => {
const db = await createDatabaseHandler(databaseId);
await db.insertMetadata(bazaarProject);
const entities = await db.getEntities();
expect(entities.length).toEqual(1);
expect(entities[0].entity_ref).toEqual(bazaarProject.entityRef);
},
60_000,
);
});
@@ -0,0 +1,145 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { resolvePackagePath } from '@backstage/backend-common';
import { Knex } from 'knex';
const migrationsDir = resolvePackagePath(
'@backstage/plugin-bazaar-backend',
'migrations',
);
type Options = {
database: Knex;
};
export class DatabaseHandler {
static async create(options: Options): Promise<DatabaseHandler> {
const { database } = options;
await database.migrate.latest({
directory: migrationsDir,
});
return new DatabaseHandler(options);
}
private readonly database: Knex;
private constructor(options: Options) {
this.database = options.database;
}
async getMembers(entityRef: string) {
return await this.database
.select('*')
.from('members')
.where({ entity_ref: entityRef });
}
async addMember(userId: string, entityRef: string, picture?: string) {
await this.database
.insert({
entity_ref: entityRef,
user_id: userId,
picture: picture,
})
.into('members');
}
async deleteMember(userId: string, entityRef: string) {
return await this.database('members')
.where({ entity_ref: decodeURIComponent(entityRef) })
.andWhere('user_id', userId)
.del();
}
async getMetadata(entityRef: string) {
const coalesce = this.database.raw(
'coalesce(count(members.entity_ref), 0) as members_count',
);
const columns = [
'members.entity_ref',
'metadata.entity_ref',
'metadata.name',
'metadata.announcement',
'metadata.status',
'metadata.updated_at',
'metadata.community',
];
return await this.database('metadata')
.select([...columns, coalesce])
.where({ 'metadata.entity_ref': entityRef })
.groupBy(columns)
.leftJoin('members', 'metadata.entity_ref', '=', 'members.entity_ref');
}
async insertMetadata(bazaarProject: any) {
const { name, entityRef, community, announcement, status } = bazaarProject;
await this.database
.insert({
name: name,
entity_ref: entityRef,
community: community,
announcement: announcement,
status: status,
updated_at: new Date().toISOString(),
})
.into('metadata');
}
async updateMetadata(bazaarProject: any) {
const { entityRef, community, announcement, status } = bazaarProject;
return await this.database('metadata')
.where({ entity_ref: entityRef })
.update({
announcement: announcement,
community: community,
status: status,
updated_at: new Date().toISOString(),
});
}
async deleteMetadata(entityRef: string) {
return await this.database('metadata')
.where({ entity_ref: entityRef })
.del();
}
async getEntities() {
const coalesce = this.database.raw(
'coalesce(count(members.entity_ref), 0) as members_count',
);
const columns = [
'members.entity_ref',
'metadata.entity_ref',
'metadata.name',
'metadata.announcement',
'metadata.status',
'metadata.updated_at',
'metadata.community',
];
return await this.database('metadata')
.select([...columns, coalesce])
.groupBy(columns)
.leftJoin('members', 'metadata.entity_ref', '=', 'members.entity_ref');
}
}
@@ -0,0 +1,113 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { errorHandler, PluginDatabaseManager } from '@backstage/backend-common';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import { Config } from '@backstage/config';
import { DatabaseHandler } from './DatabaseHandler';
export interface RouterOptions {
logger: Logger;
database: PluginDatabaseManager;
config: Config;
}
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { logger, database } = options;
const db = await database.getClient();
const dbHandler = await DatabaseHandler.create({ database: db });
logger.info('Initializing Bazaar backend');
const router = Router();
router.use(express.json());
router.get('/members/:ref', async (request, response) => {
const entity_ref = decodeURIComponent(request.params.ref);
const data = await dbHandler.getMembers(entity_ref);
if (data?.length) {
response.json({ status: 'ok', data: data });
} else {
response.json({ status: 'ok', data: [] });
}
});
router.put('/member', async (request, response) => {
const { user_id, entity_ref, picture } = request.body;
await dbHandler.addMember(user_id, entity_ref, picture);
response.json({ status: 'ok' });
});
router.delete('/member/:ref/:id', async (request, response) => {
const { ref, id } = request.params;
const count = await dbHandler.deleteMember(id, ref);
if (count) {
response.json({ status: 'ok' });
} else {
response.status(404).json({ message: 'Record not found' });
}
});
router.get('/metadata/:ref', async (request, response) => {
const ref = decodeURIComponent(request.params.ref);
const data = await dbHandler.getMetadata(ref);
response.json({ status: 'ok', data: data });
});
router.get('/entities', async (_, response) => {
const data = await dbHandler.getEntities();
response.json({ status: 'ok', data: data });
});
router.put('/metadata', async (request, response) => {
const bazaarProject = request.body;
const count = await dbHandler.updateMetadata(bazaarProject);
if (count) {
response.json({ status: 'ok' });
} else {
await dbHandler.insertMetadata(bazaarProject);
response.json({ status: 'ok' });
}
});
router.delete('/metadata/:ref', async (request, response) => {
const ref = decodeURIComponent(request.params.ref);
const count = await dbHandler.deleteMetadata(ref);
if (count) {
response.json({ status: 'ok' });
} else {
response.status(404).json({ message: 'Record not found' });
}
});
router.use(errorHandler());
return router;
}
@@ -0,0 +1,72 @@
/*
* Copyright 2020 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
createServiceBuilder,
loadBackendConfig,
useHotMemoize,
} from '@backstage/backend-common';
import { Server } from 'http';
import { Logger } from 'winston';
import { createRouter } from './router';
import knexFactory from 'knex';
export interface ServerOptions {
port: number;
enableCors: boolean;
logger: Logger;
}
export async function startStandaloneServer(
options: ServerOptions,
): Promise<Server> {
const logger = options.logger.child({ service: 'bazaar-backend' });
const config = await loadBackendConfig({ logger, argv: process.argv });
const db = useHotMemoize(module, () => {
const knex = knexFactory({
client: 'sqlite3',
connection: ':memory:',
useNullAsDefault: true,
});
knex.client.pool.on('createSuccess', (_eventId: any, resource: any) => {
resource.run('PRAGMA foreign_keys = ON', () => {});
});
return knex;
});
const router = await createRouter({
logger,
database: { getClient: async () => db },
config: config,
});
let service = createServiceBuilder(module)
.setPort(options.port)
.addRouter('/bazaar', router);
if (options.enableCors) {
service = service.enableCors({ origin: 'http://localhost:3000' });
}
return await service.start().catch(err => {
logger.error(err);
process.exit(1);
});
}
module.hot?.accept();
@@ -14,4 +14,4 @@
* limitations under the License.
*/
import '@testing-library/jest-dom';
export {};
+14
View File
@@ -0,0 +1,14 @@
# @backstage/plugin-bazaar
## 0.1.2
### Patch Changes
- 5e43a73dd4: Bumped plugin-catalog-react version from 0.5.0 to 0.6.0
- Updated dependencies
- @backstage/core-components@0.7.1
- @backstage/core-plugin-api@0.1.11
- @backstage/cli@0.8.0
- @backstage/plugin-catalog@0.7.2
- @backstage/plugin-catalog-react@0.6.1
- @backstage/catalog-model@0.9.5
+127
View File
@@ -0,0 +1,127 @@
# @backstage/plugin-bazaar
### What is the Bazaar?
The Bazaar is a place where teams can propose projects for cross-functional team development. Essentially a marketplace for internal projects suitable for [Inner Sourcing](https://en.wikipedia.org/wiki/Inner_source). With "Inner Sourcing", we mean projects that are developed internally within a company, but with Open Source best practices.
### Why?
Many companies today are of high need to increase the ease of cross-team cooperation. In large organizations, engineers often have limited ways of discovering or announcing the projects which could benefit from a wider development effort in terms of different expertise, experiences, and teams spread across the organization. With no good way to find these existing internal projects to join, the possibility of working with Inner Sourcing practices suffers.
### How?
The Bazaar allows engineers and teams to open up and announce their new and exciting projects for transparent cooperation in other parts of larger organizations. The Bazaar ensures that new Inner Sourcing friendly projects gain visibility through Backstage and a way for interested engineers to show their interest and in the future contribute with their specific skill set. The Bazaar also provides an easy way to manage, catalog, and browse these Inner Sourcing friendly projects and components.
## Getting Started
First install the plugin into your app:
```sh
# From your Backstage root directory
cd packages/app
yarn add @backstage/plugin-bazaar
```
Modify your app routes in `packages/app/src/App.tsx` to include the `Bazaar` component exported from the plugin, for example:
```diff
+ import { BazaarPage } from '@backstage/plugin-bazaar';
const routes = (
<FlatRoutes>
...
+ <Route path="bazaar" element={<BazaarPage />} />
{/* other routes... */}
```
Add a **Bazaar icon** to the Sidebar to easily access the Bazaar. In `packages/app/src/components/Root.tsx` add:
```diff
+ import StorefrontIcon from '@material-ui/icons/Storefront';
<SidebarDivider />
<SidebarScrollWrapper>
+ <SidebarItem icon={StorefrontIcon} to="bazaar" text="Bazaar" />
{/* ...other sidebar-items */}
```
Add a **Bazaar card** to the overview tab on the `packages/app/src/components/catalog/EntityPage.tsx` add:
```diff
+ import { EntityBazaarInfoCard } from '@backstage/plugin-bazaar';
const overviewContent = (
<Grid item md={8} xs={12}>
<EntityAboutCard variant="gridItem" />
</Grid>
+ <Grid item sm={4}>
+ <EntityBazaarInfoCard />
+ </Grid>
{/* ...other entity-cards */}
```
## How does the Bazaar work?
### Layout
The latest modified Bazaar projects are displayed in the Bazaar landing page, located at the Bazaar icon in the sidebar. Each project is represented as a card containing its most relevant data to give an overview of the project. The list of project is paginated.
![home](media/bazaar_pr_fullscreen.png)
### Workflow
To add a project to the Bazaar, you need to create a project with one of the templates in Backstage. Click the add project-button, choose the project and fill in the form. You will be asked to add an announcement for new team members. The purpose of the announcement is for you to present your ideas and what skills you are looking for. Further you need to provide the status of the project.
When the project is added, you will see the Bazaar information in the Bazaar card on the entity page. There you can join a project, edit or delete it.
![workflow](media/bazaar_demo.gif)
### Database
The metadata related to the Bazaar is stored in a database. Right now there are two tables, one for storing the metadata and one for storing the members of a Bazaar project.
**metadata**:
- name - name of the entity
- entity_ref - namespace/kind/name of the entity
- announcement - announcement of the project and its current need of skills/team member
- status - status of the project, 'proposed' or 'ongoing'
- updated_at - date when the Bazaar information was last modified (ISO 8601 format)
**members**:
- entity_ref - namespace/kind/name of the entity
- user_name
- join_date - date when the user joined the project (ISO 8601 format)
## Future work and ideas
- Workflow
- Make it possible to add a Bazaar project without linking it to a Backstage entity, this would make it easier to just add an idea to the Bazaar.
- Bazaar landing page
- Add a tab 'My page', where your personal data is displayed. For example: your projects and its latest activities, projects or tags you are following etc.
- Make it possible to sort the project based on the number of members
- Bazaar card
- Make it possible to follow tags/projects
- Bazaar tab on the EntityPage
- Fill Bazaar-tab with more content, for example images and achievements
- Show all the members that have joined the project
- Dialogues
- Extend the dialogue for adding a project with more fields, e.g. the possibility to add images
- Testing
- Add tests to all the components
+32
View File
@@ -0,0 +1,32 @@
## API Report File for "@backstage/plugin-bazaar"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
/// <reference types="react" />
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { RouteRef } from '@backstage/core-plugin-api';
// Warning: (ae-missing-release-tag) "BazaarPage" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const BazaarPage: () => JSX.Element;
// Warning: (ae-missing-release-tag) "bazaarPlugin" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const bazaarPlugin: BackstagePlugin<
{
root: RouteRef<undefined>;
},
{}
>;
// Warning: (ae-missing-release-tag) "EntityBazaarInfoCard" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
export const EntityBazaarInfoCard: () => JSX.Element | null;
// (No @packageDocumentation comment for this package)
```
@@ -1,5 +1,5 @@
/*
* Copyright 2020 The Backstage Authors
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -16,12 +16,12 @@
import React from 'react';
import { createDevApp } from '@backstage/dev-utils';
import { welcomePlugin, WelcomePage } from '../src/plugin';
import { bazaarPlugin, BazaarPage } from '../src/plugin';
createDevApp()
.registerPlugin(welcomePlugin)
.registerPlugin(bazaarPlugin)
.addPage({
title: 'Welcome',
element: <WelcomePage />,
element: <BazaarPage />,
title: 'Root Page',
})
.render();
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 389 KiB

+50
View File
@@ -0,0 +1,50 @@
{
"name": "@backstage/plugin-bazaar",
"version": "0.1.2",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": false,
"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/catalog-model": "^0.9.5",
"@backstage/cli": "^0.8.0",
"@backstage/core-components": "^0.7.1",
"@backstage/core-plugin-api": "^0.1.11",
"@backstage/plugin-catalog": "^0.7.2",
"@backstage/plugin-catalog-react": "^0.6.1",
"@material-ui/core": "^4.12.2",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.57",
"@testing-library/jest-dom": "^5.10.1",
"luxon": "^2.0.2",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-hook-form": "^7.13.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^17.2.4"
},
"devDependencies": {
"@backstage/cli": "^0.8.0",
"@backstage/dev-utils": "^0.2.12",
"@testing-library/jest-dom": "^5.10.1",
"cross-fetch": "^3.0.6"
},
"files": [
"dist"
]
}
+141
View File
@@ -0,0 +1,141 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import {
createApiRef,
DiscoveryApi,
IdentityApi,
} from '@backstage/core-plugin-api';
import { BazaarProject } from './types';
export const bazaarApiRef = createApiRef<BazaarApi>({
id: 'bazaar',
description: 'Used to make requests towards the bazaar backend',
});
export interface BazaarApi {
updateMetadata(bazaarProject: BazaarProject): Promise<any>;
getMetadata(entity: Entity): Promise<any>;
getMembers(entity: Entity): Promise<any>;
deleteMember(entity: Entity): Promise<void>;
addMember(entity: Entity): Promise<void>;
getEntities(): Promise<any>;
deleteEntity(bazaarProject: BazaarProject): Promise<void>;
}
export class BazaarClient implements BazaarApi {
private readonly identityApi: IdentityApi;
private readonly discoveryApi: DiscoveryApi;
constructor(options: {
identityApi: IdentityApi;
discoveryApi: DiscoveryApi;
}) {
this.identityApi = options.identityApi;
this.discoveryApi = options.discoveryApi;
}
async updateMetadata(bazaarProject: BazaarProject): Promise<any> {
const baseUrl = await this.discoveryApi.getBaseUrl('bazaar');
return await fetch(`${baseUrl}/metadata`, {
method: 'PUT',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(bazaarProject),
}).then(resp => resp.json());
}
async getMetadata(entity: Entity): Promise<any> {
const baseUrl = await this.discoveryApi.getBaseUrl('bazaar');
const response = await fetch(
`${baseUrl}/metadata/${encodeURIComponent(stringifyEntityRef(entity))}`,
{
method: 'GET',
},
);
return response.ok ? response : null;
}
async getMembers(entity: Entity): Promise<any> {
const baseUrl = await this.discoveryApi.getBaseUrl('bazaar');
return await fetch(
`${baseUrl}/members/${encodeURIComponent(stringifyEntityRef(entity))}`,
{
method: 'GET',
},
).then(resp => resp.json());
}
async addMember(entity: Entity): Promise<void> {
const baseUrl = await this.discoveryApi.getBaseUrl('bazaar');
await fetch(`${baseUrl}/member`, {
method: 'PUT',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
entity_ref: stringifyEntityRef(entity),
user_id: this.identityApi.getUserId(),
picture: this.identityApi.getProfile()?.picture,
}),
});
}
async deleteMember(entity: Entity): Promise<void> {
const baseUrl = await this.discoveryApi.getBaseUrl('bazaar');
await fetch(
`${baseUrl}/member/${encodeURIComponent(
stringifyEntityRef(entity),
)}/${this.identityApi.getUserId()}`,
{
method: 'DELETE',
},
);
}
async getEntities(): Promise<any> {
const baseUrl = await this.discoveryApi.getBaseUrl('bazaar');
return await fetch(`${baseUrl}/entities`, {
method: 'GET',
}).then(resp => resp.json());
}
async deleteEntity(bazaarProject: BazaarProject): Promise<void> {
const baseUrl = await this.discoveryApi.getBaseUrl('bazaar');
const entityRef = bazaarProject.entityRef as string;
await fetch(`${baseUrl}/metadata/${encodeURIComponent(entityRef)}`, {
method: 'DELETE',
});
}
}
@@ -0,0 +1,78 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Grid, Typography, Link, makeStyles } from '@material-ui/core';
import { InfoCard } from '@backstage/core-components';
const useStyles = makeStyles({
subheader: {
fontWeight: 'bold',
},
});
export const About = () => {
const classes = useStyles();
return (
<Grid container spacing={4}>
<Grid item xs={5}>
<InfoCard title="About Bazaar">
<Typography className={classes.subheader} variant="body1">
What is the Bazaar?
</Typography>
<Typography paragraph>
The Bazaar is a place where teams can propose projects for
cross-functional team development. Essentially a marketplace for
internal projects suitable for{' '}
<Link
target="_blank"
href="https://en.wikipedia.org/wiki/Inner_source"
>
Inner Sourcing
</Link>
. With "Inner Sourcing", we mean projects that are developed
internally within a company, but with Open Source best practices.
</Typography>
<Typography className={classes.subheader} variant="body1">
Why?
</Typography>
<Typography paragraph>
Many companies today are of high need to increase the ease of
cross-team cooperation. In large organizations, engineers often have
limited ways of discovering or announcing the projects which could
benefit from a wider development effort in terms of different
expertise, experiences, and teams spread across the organization.
With no good way to find these existing internal projects to join,
the possibility of working with Inner Sourcing practices suffers.
</Typography>
<Typography className={classes.subheader} variant="body1">
How?
</Typography>
<Typography paragraph>
The Bazaar allows engineers and teams to open up and announce their
new and exciting projects for transparent cooperation in other parts
of larger organizations. The Bazaar ensures that new Inner Sourcing
friendly projects gain visibility through Backstage and a way for
interested engineers to show their interest and in the future
contribute with their specific skill set. The Bazaar also provides
an easy way to manage, catalog, and browse these Inner Sourcing
friendly projects and components.
</Typography>
</InfoCard>
</Grid>
</Grid>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { About } from './About';
@@ -0,0 +1,108 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState, useEffect } from 'react';
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { SubmitHandler } from 'react-hook-form';
import { useApi } from '@backstage/core-plugin-api';
import { ProjectDialog } from '../ProjectDialog';
import { ProjectSelector } from '../ProjectSelector';
import { BazaarProject, FormValues, Status } from '../../types';
import { bazaarApiRef } from '../../api';
type Props = {
catalogEntities: Entity[];
open: boolean;
handleClose: () => void;
fetchBazaarProjects: () => Promise<BazaarProject[]>;
fetchCatalogEntities: () => Promise<Entity[]>;
};
export const AddProjectDialog = ({
catalogEntities,
open,
handleClose,
fetchBazaarProjects,
fetchCatalogEntities,
}: Props) => {
const bazaarApi = useApi(bazaarApiRef);
const [selectedEntity, setSelectedEntity] = useState(
catalogEntities ? catalogEntities[0] : null,
);
useEffect(() => {
setSelectedEntity(catalogEntities ? catalogEntities[0] : null);
}, [catalogEntities]);
const defaultValues = {
title: 'Add project',
community: '',
announcement: '',
status: 'proposed' as Status,
};
const handleListItemClick = (entity: Entity) => {
setSelectedEntity(entity);
};
const handleCloseDialog = () => {
setSelectedEntity(catalogEntities ? catalogEntities[0] : null);
handleClose();
};
const handleSave: SubmitHandler<FormValues> = async (
getValues: any,
reset: any,
) => {
const formValues = getValues();
if (selectedEntity) {
await bazaarApi.updateMetadata({
name: selectedEntity.metadata.name,
entityRef: stringifyEntityRef(selectedEntity),
announcement: formValues.announcement,
status: formValues.status,
community: formValues.community,
membersCount: 0,
} as BazaarProject);
fetchBazaarProjects();
fetchCatalogEntities();
handleClose();
reset(defaultValues);
}
};
return (
<ProjectDialog
handleSave={handleSave}
title="Add project"
isAddForm
defaultValues={defaultValues}
open={open}
projectSelector={
<ProjectSelector
value={selectedEntity?.metadata?.name || ''}
onChange={handleListItemClick}
isFormInvalid={selectedEntity === null}
entities={catalogEntities || []}
/>
}
handleClose={handleCloseDialog}
/>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { AddProjectDialog } from './AddProjectDialog';
@@ -0,0 +1,51 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Snackbar, IconButton } from '@material-ui/core';
import CloseIcon from '@material-ui/icons/Close';
import { Alert } from '@material-ui/lab';
type Props = {
open: boolean;
message: JSX.Element;
handleClose: () => void;
};
export const AlertBanner = ({ open, message, handleClose }: Props) => {
return (
<Snackbar
open={open}
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
>
<Alert
severity="error"
action={
<IconButton
color="inherit"
size="small"
onClick={handleClose}
data-testid="error-button-close"
>
<CloseIcon />
</IconButton>
}
>
{message}
</Alert>
</Snackbar>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { AlertBanner } from './AlertBanner';
@@ -0,0 +1,139 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { Dispatch, SetStateAction } from 'react';
import {
createStyles,
Theme,
withStyles,
WithStyles,
} from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';
import Dialog from '@material-ui/core/Dialog';
import MuiDialogTitle from '@material-ui/core/DialogTitle';
import MuiDialogContent from '@material-ui/core/DialogContent';
import MuiDialogActions from '@material-ui/core/DialogActions';
import IconButton from '@material-ui/core/IconButton';
import CloseIcon from '@material-ui/icons/Close';
import Typography from '@material-ui/core/Typography';
import { useApi } from '@backstage/core-plugin-api';
import { bazaarApiRef } from '../../api';
import { BazaarProject } from '../../types';
const styles = (theme: Theme) =>
createStyles({
root: {
margin: 0,
padding: theme.spacing(2),
},
closeButton: {
position: 'absolute',
right: theme.spacing(1),
top: theme.spacing(1),
color: theme.palette.grey[500],
},
});
/*
DialogTitleProps, DialogTitle, DialogContent and DialogActions
are copied from the git-release plugin
*/
export interface DialogTitleProps extends WithStyles<typeof styles> {
id: string;
children: React.ReactNode;
onClose: () => void;
}
const DialogTitle = withStyles(styles)((props: DialogTitleProps) => {
const { children, classes, onClose, ...other } = props;
return (
<MuiDialogTitle disableTypography className={classes.root} {...other}>
<Typography variant="h6">{children}</Typography>
{onClose ? (
<IconButton
aria-label="close"
className={classes.closeButton}
onClick={onClose}
>
<CloseIcon />
</IconButton>
) : null}
</MuiDialogTitle>
);
});
const DialogContent = withStyles((theme: Theme) => ({
root: {
padding: theme.spacing(2),
},
}))(MuiDialogContent);
const DialogActions = withStyles((theme: Theme) => ({
root: {
margin: 0,
padding: theme.spacing(1),
},
}))(MuiDialogActions);
type Props = {
bazaarProject: BazaarProject;
openDelete: boolean;
handleClose: () => void;
setIsBazaar: Dispatch<SetStateAction<boolean>>;
};
export const DeleteProjectDialog = ({
bazaarProject,
openDelete,
handleClose,
setIsBazaar,
}: Props) => {
const handleCloseAndClear = () => {
handleClose();
};
const bazaarApi = useApi(bazaarApiRef);
const handleSubmit = async () => {
await bazaarApi.deleteEntity(bazaarProject);
setIsBazaar(false);
handleCloseAndClear();
};
return (
<Dialog
fullWidth
maxWidth="xs"
onClose={handleCloseAndClear}
aria-labelledby="customized-dialog-title"
open={openDelete}
>
<DialogTitle id="customized-dialog-title" onClose={handleCloseAndClear}>
Delete project
</DialogTitle>
<DialogContent dividers>
Are you sure you want to delete this project from the Bazaar?
</DialogContent>
<DialogActions>
<Button onClick={handleSubmit} color="primary" type="submit">
Delete
</Button>
</DialogActions>
</Dialog>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { DeleteProjectDialog } from './DeleteProjectDialog';
@@ -0,0 +1,82 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState, useEffect } from 'react';
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { ProjectDialog } from '../ProjectDialog';
import { BazaarProject, FormValues } from '../../types';
import { bazaarApiRef } from '../../api';
type Props = {
entity: Entity;
bazaarProject: BazaarProject;
fetchBazaarProject: () => Promise<BazaarProject | null>;
open: boolean;
handleClose: () => void;
isAddForm: boolean;
};
export const EditProjectDialog = ({
entity,
bazaarProject,
fetchBazaarProject,
open,
handleClose,
}: Props) => {
const [defaultValues, setDefaultValues] = useState<FormValues>({
announcement: bazaarProject.announcement,
community: bazaarProject.community,
status: bazaarProject.status,
});
const bazaarApi = useApi(bazaarApiRef);
useEffect(() => {
setDefaultValues({
announcement: bazaarProject.announcement,
community: bazaarProject.community,
status: bazaarProject.status,
});
}, [bazaarProject]);
const handleSave: any = async (getValues: any, _: any) => {
const formValues = getValues();
const updateResponse = await bazaarApi.updateMetadata({
name: entity.metadata.name,
entityRef: stringifyEntityRef(entity),
announcement: formValues.announcement,
status: formValues.status,
community: formValues.community,
membersCount: bazaarProject.membersCount,
});
if (updateResponse.status === 'ok') fetchBazaarProject();
handleClose();
};
return (
<ProjectDialog
title="Edit project"
handleSave={handleSave}
isAddForm={false}
defaultValues={defaultValues}
open={open}
handleClose={handleClose}
/>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { EditProjectDialog } from './EditProjectDialog';
@@ -0,0 +1,324 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState, useEffect } from 'react';
import {
Grid,
makeStyles,
Card,
CardContent,
CardHeader,
Typography,
Divider,
IconButton,
Popover,
MenuList,
MenuItem,
ListItemText,
Link,
} from '@material-ui/core';
import {
Progress,
HeaderIconLinkRow,
IconLinkVerticalProps,
Avatar,
} from '@backstage/core-components';
import { useEntity } from '@backstage/plugin-catalog-react';
import { AboutField } from '@backstage/plugin-catalog';
import { StatusTag } from '../StatusTag';
import EditIcon from '@material-ui/icons/Edit';
import ChatIcon from '@material-ui/icons/Chat';
import PersonAddIcon from '@material-ui/icons/PersonAdd';
import MoreVertIcon from '@material-ui/icons/MoreVert';
import DeleteIcon from '@material-ui/icons/Delete';
import { EditProjectDialog } from '../EditProjectDialog';
import { DeleteProjectDialog } from '../DeleteProjectDialog';
import ExitToAppIcon from '@material-ui/icons/ExitToApp';
import { useApi, identityApiRef } from '@backstage/core-plugin-api';
import { Member, BazaarProject } from '../../types';
import { bazaarApiRef } from '../../api';
import { Alert } from '@material-ui/lab';
import { useAsyncFn } from 'react-use';
const useStyles = makeStyles({
description: {
wordBreak: 'break-word',
},
icon: {
marginRight: '1.75rem',
},
link: {
color: '#9cc9ff',
'&:hover': {
textDecoration: 'underline',
},
},
memberLink: {
display: 'block',
marginBottom: '0.3rem',
},
});
const sortMembers = (m1: Member, m2: Member) => {
return new Date(m2.joinDate!).getTime() - new Date(m1.joinDate!).getTime();
};
export const EntityBazaarInfoCard = () => {
const { entity } = useEntity();
const classes = useStyles();
const bazaarApi = useApi(bazaarApiRef);
const identity = useApi(identityApiRef);
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement>();
const [open, setOpen] = useState(false);
const [popoverOpen, setPopoverOpen] = useState(false);
const [openDelete, setOpenDelete] = useState(false);
const [isMember, setIsMember] = useState(false);
const [isBazaar, setIsBazaar] = useState(false);
const [members, fetchMembers] = useAsyncFn(async () => {
const response = await bazaarApi.getMembers(entity);
const dbMembers = response.data.map((obj: any) => {
const member: Member = {
userId: obj.user_id,
entityRef: obj.entity_ref,
joinDate: obj.join_date,
picture: obj.picture,
};
return member;
});
dbMembers.sort(sortMembers);
return dbMembers;
});
const [bazaarProject, fetchBazaarProject] = useAsyncFn(async () => {
const response = await bazaarApi.getMetadata(entity);
if (response) {
const metadata = await response.json().then((resp: any) => resp.data[0]);
if (metadata) {
return {
entityRef: metadata.entity_ref,
name: metadata.name,
community: metadata.community,
announcement: metadata.announcement,
status: metadata.status,
updatedAt: metadata.updated_at,
membersCount: metadata.members_count,
} as BazaarProject;
}
}
return null;
});
useEffect(() => {
fetchMembers();
fetchBazaarProject();
}, [fetchMembers, fetchBazaarProject]);
useEffect(() => {
const isBazaarMember =
members?.value
?.map((member: Member) => member.userId)
.indexOf(identity.getUserId()) >= 0;
const isBazaarProject = bazaarProject.value !== null;
setIsMember(isBazaarMember);
setIsBazaar(isBazaarProject);
}, [bazaarProject, members, identity]);
const onOpen = (event: React.SyntheticEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
setPopoverOpen(true);
};
const closeEdit = () => {
setOpen(false);
};
const closeDelete = () => {
setOpenDelete(false);
};
const popoverCloseHandler = () => {
setPopoverOpen(false);
};
const handleMembersClick = async () => {
if (!isMember) {
await bazaarApi.addMember(entity);
} else {
await bazaarApi.deleteMember(entity);
}
fetchMembers();
fetchBazaarProject();
};
const links: IconLinkVerticalProps[] = [
{
label: isMember ? 'Leave' : 'Join',
icon: isMember ? <ExitToAppIcon /> : <PersonAddIcon />,
href: '',
onClick: async () => {
handleMembersClick();
},
},
{
label: 'Community',
icon: <ChatIcon />,
href: bazaarProject?.value?.community,
disabled: !bazaarProject?.value?.community || !isMember,
},
];
if (!isBazaar) {
return null;
} else if (bazaarProject.loading || members.loading) {
return <Progress />;
} else if (bazaarProject.error) {
return <Alert severity="error">{bazaarProject?.error?.message}</Alert>;
} else if (members.error) {
return <Alert severity="error">{members?.error?.message}</Alert>;
}
return (
<Card>
{bazaarProject?.value && (
<EditProjectDialog
open={open}
entity={entity}
bazaarProject={bazaarProject.value}
fetchBazaarProject={fetchBazaarProject}
handleClose={closeEdit}
isAddForm={false}
/>
)}
{bazaarProject?.value && (
<DeleteProjectDialog
bazaarProject={bazaarProject.value}
openDelete={openDelete}
handleClose={closeDelete}
setIsBazaar={setIsBazaar}
/>
)}
<CardHeader
title="Bazaar"
action={
<IconButton onClick={onOpen}>
<MoreVertIcon />
</IconButton>
}
subheader={<HeaderIconLinkRow links={links} />}
/>
<Divider />
<CardContent>
<Popover
open={popoverOpen}
onClose={popoverCloseHandler}
anchorEl={anchorEl}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<MenuList>
<MenuItem
onClick={() => {
setOpen(true);
setPopoverOpen(false);
}}
>
<EditIcon className={classes.icon} />
<ListItemText primary="Edit project" />
</MenuItem>
<Divider />
<MenuItem
onClick={() => {
setOpenDelete(true);
setPopoverOpen(false);
}}
>
<DeleteIcon className={classes.icon} />
<ListItemText primary="Remove from Bazaar" />
</MenuItem>
</MenuList>
</Popover>
<Grid container>
<Grid item xs={12}>
<AboutField label="Announcement">
{bazaarProject?.value?.announcement
? bazaarProject?.value?.announcement
.split('\n')
.map((str: string, i: number) => (
<Typography
key={i}
variant="body2"
paragraph
className={classes.description}
>
{str}
</Typography>
))
: 'No announcement'}
</AboutField>
</Grid>
<Grid item xs={6}>
<AboutField label="Status">
<StatusTag status={bazaarProject?.value?.status || 'proposed'} />
</AboutField>
</Grid>
<Grid item xs={6}>
{' '}
<AboutField label="Latest members">
{members?.value?.length ? (
members.value.slice(0, 3).map((member: Member) => {
return (
<div key={member.userId}>
<Avatar
displayName={member.userId}
customStyles={{
width: '19px',
height: '19px',
fontSize: '8px',
float: 'left',
marginRight: '0.3rem',
}}
picture={member.picture}
/>
<Link
className={classes.memberLink}
href={`http://github.com/${member.userId}`}
target="_blank"
>
{member?.userId}
</Link>
</div>
);
})
) : (
<div />
)}
</AboutField>
</Grid>
</Grid>
</CardContent>
</Card>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { EntityBazaarInfoCard } from './EntityBazaarInfoCard';
@@ -0,0 +1,47 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Header, RoutedTabs } from '@backstage/core-components';
import { Route } from 'react-router-dom';
import { SortView } from '../SortView';
import { About } from '../About';
export const HomePage = () => {
const tabContent = [
{
path: '/',
title: 'Home',
children: <Route path="/" element={<SortView />} />,
},
{
path: '/about',
title: 'About',
children: <Route path="/about" element={<About />} />,
},
];
return (
<div>
<Header
data-testid="bazaar-header"
title="Bazaar"
subtitle="Marketplace for inner source projects"
/>
<RoutedTabs routes={tabContent} />
</div>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { HomePage } from './HomePage';
@@ -0,0 +1,63 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Controller, Control, FieldError } from 'react-hook-form';
import { TextField } from '@material-ui/core';
import { FormValues } from '../../types';
type Props = {
inputType: 'announcement' | 'community';
error?: FieldError | undefined;
control: Control<FormValues, object>;
helperText?: string;
placeholder?: string;
rules?: Object;
};
export const InputField = ({
inputType,
error,
control,
helperText,
placeholder,
rules,
}: Props) => {
const label =
inputType.charAt(0).toLocaleUpperCase('en-US') + inputType.slice(1);
return (
<Controller
name={inputType}
control={control}
rules={rules}
render={({ field }) => (
<TextField
{...field}
margin="dense"
multiline
id="title"
type="text"
fullWidth
label={label}
placeholder={placeholder}
error={!!error}
helperText={error && helperText}
/>
)}
/>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { InputField } from './InputField';
@@ -0,0 +1,75 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import InputLabel from '@material-ui/core/InputLabel';
import MenuItem from '@material-ui/core/MenuItem';
import FormControl from '@material-ui/core/FormControl';
import Select from '@material-ui/core/Select';
import { Controller, Control, FieldError } from 'react-hook-form';
import { FormValues } from '../../types';
type Props = {
options: string[];
control: Control<FormValues, object>;
name: 'announcement' | 'status';
error?: FieldError | undefined;
};
export const InputSelector = ({ name, options, control, error }: Props) => {
const label = name.charAt(0).toLocaleUpperCase('en-US') + name.slice(1);
return (
<Controller
name={name}
control={control}
rules={{
required: true,
}}
render={({ field }) => (
<FormControl fullWidth>
<InputLabel
htmlFor="demo-simple-select-outlined"
id="demo-simple-select-outlined-label"
>
{label}
</InputLabel>
<Select
{...field}
required
labelId="demo-simple-select-outlined-label"
id="demo-simple-select-outlined"
label={label}
error={!!error}
>
{options.map(option => {
return (
<MenuItem
data-testid="menu-item"
button
key={option}
value={option}
>
{option}
</MenuItem>
);
})}
</Select>
</FormControl>
)}
/>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { InputSelector } from './InputSelector';
@@ -0,0 +1,98 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { ItemCardHeader } from '@backstage/core-components';
import {
Card,
CardActionArea,
CardContent,
makeStyles,
Typography,
} from '@material-ui/core';
import { StatusTag } from '../StatusTag/StatusTag';
import { Link as RouterLink } from 'react-router-dom';
import { catalogRouteRef } from '@backstage/plugin-catalog-react';
import { useRouteRef } from '@backstage/core-plugin-api';
import { BazaarProject } from '../../types';
import { parseEntityName } from '@backstage/catalog-model';
import { DateTime } from 'luxon';
const useStyles = makeStyles({
statusTag: {
display: 'inline-block',
whiteSpace: 'nowrap',
marginBottom: '0.5rem',
},
announcement: {
display: '-webkit-box',
WebkitLineClamp: 5,
WebkitBoxOrient: 'vertical',
marginBottom: '0.8rem',
overflow: 'hidden',
},
memberCount: {
float: 'right',
},
});
type Props = {
bazaarProject: BazaarProject;
};
export const ProjectCard = ({ bazaarProject }: Props) => {
const classes = useStyles();
const { entityRef, name, status, updatedAt, announcement, membersCount } =
bazaarProject;
const catalogLink = useRouteRef(catalogRouteRef);
const { namespace, kind } = parseEntityName(entityRef);
return (
<Card key={entityRef as string}>
<CardActionArea
style={{
height: '100%',
overflow: 'hidden',
width: '100%',
}}
component={RouterLink}
to={`${catalogLink()}/${namespace}/${kind}/${name}`}
>
<ItemCardHeader
title={name}
subtitle={`updated ${DateTime.fromISO(
new Date(updatedAt!).toISOString(),
).toRelative({
base: DateTime.now(),
})}`}
/>
<CardContent style={{ height: '12rem' }}>
<StatusTag styles={classes.statusTag} status={status} />
<Typography variant="body2" className={classes.memberCount}>
{membersCount === 1
? `${membersCount} member`
: `${membersCount} members`}
</Typography>
<div style={{ minHeight: '6.5rem', maxHeight: '6.5rem' }}>
<Typography variant="body2" className={classes.announcement}>
{announcement}
</Typography>
</div>
</CardContent>
</CardActionArea>
</Card>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ProjectCard } from './ProjectCard';
@@ -0,0 +1,186 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import {
createStyles,
Theme,
withStyles,
WithStyles,
} from '@material-ui/core/styles';
import MuiDialogTitle from '@material-ui/core/DialogTitle';
import MuiDialogContent from '@material-ui/core/DialogContent';
import MuiDialogActions from '@material-ui/core/DialogActions';
import CloseIcon from '@material-ui/icons/Close';
import { Button, Dialog, Typography, IconButton } from '@material-ui/core';
import { useForm, SubmitHandler } from 'react-hook-form';
import { InputField } from '../InputField/InputField';
import { InputSelector } from '../InputSelector/InputSelector';
import { FormValues } from '../../types';
const styles = (theme: Theme) =>
createStyles({
root: {
margin: 0,
padding: theme.spacing(2),
},
closeButton: {
position: 'absolute',
right: theme.spacing(1),
top: theme.spacing(1),
color: theme.palette.grey[500],
},
});
/*
DialogTitleProps, DialogTitle, DialogContent and DialogActions
are copied from the git-release plugin
*/
export interface DialogTitleProps extends WithStyles<typeof styles> {
id: string;
children: React.ReactNode;
onClose: () => void;
}
const DialogTitle = withStyles(styles)((props: DialogTitleProps) => {
const { children, classes, onClose, ...other } = props;
return (
<MuiDialogTitle disableTypography className={classes.root} {...other}>
<Typography variant="h6">{children}</Typography>
{onClose ? (
<IconButton
aria-label="close"
className={classes.closeButton}
onClick={onClose}
>
<CloseIcon />
</IconButton>
) : null}
</MuiDialogTitle>
);
});
const DialogContent = withStyles((theme: Theme) => ({
root: {
padding: theme.spacing(2),
},
}))(MuiDialogContent);
const DialogActions = withStyles((theme: Theme) => ({
root: {
margin: 0,
padding: theme.spacing(1),
},
}))(MuiDialogActions);
type Props = {
handleSave: (getValues: any, reset: any) => SubmitHandler<FormValues>;
isAddForm: boolean;
title: string;
defaultValues: FormValues;
open: boolean;
projectSelector?: JSX.Element;
handleClose: () => void;
};
export const ProjectDialog = ({
handleSave,
isAddForm,
title,
defaultValues,
open,
projectSelector,
handleClose,
}: Props) => {
const {
handleSubmit,
reset,
control,
getValues,
formState: { errors },
} = useForm<FormValues>({
mode: 'onChange',
defaultValues: defaultValues,
});
const handleCloseAndClear = () => {
handleClose();
reset(defaultValues);
};
const handleSaveProject = () => {
handleSave(getValues, reset);
};
return (
<div>
<Dialog
fullWidth
maxWidth="xs"
onClose={handleCloseAndClear}
aria-labelledby="customized-dialog-title"
open={open}
>
<DialogTitle id="customized-dialog-title" onClose={handleCloseAndClear}>
{title}
</DialogTitle>
<DialogContent dividers>
{isAddForm && projectSelector}
<InputField
error={errors.announcement}
control={control}
rules={{
required: true,
}}
inputType="announcement"
helperText="please enter an announcement"
placeholder="Describe who you are and what skills you are looking for"
/>
<InputField
error={errors.community}
control={control}
rules={{
required: false,
pattern: RegExp('^(https?)://[^s$.?#].[^s]*$'),
}}
inputType="community"
helperText="please enter a link starting with http/https"
placeholder="Community link to e.g. Teams or Discord"
/>
<InputSelector
control={control}
name="status"
options={['proposed', 'ongoing']}
/>
</DialogContent>
<DialogActions>
<Button
onClick={handleSubmit(handleSaveProject)}
color="primary"
type="submit"
>
Submit
</Button>
</DialogActions>
</Dialog>
</div>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ProjectDialog } from './ProjectDialog';
@@ -0,0 +1,109 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useState } from 'react';
import { Content } from '@backstage/core-components';
import { ProjectCard } from '../ProjectCard/ProjectCard';
import { makeStyles, Grid } from '@material-ui/core';
import Pagination from '@material-ui/lab/Pagination';
import { BazaarProject } from '../../types';
type Props = {
bazaarProjects: BazaarProject[];
sortingMethod: (arg0: BazaarProject, arg1: BazaarProject) => number;
};
const useStyles = makeStyles({
content: {
width: '100%',
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
},
item: {
minWidth: '20.5rem',
maxWidth: '20.5rem',
width: '20%',
},
});
export const ProjectPreview = ({ bazaarProjects, sortingMethod }: Props) => {
const classes = useStyles();
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 10;
const pageCount = Math.ceil(bazaarProjects.length / pageSize);
const handlePageClick = (_: any, pageIndex: number) => {
setCurrentPage(pageIndex);
};
if (!bazaarProjects.length) {
return (
<div
data-testid="empty-bazaar"
style={{
height: '10rem',
textAlign: 'center',
verticalAlign: 'middle',
lineHeight: '10rem',
}}
>
Please add projects to the Bazaar.
</div>
);
}
bazaarProjects.sort(sortingMethod);
return (
<Content className={classes.content} noPadding>
<Grid wrap="wrap" container spacing={3}>
{bazaarProjects
.slice(pageSize * (currentPage - 1), pageSize * currentPage)
.map((bazaarProject: BazaarProject) => {
const entityRef = bazaarProject.entityRef;
return (
<Grid
key={(entityRef as string) || ''}
className={classes.item}
item
xs={3}
>
<ProjectCard
bazaarProject={bazaarProject}
key={Math.random()}
/>
</Grid>
);
})}
</Grid>
<Pagination
showFirstButton
showLastButton
siblingCount={2}
style={{
marginTop: '1rem',
marginLeft: 'auto',
marginRight: '0',
}}
count={pageCount}
page={currentPage}
onChange={handlePageClick}
/>
</Content>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ProjectPreview } from './ProjectPreview';
@@ -0,0 +1,54 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Entity } from '@backstage/catalog-model';
import { Autocomplete } from '@material-ui/lab';
import { TextField } from '@material-ui/core';
type Props = {
entities: Entity[];
value: string;
onChange: (entity: Entity) => void;
isFormInvalid: boolean;
};
export const ProjectSelector = ({
entities,
value,
onChange,
isFormInvalid,
}: Props) => {
return (
<Autocomplete
defaultValue={entities[0]}
options={entities}
getOptionLabel={option => option?.metadata?.name}
renderOption={option => <span>{option?.metadata?.name}</span>}
renderInput={params => (
<TextField
error={isFormInvalid && value === ''}
helperText={
isFormInvalid && value === '' ? 'Please select a project' : ''
}
{...params}
label="Select a project"
/>
)}
onChange={(_, data) => onChange(data!)}
/>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { ProjectSelector } from './ProjectSelector';
@@ -0,0 +1,183 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useEffect, useState } from 'react';
import {
Content,
ContentHeader,
SupportButton,
Progress,
} from '@backstage/core-components';
import { AddProjectDialog } from '../AddProjectDialog';
import { AlertBanner } from '../AlertBanner';
import { ProjectPreview } from '../ProjectPreview/ProjectPreview';
import { Button, makeStyles, Link } from '@material-ui/core';
import { useAsyncFn } from 'react-use';
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { BazaarProject } from '../../types';
import { bazaarApiRef } from '../../api';
import { Alert } from '@material-ui/lab';
const useStyles = makeStyles({
container: {
marginTop: '2rem',
},
});
const filterCatalogEntities = (
bazaarProjects: BazaarProject[],
catalogEntities: Entity[],
) => {
const bazaarProjectRefs = bazaarProjects.map(
(project: BazaarProject) => project.entityRef,
);
const filtered = catalogEntities.filter((entity: Entity) => {
return !bazaarProjectRefs?.includes(stringifyEntityRef(entity));
});
return filtered;
};
export const SortView = () => {
const classes = useStyles();
const [openAdd, setOpenAdd] = useState(false);
const [openNoProjects, setOpenNoProjects] = useState(false);
const bazaarApi = useApi(bazaarApiRef);
const catalogApi = useApi(catalogApiRef);
const [filteredCatalogEntites, setFilteredCatalogEntities] =
useState<Entity[]>();
const compareProjectsByDate = (
a: BazaarProject,
b: BazaarProject,
): number => {
const dateA = new Date(a.updatedAt!).getTime();
const dateB = new Date(b.updatedAt!).getTime();
return dateB - dateA;
};
const handleCloseNoProjects = () => {
setOpenNoProjects(false);
};
const [catalogEntities, fetchCatalogEntities] = useAsyncFn(async () => {
const entities = await catalogApi.getEntities({
filter: {
kind: ['Component', 'Resource'],
},
fields: ['kind', 'metadata.name', 'metadata.namespace'],
});
return entities.items;
});
const [bazaarProjects, fetchBazaarProjects] = useAsyncFn(async () => {
const response = await bazaarApi.getEntities();
const dbProjects: BazaarProject[] = [];
response.data.forEach((project: any) => {
dbProjects.push({
entityRef: project.entity_ref,
name: project.name,
status: project.status,
announcement: project.announcement,
community: project.community,
updatedAt: project.updated_at,
membersCount: project.members_count,
});
});
return dbProjects;
});
useEffect(() => {
fetchCatalogEntities();
fetchBazaarProjects();
}, [fetchBazaarProjects, fetchCatalogEntities]);
useEffect(() => {
const filteredCatalogEntities = filterCatalogEntities(
bazaarProjects.value || [],
catalogEntities.value || [],
);
if (filteredCatalogEntities) {
setFilteredCatalogEntities(filteredCatalogEntities);
}
}, [bazaarProjects, catalogEntities]);
if (catalogEntities.loading || bazaarProjects.loading) return <Progress />;
if (catalogEntities.error)
return <Alert severity="error">{catalogEntities.error.message}</Alert>;
if (bazaarProjects.error)
return <Alert severity="error">{bazaarProjects.error.message}</Alert>;
return (
<Content noPadding>
<AlertBanner
open={openNoProjects}
message={
<div>
No project available. Please{' '}
<Link
style={{ color: 'inherit', fontWeight: 'bold' }}
href="/create"
>
create a project
</Link>{' '}
from a template first.
</div>
}
handleClose={handleCloseNoProjects}
/>
<ContentHeader title="Latest updated">
<Button
variant="contained"
color="primary"
onClick={() => {
if (filteredCatalogEntites?.length !== 0) {
setOpenAdd(true);
} else {
setOpenNoProjects(true);
}
}}
>
Add project
</Button>
<AddProjectDialog
catalogEntities={filteredCatalogEntites || []}
handleClose={() => {
setOpenAdd(false);
}}
open={openAdd}
fetchBazaarProjects={fetchBazaarProjects}
fetchCatalogEntities={fetchCatalogEntities}
/>
<SupportButton />
</ContentHeader>
<ProjectPreview
bazaarProjects={bazaarProjects.value || []}
sortingMethod={compareProjectsByDate}
/>
<Content noPadding className={classes.container} />
</Content>
);
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { SortView } from './SortView';
@@ -0,0 +1,37 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { StatusOK, StatusWarning } from '@backstage/core-components';
import { Status } from '../../types';
interface StatusComponent {
[key: string]: JSX.Element | undefined;
}
const statuses: StatusComponent = {
proposed: <StatusWarning>proposed</StatusWarning>,
ongoing: <StatusOK>ongoing</StatusOK>,
};
type Props = {
status: Status;
styles?: string;
};
export const StatusTag = ({ status, styles }: Props) => {
return <div className={styles}>{statuses[status]}</div>;
};
@@ -0,0 +1,17 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { StatusTag } from './StatusTag';
+18
View File
@@ -0,0 +1,18 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { bazaarPlugin, BazaarPage } from './plugin';
export { EntityBazaarInfoCard } from './components/EntityBazaarInfoCard';
@@ -1,5 +1,5 @@
/*
* Copyright 2020 The Backstage Authors
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,10 +14,10 @@
* limitations under the License.
*/
import { welcomePlugin } from './plugin';
import { bazaarPlugin } from './plugin';
describe('welcome', () => {
describe('bazaar', () => {
it('should export plugin', () => {
expect(welcomePlugin).toBeDefined();
expect(bazaarPlugin).toBeDefined();
});
});
+50
View File
@@ -0,0 +1,50 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { rootRouteRef } from './routes';
import {
createApiFactory,
createPlugin,
createRoutableExtension,
identityApiRef,
discoveryApiRef,
} from '@backstage/core-plugin-api';
import { bazaarApiRef, BazaarClient } from './api';
export const bazaarPlugin = createPlugin({
id: 'bazaar',
routes: {
root: rootRouteRef,
},
apis: [
createApiFactory({
api: bazaarApiRef,
deps: {
identityApi: identityApiRef,
discoveryApi: discoveryApiRef,
},
factory: ({ identityApi, discoveryApi }) =>
new BazaarClient({ identityApi, discoveryApi }),
}),
],
});
export const BazaarPage = bazaarPlugin.provide(
createRoutableExtension({
component: () => import('./components/HomePage').then(m => m.HomePage),
mountPoint: rootRouteRef,
}),
);
+21
View File
@@ -0,0 +1,21 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createRouteRef } from '@backstage/core-plugin-api';
export const rootRouteRef = createRouteRef({
title: 'bazaar',
});

Some files were not shown because too many files have changed in this diff Show More