From 3935f391ecc37676b04b71996c757dd28867a32c Mon Sep 17 00:00:00 2001 From: hram_wh Date: Thu, 15 Sep 2022 12:39:35 +0530 Subject: [PATCH 001/127] add support for copy enity url in enity page contex menu Signed-off-by: hram_wh --- .../EntityContextMenu/EntityContextMenu.tsx | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx index 41610ed13c..25d9a5242a 100644 --- a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx @@ -26,12 +26,14 @@ import { import { makeStyles } from '@material-ui/core/styles'; import BugReportIcon from '@material-ui/icons/BugReport'; import MoreVert from '@material-ui/icons/MoreVert'; +import FileCopyTwoToneIcon from '@material-ui/icons/FileCopyTwoTone'; import React, { useState } from 'react'; import { IconComponent } from '@backstage/core-plugin-api'; import { useEntityPermission } from '@backstage/plugin-catalog-react'; import { catalogEntityDeletePermission } from '@backstage/plugin-catalog-common'; import { BackstageTheme } from '@backstage/theme'; import { UnregisterEntity, UnregisterEntityOptions } from './UnregisterEntity'; +import Snackbar from '@material-ui/core/Snackbar'; /** @public */ export type EntityContextMenuClassKey = 'button'; @@ -84,6 +86,22 @@ export function EntityContextMenu(props: EntityContextMenuProps) { setAnchorEl(undefined); }; + const [state, setState] = useState({ + open: false, + vertical: 'top', + horizontal:'right' + }); + + const { open, vertical, horizontal } = state; + + const handleClose = () => { + setState({ ...state, open: false }); + }; + + const copyToClipboard = () => { + navigator.clipboard.writeText(window.location.toString()).then(()=> setState({...state, open:true})); + }; + const extraItems = UNSTABLE_extraContextMenuItems && [ ...UNSTABLE_extraContextMenuItems.map(item => ( + { + copyToClipboard(); + }} + > + + + + + + From ac2c30b7173a2f9279ac768b22ac34531a68992a Mon Sep 17 00:00:00 2001 From: hram_wh Date: Thu, 15 Sep 2022 16:42:59 +0530 Subject: [PATCH 002/127] fix style issue Signed-off-by: hram_wh --- .../src/components/EntityContextMenu/EntityContextMenu.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx index 25d9a5242a..92ed8c02a6 100644 --- a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx @@ -89,7 +89,7 @@ export function EntityContextMenu(props: EntityContextMenuProps) { const [state, setState] = useState({ open: false, vertical: 'top', - horizontal:'right' + horizontal: 'right', }); const { open, vertical, horizontal } = state; @@ -99,7 +99,9 @@ export function EntityContextMenu(props: EntityContextMenuProps) { }; const copyToClipboard = () => { - navigator.clipboard.writeText(window.location.toString()).then(()=> setState({...state, open:true})); + navigator.clipboard + .writeText(window.location.toString()) + .then(() => setState({ ...state, open: true })); }; const extraItems = UNSTABLE_extraContextMenuItems && [ From f87e38ecfa662d68abb9f0748e2d5cda001de530 Mon Sep 17 00:00:00 2001 From: hram_wh Date: Wed, 21 Sep 2022 13:28:16 +0530 Subject: [PATCH 003/127] changeset added Signed-off-by: hram_wh --- .changeset/young-spies-wait.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/young-spies-wait.md diff --git a/.changeset/young-spies-wait.md b/.changeset/young-spies-wait.md new file mode 100644 index 0000000000..1817085d08 --- /dev/null +++ b/.changeset/young-spies-wait.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': major +--- + +Added support for copy entity url in entity page context menu From e89e1f614d15428b95c4e8731a31834739e975b1 Mon Sep 17 00:00:00 2001 From: hram_wh Date: Fri, 23 Sep 2022 13:16:20 +0530 Subject: [PATCH 004/127] changeset added Signed-off-by: hram_wh --- .changeset/warm-days-watch.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/warm-days-watch.md diff --git a/.changeset/warm-days-watch.md b/.changeset/warm-days-watch.md new file mode 100644 index 0000000000..b0a211c7a8 --- /dev/null +++ b/.changeset/warm-days-watch.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Added support for copy entity URL in entity page context menu From bf983d06019666120a233d9fe1bfd802824eb163 Mon Sep 17 00:00:00 2001 From: hram_wh Date: Fri, 23 Sep 2022 13:22:27 +0530 Subject: [PATCH 005/127] deleted waste changset file Signed-off-by: hram_wh --- .changeset/young-spies-wait.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/young-spies-wait.md diff --git a/.changeset/young-spies-wait.md b/.changeset/young-spies-wait.md deleted file mode 100644 index 1817085d08..0000000000 --- a/.changeset/young-spies-wait.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@backstage/plugin-catalog': major ---- - -Added support for copy entity url in entity page context menu From 1d3d575132a55e08403cd7eb1d5d75360bfbcb51 Mon Sep 17 00:00:00 2001 From: Jan Vilimek Date: Mon, 26 Sep 2022 12:29:16 +0200 Subject: [PATCH 006/127] score-card plugin integrated to example-app Signed-off-by: Jan Vilimek --- .changeset/popular-months-grab.md | 5 +++ app-config.yaml | 5 +++ packages/app/package.json | 1 + packages/app/src/App.tsx | 2 + packages/app/src/components/Root/Root.tsx | 2 + .../app/src/components/catalog/EntityPage.tsx | 8 ++++ yarn.lock | 41 +++++++++++++++---- 7 files changed, 56 insertions(+), 8 deletions(-) create mode 100644 .changeset/popular-months-grab.md diff --git a/.changeset/popular-months-grab.md b/.changeset/popular-months-grab.md new file mode 100644 index 0000000000..6e68d124ad --- /dev/null +++ b/.changeset/popular-months-grab.md @@ -0,0 +1,5 @@ +--- +'example-app': patch +--- + +score-card plugin 0.5.1 integrated diff --git a/app-config.yaml b/app-config.yaml index 22ca1fcaff..57349d213a 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -138,6 +138,11 @@ techdocs: dynatrace: baseUrl: https://your.dynatrace.instance.com +# Score-cards sample configuration. +scorecards: + jsonDataUrl: https://raw.githubusercontent.com/Oriflame/backstage-plugins/main/plugins/score-card/sample-data/ + wikiLinkTemplate: https://link-to-wiki/{id} + sentry: organization: my-company diff --git a/packages/app/package.json b/packages/app/package.json index ad24f23c45..406e908cad 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -67,6 +67,7 @@ "@material-ui/icons": "^4.9.1", "@material-ui/lab": "4.0.0-alpha.57", "@octokit/rest": "^19.0.3", + "@oriflame/backstage-plugin-score-card": "^0.5.1", "@roadiehq/backstage-plugin-buildkite": "^2.0.8", "@roadiehq/backstage-plugin-github-insights": "^2.0.5", "@roadiehq/backstage-plugin-github-pull-requests": "^2.2.7", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index a6c613ded7..aa1db7cd0e 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -106,6 +106,7 @@ import { RequirePermission } from '@backstage/plugin-permission-react'; import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common'; import { PlaylistIndexPage } from '@backstage/plugin-playlist'; import { TwoColumnLayout } from './components/scaffolder/customScaffolderLayouts'; +import { ScoreBoardPage } from '@oriflame/backstage-plugin-score-card'; const app = createApp({ apis, @@ -272,6 +273,7 @@ const routes = ( } /> } /> } /> + } /> ); diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 8b5f0932fb..0c142bafbd 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -51,6 +51,7 @@ import { import { MyGroupsSidebarItem } from '@backstage/plugin-org'; import GroupIcon from '@material-ui/icons/People'; import { SearchModal } from '../search/SearchModal'; +import Score from '@material-ui/icons/Score'; const useSidebarLogoStyles = makeStyles({ root: { @@ -120,6 +121,7 @@ export const Root = ({ children }: PropsWithChildren<{}>) => ( text="Cost Insights" /> + diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index c2f297a2d7..f7592eb0b9 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -144,6 +144,7 @@ import { EntityNewRelicDashboardCard, } from '@backstage/plugin-newrelic-dashboard'; import { EntityGoCdContent, isGoCdAvailable } from '@backstage/plugin-gocd'; +import { EntityScoreCardContent } from '@oriflame/backstage-plugin-score-card'; import React, { ReactNode, useMemo, useState } from 'react'; @@ -704,6 +705,13 @@ const systemPage = ( + + + + + + + Date: Mon, 26 Sep 2022 12:35:28 +0200 Subject: [PATCH 007/127] score-card plugin e2e tests Signed-off-by: Jan Vilimek --- .../integration/plugins/score-card.spec.ts | 83 +++++++++++++++++++ cypress/src/support/commands.ts | 5 ++ cypress/src/types.d.ts | 5 ++ 3 files changed, 93 insertions(+) create mode 100644 cypress/src/integration/plugins/score-card.spec.ts diff --git a/cypress/src/integration/plugins/score-card.spec.ts b/cypress/src/integration/plugins/score-card.spec.ts new file mode 100644 index 0000000000..5871a094e1 --- /dev/null +++ b/cypress/src/integration/plugins/score-card.spec.ts @@ -0,0 +1,83 @@ +/* + * 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 'os'; + +describe('score-card', () => { + describe('Score board', () => { + it('displays the score board based on sample data', () => { + cy.loginAsGuest(); + + cy.visit('/score-board'); + cy.screenshot({ capture: 'viewport' }); + + cy.contains('System scores overview').should('be.visible'); + cy.checkForErrors(); + cy.get('span:contains("1-2 of 2")').should('be.visible'); // beware, there is also a hidden

element + cy.contains('audio-playback').should('be.visible'); + cy.contains('team-c').should('be.visible'); + cy.contains('non-valid-system').should('be.visible'); + cy.contains('Name').should('be.visible'); + cy.contains('Date').should('be.visible'); + cy.contains('Code').should('be.visible'); + cy.contains('Documentation').should('be.visible'); + cy.contains('Operations').should('be.visible'); + cy.contains('Quality').should('be.visible'); + cy.contains('Security').should('be.visible'); + cy.contains('Total').should('be.visible'); + cy.contains('50 %').should('be.visible'); + cy.contains('75 %').should('be.visible'); + cy.log('navigating to score card detail for audio-playback'); + cy.get('a[data-id="audio-playback"]').should('be.visible').click(); + cy.screenshot({ capture: 'viewport' }); + + cy.url().should( + 'include', + '/catalog/default/System/audio-playback/score', + ); + cy.contains('Scoring').should('be.visible'); + cy.contains('Total score: 57 %').should('be.visible'); + cy.contains('Code').should('be.visible'); + cy.contains('90 %').should('be.visible'); + cy.contains('Documentation').should('be.visible'); + cy.contains('75 %').should('be.visible'); + cy.contains('Operations').should('be.visible'); + cy.contains('50 %').should('be.visible'); + cy.contains('Quality').should('be.visible'); + cy.contains('25 %').should('be.visible'); + cy.contains('Security'); + cy.contains('10 %').should('be.visible'); + cy.checkForErrors(); + + cy.log( + 'Clicking on button [>] that is first child of the element (td) with value=Code', + ); + cy.get('[value="Code"] > button:first-child').click(); + cy.checkForErrors(); + cy.screenshot({ capture: 'viewport' }); + + cy.log('Clicking on link for Code'); + cy.contains('hints: Gitflow: 100%').should('be.visible'); + cy.get('a[data-id="2157"]') + .should('be.visible') + .should( + 'have.attr', + 'href', + 'https://TBD/XXX/_wiki/wikis/XXX.wiki/2157', + ); + }); + }); +}); diff --git a/cypress/src/support/commands.ts b/cypress/src/support/commands.ts index 02723ffc90..62e21a5bef 100644 --- a/cypress/src/support/commands.ts +++ b/cypress/src/support/commands.ts @@ -118,3 +118,8 @@ Cypress.Commands.add('waitSectionTwoPage', () => { Cypress.Commands.add('waitHomePage', () => { cy.wait(['@entityMetadata', '@syncEntity', '@techdocsMetadata', '@homeHTML']); }); + +Cypress.Commands.add('checkForErrors', () => { + // when an error occurs there is a

with an "alert" role attribute. This can change ofc => we shall add also some positive ("when error occurs") test + cy.get('div[role="alert"]').should('not.exist'); +}); diff --git a/cypress/src/types.d.ts b/cypress/src/types.d.ts index 181b608a9b..dca22112e9 100644 --- a/cypress/src/types.d.ts +++ b/cypress/src/types.d.ts @@ -77,5 +77,10 @@ declare namespace Cypress { * @example cy.isNotInViewport */ isNotInViewport(element: string): Chainable; + /** + * Check if we have not caused error by our last action + * @example cy.checkForErrors + */ + checkForErrors(): Chainable; } } From a2c7a91f5640c08e83731a7c130a7679ade20814 Mon Sep 17 00:00:00 2001 From: Jan Vilimek Date: Mon, 26 Sep 2022 12:47:52 +0200 Subject: [PATCH 008/127] changeset for example-app not needed Signed-off-by: Jan Vilimek --- .changeset/popular-months-grab.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/popular-months-grab.md diff --git a/.changeset/popular-months-grab.md b/.changeset/popular-months-grab.md deleted file mode 100644 index 6e68d124ad..0000000000 --- a/.changeset/popular-months-grab.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'example-app': patch ---- - -score-card plugin 0.5.1 integrated From c008f74f8ffff7f898fead101b7d24cf70414b8a Mon Sep 17 00:00:00 2001 From: Crevil Date: Thu, 4 Aug 2022 17:32:41 +0200 Subject: [PATCH 009/127] Implement EntityKindPicker This change implements the EntityKindPicker allowing for selecting multiple kinds. Signed-off-by: Crevil --- .../EntityKindPicker/EntityKindPicker.tsx | 122 ++++++++++++++---- plugins/catalog-react/src/filters.ts | 15 ++- .../CatalogPage/DefaultCatalogPage.tsx | 2 + .../components/CatalogTable/CatalogTable.tsx | 9 +- 4 files changed, 117 insertions(+), 31 deletions(-) diff --git a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx index 4cfd141509..774d07c544 100644 --- a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx +++ b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx @@ -14,41 +14,115 @@ * limitations under the License. */ -import React, { useEffect, useState } from 'react'; -import { Alert } from '@material-ui/lab'; -import { useEntityList } from '../../hooks'; +import { useApi } from '@backstage/core-plugin-api'; +import { + Box, + Checkbox, + FormControlLabel, + makeStyles, + TextField, + Typography, +} from '@material-ui/core'; +import CheckBoxIcon from '@material-ui/icons/CheckBox'; +import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'; +import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { Autocomplete } from '@material-ui/lab'; +import React, { useEffect, useMemo, useState } from 'react'; +import useAsync from 'react-use/lib/useAsync'; +import { catalogApiRef } from '../../api'; import { EntityKindFilter } from '../../filters'; +import { useEntityList } from '../../hooks'; -/** - * Props for {@link EntityKindPicker}. - * - * @public - */ -export interface EntityKindPickerProps { - initialFilter?: string; - hidden: boolean; -} +const useStyles = makeStyles( + { + input: {}, + }, + { + name: 'CatalogReactEntityKindPicker', + }, +); + +const icon = ; +const checkedIcon = ; /** @public */ -export const EntityKindPicker = (props: EntityKindPickerProps) => { - const { initialFilter, hidden } = props; - +export const EntityKindPicker = () => { + const classes = useStyles(); const { updateFilters, - queryParameters: { kind: kindParameter }, + filters, + queryParameters: { kind: kindsParameter }, } = useEntityList(); - const [selectedKind] = useState([kindParameter].flat()[0] ?? initialFilter); + + const catalogApi = useApi(catalogApiRef); + const { value: availableKinds } = useAsync(async () => { + const facet = 'kind'; + const { facets } = await catalogApi.getEntityFacets({ + facets: [facet], + }); + + return facets[facet].map(({ value }) => value); + }, [filters.kind]); + + const queryParamKinds = useMemo( + () => [kindsParameter].flat().filter(Boolean) as string[], + [kindsParameter], + ); + + const [selectedKinds, setSelectedKinds] = useState( + queryParamKinds.length ? queryParamKinds : filters.kind?.getKinds() ?? [], + ); + + // Set selected kinds on query parameter updates; this happens at initial page load and from + // external updates to the page location. + useEffect(() => { + if (queryParamKinds.length) { + setSelectedKinds(queryParamKinds); + } + }, [queryParamKinds]); useEffect(() => { updateFilters({ - kind: selectedKind ? new EntityKindFilter(selectedKind) : undefined, + kind: selectedKinds.length + ? new EntityKindFilter(selectedKinds) + : undefined, }); - }, [selectedKind, updateFilters]); + }, [selectedKinds, updateFilters]); - if (hidden) return null; + if (!availableKinds?.length) return null; - // TODO(timbonicus): This should load available kinds from the catalog-backend, similar to - // EntityTypePicker. - - return Kind filter not yet available; + return ( + + + Kinds + setSelectedKinds(value)} + renderOption={(option, { selected }) => ( + + } + label={option} + /> + )} + size="small" + popupIcon={} + renderInput={params => ( + + )} + /> + + + ); }; diff --git a/plugins/catalog-react/src/filters.ts b/plugins/catalog-react/src/filters.ts index 056b3b92c1..efa10a8e19 100644 --- a/plugins/catalog-react/src/filters.ts +++ b/plugins/catalog-react/src/filters.ts @@ -28,14 +28,19 @@ import { getEntityRelations } from './utils'; * @public */ export class EntityKindFilter implements EntityFilter { - constructor(readonly value: string) {} + constructor(readonly value: string | string[]) {} - getCatalogFilters(): Record { - return { kind: this.value }; + // Simplify `string | string[]` for consumers, always returns an array + getKinds(): string[] { + return Array.isArray(this.value) ? this.value : [this.value]; } - toQueryValue(): string { - return this.value; + getCatalogFilters(): Record { + return { kind: this.getKinds() }; + } + + toQueryValue(): string[] { + return this.getKinds(); } } diff --git a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx index 39c19d717a..3f34a950be 100644 --- a/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/DefaultCatalogPage.tsx @@ -34,6 +34,7 @@ import { EntityTypePicker, UserListFilterKind, UserListPicker, + EntityKindPicker, } from '@backstage/plugin-catalog-react'; import React from 'react'; import { createComponentRouteRef } from '../../routes'; @@ -83,6 +84,7 @@ export function DefaultCatalogPage(props: DefaultCatalogPageProps) { + diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 6bc18d7a90..5a7f8aabbb 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -70,7 +70,12 @@ export const CatalogTable = (props: CatalogTableProps) => { const defaultColumns: TableColumn[] = useMemo(() => { return [ columnFactories.createTitleColumn({ hidden: true }), - columnFactories.createNameColumn({ defaultKind: filters.kind?.value }), + columnFactories.createNameColumn({ + defaultKind: + filters.kind?.getKinds()?.length === 1 + ? filters.kind?.getKinds()[0] + : undefined, + }), ...createEntitySpecificColumns(), columnFactories.createMetadataDescriptionColumn(), columnFactories.createTagsColumn(), @@ -100,7 +105,7 @@ export const CatalogTable = (props: CatalogTableProps) => { ]; } } - }, [filters.kind?.value]); + }, [filters.kind]); const showTypeColumn = filters.type === undefined; // TODO(timbonicus): remove the title from the CatalogTable once using EntitySearchBar From 7289b5ec4eaf8d6e01fd45ad61649762d114fb9e Mon Sep 17 00:00:00 2001 From: Alessandro Dalfovo Date: Tue, 13 Sep 2022 16:27:30 +0200 Subject: [PATCH 010/127] Call logout OAuth handler in OAuthAdapter Signed-off-by: Alessandro Dalfovo --- .../src/lib/oauth/OAuthAdapter.test.ts | 10 ++++++++-- .../auth-backend/src/lib/oauth/OAuthAdapter.ts | 16 ++++++++++++++-- plugins/auth-backend/src/lib/oauth/index.ts | 1 + plugins/auth-backend/src/lib/oauth/types.ts | 7 ++++++- .../src/providers/google/provider.ts | 7 +++++++ 5 files changed, 36 insertions(+), 5 deletions(-) diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts index 526b05d0ab..e0d4f0e051 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.test.ts @@ -17,7 +17,7 @@ import express from 'express'; import { THOUSAND_DAYS_MS, TEN_MINUTES_MS, OAuthAdapter } from './OAuthAdapter'; import { encodeState } from './helpers'; -import { OAuthHandlers, OAuthState } from './types'; +import { OAuthHandlers, OAuthLogoutRequest, OAuthState } from './types'; import { CookieConfigurer } from '../../providers/types'; const mockResponseData = { @@ -60,6 +60,7 @@ describe('OAuthAdapter', () => { refreshToken: 'token', }; } + async logout(_: OAuthLogoutRequest) {} } const providerInstance = new MyAuthProvider(); const mockCookieConfig: ReturnType = { @@ -262,13 +263,17 @@ describe('OAuthAdapter', () => { ); }); - it('removes refresh cookie when logging out', async () => { + it('removes refresh cookie and calls logout handler when logging out', async () => { + const logoutSpy = jest.spyOn(providerInstance, 'logout'); const oauthProvider = new OAuthAdapter(providerInstance, { ...oAuthProviderOptions, isOriginAllowed: () => false, }); const mockRequest = { + cookies: { + 'test-provider-refresh-token': 'token', + }, header: () => 'XMLHttpRequest', get: jest.fn(), } as unknown as express.Request; @@ -281,6 +286,7 @@ describe('OAuthAdapter', () => { await oauthProvider.logout(mockRequest, mockResponse); expect(mockRequest.get).toHaveBeenCalledTimes(1); + expect(logoutSpy).toHaveBeenCalledTimes(1); expect(mockResponse.cookie).toHaveBeenCalledTimes(1); expect(mockResponse.cookie).toHaveBeenCalledWith( expect.stringContaining('test-provider-refresh-token'), diff --git a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts index 8f08cfbbd0..85104e9608 100644 --- a/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts +++ b/plugins/auth-backend/src/lib/oauth/OAuthAdapter.ts @@ -39,6 +39,7 @@ import { OAuthStartRequest, OAuthRefreshRequest, OAuthState, + OAuthLogoutRequest, } from './types'; import { prepareBackstageIdentityResponse } from '../../providers/prepareBackstageIdentityResponse'; @@ -190,6 +191,14 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { throw new AuthenticationError('Invalid X-Requested-With header'); } + if (this.handlers.logout) { + const refreshToken = this.getRefreshTokenFromCookie(req); + const revokeRequest: OAuthLogoutRequest = Object.assign(req, { + refreshToken, + }); + await this.handlers.logout(revokeRequest); + } + // remove refresh token cookie if it is set const origin = req.get('origin'); const cookieConfig = this.getCookieConfig(origin); @@ -210,8 +219,7 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { } try { - const refreshToken = - req.cookies[`${this.options.providerId}-refresh-token`]; + const refreshToken = this.getRefreshTokenFromCookie(req); // throw error if refresh token is missing in the request if (!refreshToken) { @@ -286,6 +294,10 @@ export class OAuthAdapter implements AuthProviderRouteHandlers { }); }; + private getRefreshTokenFromCookie = (req: express.Request) => { + return req.cookies[`${this.options.providerId}-refresh-token`]; + }; + private getGrantedScopeFromCookie = (req: express.Request) => { return req.cookies[`${this.options.providerId}-granted-scope`]; }; diff --git a/plugins/auth-backend/src/lib/oauth/index.ts b/plugins/auth-backend/src/lib/oauth/index.ts index 671e8e48e9..3643898bed 100644 --- a/plugins/auth-backend/src/lib/oauth/index.ts +++ b/plugins/auth-backend/src/lib/oauth/index.ts @@ -26,5 +26,6 @@ export type { OAuthState, OAuthStartRequest, OAuthRefreshRequest, + OAuthLogoutRequest, OAuthResult, } from './types'; diff --git a/plugins/auth-backend/src/lib/oauth/types.ts b/plugins/auth-backend/src/lib/oauth/types.ts index e8b5282aad..3902c3f026 100644 --- a/plugins/auth-backend/src/lib/oauth/types.ts +++ b/plugins/auth-backend/src/lib/oauth/types.ts @@ -104,6 +104,11 @@ export type OAuthRefreshRequest = express.Request<{}> & { refreshToken: string; }; +/** @public */ +export type OAuthLogoutRequest = express.Request<{}> & { + refreshToken: string; +}; + /** * Any OAuth provider needs to implement this interface which has provider specific * handlers for different methods to perform authentication, get access tokens, @@ -136,5 +141,5 @@ export interface OAuthHandlers { /** * (Optional) Sign out of the auth provider. */ - logout?(): Promise; + logout?(req: OAuthLogoutRequest): Promise; } diff --git a/plugins/auth-backend/src/providers/google/provider.ts b/plugins/auth-backend/src/providers/google/provider.ts index 7689cd1a3a..afe5c27f6b 100644 --- a/plugins/auth-backend/src/providers/google/provider.ts +++ b/plugins/auth-backend/src/providers/google/provider.ts @@ -16,6 +16,7 @@ import express from 'express'; import passport from 'passport'; +import { OAuth2Client } from 'google-auth-library'; import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; import { encodeState, @@ -27,6 +28,7 @@ import { OAuthResponse, OAuthResult, OAuthStartRequest, + OAuthLogoutRequest, } from '../../lib/oauth'; import { executeFetchUserProfileStrategy, @@ -119,6 +121,11 @@ export class GoogleAuthProvider implements OAuthHandlers { }; } + async logout(req: OAuthLogoutRequest) { + const oauthClient = new OAuth2Client(); + await oauthClient.revokeToken(req.refreshToken); + } + async refresh(req: OAuthRefreshRequest) { const { accessToken, refreshToken, params } = await executeRefreshTokenStrategy( From e2dc42e9f06a152336bbdc322e85f03f5fd46bd9 Mon Sep 17 00:00:00 2001 From: Francesco Saltori Date: Tue, 13 Sep 2022 16:39:25 +0200 Subject: [PATCH 011/127] Add changeset Signed-off-by: Francesco Saltori --- .changeset/hot-geese-vanish.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/hot-geese-vanish.md diff --git a/.changeset/hot-geese-vanish.md b/.changeset/hot-geese-vanish.md new file mode 100644 index 0000000000..b392c875c7 --- /dev/null +++ b/.changeset/hot-geese-vanish.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-auth-backend': minor +--- + +Google OAuth refresh tokens will now be revoked on logout by calling Google's API From 2bd3dec11d9776660da524e73d712d432028c57f Mon Sep 17 00:00:00 2001 From: Francesco Saltori Date: Tue, 13 Sep 2022 16:48:19 +0200 Subject: [PATCH 012/127] Update API report Signed-off-by: Francesco Saltori --- plugins/auth-backend/api-report.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plugins/auth-backend/api-report.md b/plugins/auth-backend/api-report.md index ea5b923baa..cc6962cdbe 100644 --- a/plugins/auth-backend/api-report.md +++ b/plugins/auth-backend/api-report.md @@ -314,7 +314,7 @@ export interface OAuthHandlers { response: OAuthResponse; refreshToken?: string; }>; - logout?(): Promise; + logout?(req: OAuthLogoutRequest): Promise; refresh?(req: OAuthRefreshRequest): Promise<{ response: OAuthResponse; refreshToken?: string; @@ -322,6 +322,11 @@ export interface OAuthHandlers { start(req: OAuthStartRequest): Promise; } +// @public (undocumented) +export type OAuthLogoutRequest = express.Request<{}> & { + refreshToken: string; +}; + // @public (undocumented) export type OAuthProviderInfo = { accessToken: string; From 6adcec07f63eeaf4c55aa32919a49a9d39679325 Mon Sep 17 00:00:00 2001 From: Francesco Saltori Date: Tue, 27 Sep 2022 14:45:05 +0200 Subject: [PATCH 013/127] Handle logout errors with errorApi Signed-off-by: Francesco Saltori --- .../AuthProviders/ProviderSettingsItem.tsx | 7 ++++++- .../UserSettingsAuthProviders.test.tsx | 2 +- .../src/components/General/UserSettingsMenu.tsx | 14 ++++++++++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx b/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx index ab6ad8dff9..a5962d47ab 100644 --- a/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx +++ b/plugins/user-settings/src/components/AuthProviders/ProviderSettingsItem.tsx @@ -32,6 +32,7 @@ import { ProfileInfoApi, ProfileInfo, useApi, + errorApiRef, IconComponent, } from '@backstage/core-plugin-api'; import { ProviderSettingsAvatar } from './ProviderSettingsAvatar'; @@ -46,6 +47,7 @@ export const ProviderSettingsItem = (props: { const { title, description, icon: Icon, apiRef } = props; const api = useApi(apiRef); + const errorApi = useApi(errorApiRef); const [signedIn, setSignedIn] = useState(false); const emptyProfile: ProfileInfo = {}; const [profile, setProfile] = useState(emptyProfile); @@ -126,7 +128,10 @@ export const ProviderSettingsItem = (props: { diff --git a/plugins/user-settings/src/components/AuthProviders/UserSettingsAuthProviders.test.tsx b/plugins/user-settings/src/components/AuthProviders/UserSettingsAuthProviders.test.tsx index 4e14dec2ef..fe5440d355 100644 --- a/plugins/user-settings/src/components/AuthProviders/UserSettingsAuthProviders.test.tsx +++ b/plugins/user-settings/src/components/AuthProviders/UserSettingsAuthProviders.test.tsx @@ -26,7 +26,7 @@ import { UserSettingsAuthProviders } from './UserSettingsAuthProviders'; import { ApiProvider, ConfigReader } from '@backstage/core-app-api'; import { configApiRef, googleAuthApiRef } from '@backstage/core-plugin-api'; -const mockSignInHandler = jest.fn().mockReturnValue(''); +const mockSignInHandler = jest.fn().mockReturnValue(Promise.resolve()); const mockGoogleAuth = { sessionState$: () => ({ [Symbol.observable]: jest.fn(), diff --git a/plugins/user-settings/src/components/General/UserSettingsMenu.tsx b/plugins/user-settings/src/components/General/UserSettingsMenu.tsx index 2483f6cd36..c83bdd5b74 100644 --- a/plugins/user-settings/src/components/General/UserSettingsMenu.tsx +++ b/plugins/user-settings/src/components/General/UserSettingsMenu.tsx @@ -18,10 +18,15 @@ import React from 'react'; import { IconButton, ListItemIcon, Menu, MenuItem } from '@material-ui/core'; import SignOutIcon from '@material-ui/icons/MeetingRoom'; import MoreVertIcon from '@material-ui/icons/MoreVert'; -import { identityApiRef, useApi } from '@backstage/core-plugin-api'; +import { + identityApiRef, + errorApiRef, + useApi, +} from '@backstage/core-plugin-api'; /** @public */ export const UserSettingsMenu = () => { + const errorApi = useApi(errorApiRef); const identityApi = useApi(identityApiRef); const [open, setOpen] = React.useState(false); const [anchorEl, setAnchorEl] = React.useState( @@ -48,7 +53,12 @@ export const UserSettingsMenu = () => { - identityApi.signOut()}> + + identityApi.signOut().catch(error => errorApi.post(error)) + } + > From d149c09cc82dc0635518be3b25c3ab90b9e8e6c2 Mon Sep 17 00:00:00 2001 From: Carlos Esteban Lopez Date: Thu, 15 Sep 2022 13:12:37 -0500 Subject: [PATCH 014/127] docs: Add OIDC section Signed-off-by: Carlos Esteban Lopez --- docs/auth/oidc.md | 235 ++++++++++++++++++++++++++++++++++++++++ microsite/sidebars.json | 1 + mkdocs.yml | 1 + 3 files changed, 237 insertions(+) create mode 100644 docs/auth/oidc.md diff --git a/docs/auth/oidc.md b/docs/auth/oidc.md new file mode 100644 index 0000000000..eb8a95afd5 --- /dev/null +++ b/docs/auth/oidc.md @@ -0,0 +1,235 @@ +--- +id: oidc +title: OIDC provider from scratch +description: This section shows how to use an OIDC provider from scrath, same steps apply for custom providers. +--- + +This section shows how to use an OIDC provider from scrath, same steps apply for custom +providers. Please note these steps are for using a provider, not how to implement one. + +## Summary + +To add providers not enabled by default like OIDC, we need to follow some steps, we +assume you already have a sign in page to which we'll add the provider so users can +sign in through the provider. In simple steps here's how you enable the provider: + +- Create an API reference to identify the provider. +- Create the API factory that will handle the authentication. +- Add a resolver so you can handle the result from the authentication. +- Configure the provider to access your 3rd party auth solution. +- Add the provider to sign in page so users can login with it. + +We'll explain each step more in detail next. + +### The API reference + +An API reference exist for the sake of **Dependency Injection**, it's basically an ID to +help backstage DI to identify the provider and either create a new instance of the +class/object/API identified by such ID, or if it has already been created, return the +existing instance, that way we have a singleton instance of the provider. + +In this OIDC example, we'll create the API reference directly in the +`packages/app/src/apis.ts` file, it is not a requirement to put the reference in this +file. Any location will do as long as it's available to be imported to where the API +factory is, as well as easily accessible to the rest of the application so any package +and plugin can inject the API instance when necessary. + +An example of such would be when you use an auth provider from a library installed with +NPM, or any other library repository, you would import the API ref from the library. + +```ts +export const oidcAuthApiRef: ApiRef< + OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi +> = createApiRef({ + id: 'core.auth.oidc', +}); +``` + +Please note a few things, the ID can be anything you want as long as it doesn't conflict +with other refs, also we're exporting this reference, as well as the typings, we need to +be able to import this reference anywhere in the app, and the typings will tell typescript +what instance we're getting from DI when injecting the API. In this case we are defining +an API for authentication, so we tell TS that this instance complies with 4 API +interfaces: + +- The OICD API that will handle authentication. +- Profile API for requesting user profile info from the auth provider in question. +- Backstage identity API to handle and associate the user profile with backstage identity. +- Session API, to handle the session the user will have while logged in. + +### The API Factory + +A factory is a function that can take some parameters or dependencies and return an +instance of something, in our case it will be a function that requests some backstage +APIs and use them to create an instance of an OIDC provider. + +Please note that this function only runs (creates the instance) when somewhere else in +the app you request the DI to give you an instance of the OIDC provider using the API ref +defined above, and the DI will only run this function the first time, from then on any +other DI injection will just receive the same instance created the first time, basically +the instance is cached by the DI library, a singleton. + +Let's add our OIDC factory to the APIs array in the `packages/app/src/apis.ts` file: + +```diff ++ import { OAuth2 } from '@backstage/core-app-api'; + +export const apis: AnyApiFactory[] = [ ++ createApiFactory({ ++ api: oidcAuthApiRef, ++ deps: { ++ discoveryApi: discoveryApiRef, ++ oauthRequestApi: oauthRequestApiRef, ++ configApi: configApiRef, ++ }, ++ factory: ({ discoveryApi, oauthRequestApi, configApi }) => ++ OAuth2.create({ ++ discoveryApi, ++ oauthRequestApi, ++ provider: { ++ id: 'oidc', ++ title: 'OIDC provider', ++ icon: () => null, ++ }, ++ environment: configApi.getOptionalString('auth.environment'), ++ defaultScopes: [ ++ 'openid', ++ 'profile', ++ 'email', ++ ], ++ }), ++ }), + +``` + +Please note we're importing the `OAuth2` class from `@backstage/core-app-api` effectively +delegating the authentication to it (yes it can handle OIDC as well). Also we're using +the `oidc` ID to tell `OAuth2` to use the OIDC protocol, and added the default scopes to +request ID, profile, email and user read permissions. + +### The Resolver + +Resolvers exist to map user identity from the 3rd party (in this case OIDC provider) to +the backstage user identity, for a detailed explanation check the [Identity Resolver][1] +page, it explains how to write a custom resolver as well as linking the built in resolvers +of backstage. + +As an example if you're setting up OIDC provider with Microsoft, you could use the built +in microsoft resolvers, or create one yourself in `packages/backend/src/plugins/auth.ts`: + +```diff +import { + DEFAULT_NAMESPACE, ++ stringifyEntityRef, +} from '@backstage/catalog-model'; + +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + return await createRouter({ + logger: env.logger, + config: env.config, + database: env.database, + discovery: env.discovery, + tokenManager: env.tokenManager, + providerFactories: { + ...defaultAuthProviderFactories, ++ // oidc: providers.oidc.create({ ++ // signIn: { ++ // resolver: ++ // providers.microsoft.resolvers.emailMatchingUserEntityAnnotation() as any, ++ // }, ++ // }), ++ oidc: providers.oidc.create({ ++ signIn: { ++ resolver(info, ctx) { ++ const userRef = stringifyEntityRef({ ++ kind: 'User', ++ name: info.profile.email!, ++ namespace: DEFAULT_NAMESPACE, ++ }); ++ console.log(info, userRef); ++ return ctx.issueToken({ ++ claims: { ++ sub: userRef, // The user's own identity ++ ent: [userRef], // A list of identities that the user claims ownership through ++ }, ++ }); ++ }, ++ }, ++ }), + } +``` + +### The configuration + +We are using the `OAuth2` wrapper to delegate the authentication to the 3rd party using +the OIDC protocol, as such, it depends on the specific wrapper what has to be configured. + +As an example we'll configure OIDC with Microsoft, to do so we need to +[Create app registration][2] in the Azure console, the only difference is that the +`http://localhost:7007/api/auth/microsoft/handler/frame` url needs to change to +`http://localhost:7007/api/auth/oidc/handler/frame`. + +Then we need to configure the env variables for the provider, based on the provider's code +in `plugins/auth-backend/src/providers/oidc/provider.ts` we need the following variables +in the `app-config.yaml`: + +```yaml +auth: + environment: development + ### Providing an auth.session.secret will enable session support in the auth-backend + session: + secret: ${SESSION_SECRET} + providers: + oidc: + # Note that you must define a session secret (see above) since the oidc provider requires session support. + # Note that by default, this provider will use the 'none' prompt which assumes that your are already logged on in the IDP. + # You should set prompt to: + # - auto: will let the IDP decide if you need to log on or if you can skip login when you have an active SSO session + # - login: will force the IDP to always present a login form to the user + development: + metadataUrl: ${AUTH_OIDC_METADATA_URL} + clientId: ${AUTH_OIDC_CLIENT_ID} + clientSecret: ${AUTH_OIDC_CLIENT_SECRET} + authorizationUrl: ${AUTH_OIDC_AUTH_URL} + tokenUrl: ${AUTH_OIDC_TOKEN_URL} + tokenSignedResponseAlg: ${AUTH_OIDC_TOKEN_SIGNED_RESPONSE_ALG} # default='RS256' + scope: ${AUTH_OIDC_SCOPE} # default='openid profile email' + prompt: ${AUTH_OIDC_PROMPT} # default=none (allowed values: auto, none, consent, login) +``` + +Anything enclosed in `${}` can be replaced directly in the yaml, or provided as +environment variables, the way you obtain all these except `scope` and `prompt` is to +check the App Registration you created: + +- clientId: Grab from the Overview page. +- clientSecret: Can only be seen when creating the secret, if you lose it you'll need a + new secret. +- metadataUrl: In Overview > Endpoints tab, grab OpenID Connect metadata document url. +- authorizationUrl and tokenUrl: Open the metadataUrl in browser, that json will hold + these 2 urls somewhere in there. +- tokenSignedResponseAlg: Don't define it, use the default unless you know what it does. +- scope: Only used if we didn't specify `defaultScopes` in the provider's factory, + basically the same thing. +- prompt: Recommended to use `auto` so the browser will request login to the IDP if the + user has no active session. + +Note that for the time being, any change in this yaml file requires a restart of the app. + +### The Sign In provider + +The last step is to add the provider to the `SignInPage` so users can sign in with your +new provider, please follow the [Sing In Configuration][3] docs, here's where you import +and use the API ref we defined earlier. + +## Note + +These steps apply to most if not all the providers, including custom providers, the main +difference between different providers will be the contents of the factory, the code in +the resolver, and the different variables each provider needs in the YAML config or env +variables. + +[1]: https://backstage.io/docs/auth/identity-resolver +[2]: https://backstage.io/docs/auth/microsoft/provider#create-an-app-registration-on-azure +[3]: https://backstage.io/docs/auth/#sign-in-configuration diff --git a/microsite/sidebars.json b/microsite/sidebars.json index 8e77594376..2cb7cfdc03 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -277,6 +277,7 @@ }, "auth/identity-resolver", "auth/oauth", + "auth/oidc.md", "auth/add-auth-provider", "auth/troubleshooting", "auth/glossary" diff --git a/mkdocs.yml b/mkdocs.yml index c0a8e3a182..b1f8399d78 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -162,6 +162,7 @@ nav: - Bitbucket: 'auth/bitbucket/provider.md' - Sign in resolvers: 'auth/identity-resolver.md' - OAuth and OpenID Connect: 'auth/oauth.md' + - OIDC provider from scratch: 'auth/oidc.md' - Contributing New Providers: 'auth/add-auth-provider.md' - Troubleshooting Auth: 'auth/troubleshooting.md' - Glossary: 'auth/glossary.md' From bae3d9e06f732d285ca757f8c7046518f1844e1b Mon Sep 17 00:00:00 2001 From: Carlos Esteban Lopez Date: Thu, 15 Sep 2022 13:25:58 -0500 Subject: [PATCH 015/127] docs: Address Vale spelling errors Signed-off-by: Carlos Esteban Lopez --- docs/auth/oidc.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/auth/oidc.md b/docs/auth/oidc.md index eb8a95afd5..6fef6b9d4c 100644 --- a/docs/auth/oidc.md +++ b/docs/auth/oidc.md @@ -4,7 +4,7 @@ title: OIDC provider from scratch description: This section shows how to use an OIDC provider from scrath, same steps apply for custom providers. --- -This section shows how to use an OIDC provider from scrath, same steps apply for custom +This section shows how to use an OIDC provider from scratch, same steps apply for custom providers. Please note these steps are for using a provider, not how to implement one. ## Summary @@ -46,8 +46,8 @@ export const oidcAuthApiRef: ApiRef< ``` Please note a few things, the ID can be anything you want as long as it doesn't conflict -with other refs, also we're exporting this reference, as well as the typings, we need to -be able to import this reference anywhere in the app, and the typings will tell typescript +with other refs, also we're exporting this reference, as well as the `typings`, we need to +be able to import this reference anywhere in the app, and the `typings` will tell typescript what instance we're getting from DI when injecting the API. In this case we are defining an API for authentication, so we tell TS that this instance complies with 4 API interfaces: @@ -115,7 +115,7 @@ page, it explains how to write a custom resolver as well as linking the built in of backstage. As an example if you're setting up OIDC provider with Microsoft, you could use the built -in microsoft resolvers, or create one yourself in `packages/backend/src/plugins/auth.ts`: +in Microsoft resolvers, or create one yourself in `packages/backend/src/plugins/auth.ts`: ```diff import { @@ -168,7 +168,7 @@ the OIDC protocol, as such, it depends on the specific wrapper what has to be co As an example we'll configure OIDC with Microsoft, to do so we need to [Create app registration][2] in the Azure console, the only difference is that the -`http://localhost:7007/api/auth/microsoft/handler/frame` url needs to change to +`http://localhost:7007/api/auth/microsoft/handler/frame` URL needs to change to `http://localhost:7007/api/auth/oidc/handler/frame`. Then we need to configure the env variables for the provider, based on the provider's code @@ -203,16 +203,16 @@ Anything enclosed in `${}` can be replaced directly in the yaml, or provided as environment variables, the way you obtain all these except `scope` and `prompt` is to check the App Registration you created: -- clientId: Grab from the Overview page. -- clientSecret: Can only be seen when creating the secret, if you lose it you'll need a +- `clientId`: Grab from the Overview page. +- `clientSecret`: Can only be seen when creating the secret, if you lose it you'll need a new secret. -- metadataUrl: In Overview > Endpoints tab, grab OpenID Connect metadata document url. -- authorizationUrl and tokenUrl: Open the metadataUrl in browser, that json will hold - these 2 urls somewhere in there. -- tokenSignedResponseAlg: Don't define it, use the default unless you know what it does. -- scope: Only used if we didn't specify `defaultScopes` in the provider's factory, +- `metadataUrl`: In Overview > Endpoints tab, grab OpenID Connect metadata document URL. +- `authorizationUrl` and `tokenUrl`: Open the `metadataUrl` in a browser, that json will + hold these 2 urls somewhere in there. +- `tokenSignedResponseAlg`: Don't define it, use the default unless you know what it does. +- `scope`: Only used if we didn't specify `defaultScopes` in the provider's factory, basically the same thing. -- prompt: Recommended to use `auto` so the browser will request login to the IDP if the +- `prompt`: Recommended to use `auto` so the browser will request login to the IDP if the user has no active session. Note that for the time being, any change in this yaml file requires a restart of the app. From f29577ba23e4590c791f5ce6140a3bd0c509404a Mon Sep 17 00:00:00 2001 From: Carlos Esteban Lopez Jaramillo Date: Mon, 19 Sep 2022 12:32:15 -0500 Subject: [PATCH 016/127] Update microsite/sidebars.json Co-authored-by: Jamie Klassen Signed-off-by: Carlos Esteban Lopez Jaramillo --- microsite/sidebars.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/microsite/sidebars.json b/microsite/sidebars.json index 2cb7cfdc03..d0c9a8592c 100644 --- a/microsite/sidebars.json +++ b/microsite/sidebars.json @@ -277,7 +277,7 @@ }, "auth/identity-resolver", "auth/oauth", - "auth/oidc.md", + "auth/oidc", "auth/add-auth-provider", "auth/troubleshooting", "auth/glossary" From b6e95ea96dca9b2492347b4ffdf9ccde4723a428 Mon Sep 17 00:00:00 2001 From: Carlos Esteban Lopez Date: Tue, 20 Sep 2022 10:34:23 -0500 Subject: [PATCH 017/127] docs: Address MR comments Signed-off-by: Carlos Esteban Lopez --- docs/auth/oidc.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/auth/oidc.md b/docs/auth/oidc.md index 6fef6b9d4c..bde1b4076d 100644 --- a/docs/auth/oidc.md +++ b/docs/auth/oidc.md @@ -23,10 +23,8 @@ We'll explain each step more in detail next. ### The API reference -An API reference exist for the sake of **Dependency Injection**, it's basically an ID to -help backstage DI to identify the provider and either create a new instance of the -class/object/API identified by such ID, or if it has already been created, return the -existing instance, that way we have a singleton instance of the provider. +An API reference exist for the sake of **Dependency Injection**, check [Utility APIs][4] +for extended explanation. In this OIDC example, we'll create the API reference directly in the `packages/app/src/apis.ts` file, it is not a requirement to put the reference in this @@ -233,3 +231,4 @@ variables. [1]: https://backstage.io/docs/auth/identity-resolver [2]: https://backstage.io/docs/auth/microsoft/provider#create-an-app-registration-on-azure [3]: https://backstage.io/docs/auth/#sign-in-configuration +[4]: https://backstage.io/docs/api/utility-apis From 9c9938bbb5187c0f886f06f619d717f90acfe0e7 Mon Sep 17 00:00:00 2001 From: Carlos Esteban Lopez Date: Tue, 27 Sep 2022 10:45:21 -0500 Subject: [PATCH 018/127] docs: Remove commented diff per MR comments Signed-off-by: Carlos Esteban Lopez --- docs/auth/oidc.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/docs/auth/oidc.md b/docs/auth/oidc.md index bde1b4076d..f1db4a1257 100644 --- a/docs/auth/oidc.md +++ b/docs/auth/oidc.md @@ -132,12 +132,6 @@ export default async function createPlugin( tokenManager: env.tokenManager, providerFactories: { ...defaultAuthProviderFactories, -+ // oidc: providers.oidc.create({ -+ // signIn: { -+ // resolver: -+ // providers.microsoft.resolvers.emailMatchingUserEntityAnnotation() as any, -+ // }, -+ // }), + oidc: providers.oidc.create({ + signIn: { + resolver(info, ctx) { From 200bdbfa2ef7cca3dbb3ac6ea2bcbee47468ef48 Mon Sep 17 00:00:00 2001 From: Carlos Esteban Lopez Date: Tue, 27 Sep 2022 16:17:41 -0500 Subject: [PATCH 019/127] docs: Make oidc provider example an specific provider to azure Signed-off-by: Carlos Esteban Lopez --- docs/auth/oidc.md | 33 ++++++++++++++++++--------------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/docs/auth/oidc.md b/docs/auth/oidc.md index f1db4a1257..c5d2d54c0c 100644 --- a/docs/auth/oidc.md +++ b/docs/auth/oidc.md @@ -1,11 +1,14 @@ --- id: oidc title: OIDC provider from scratch -description: This section shows how to use an OIDC provider from scrath, same steps apply for custom providers. +description: This section shows how to use an OIDC provider from scratch, same steps apply for custom providers. --- This section shows how to use an OIDC provider from scratch, same steps apply for custom -providers. Please note these steps are for using a provider, not how to implement one. +providers. Please note these steps are for using a provider, not how to implement one, +and Backstage recommends creating custom providers specific to the IDP, so we'll use a +`azureOIDC` provider throughout this example, feel free to change any of those refs +to your provider name. ## Summary @@ -36,10 +39,10 @@ An example of such would be when you use an auth provider from a library install NPM, or any other library repository, you would import the API ref from the library. ```ts -export const oidcAuthApiRef: ApiRef< +export const azureOIDCAuthApiRef: ApiRef< OpenIdConnectApi & ProfileInfoApi & BackstageIdentityApi & SessionApi > = createApiRef({ - id: 'core.auth.oidc', + id: 'core.auth.azureOIDC', }); ``` @@ -74,7 +77,7 @@ Let's add our OIDC factory to the APIs array in the `packages/app/src/apis.ts` f export const apis: AnyApiFactory[] = [ + createApiFactory({ -+ api: oidcAuthApiRef, ++ api: azureOIDCAuthApiRef, + deps: { + discoveryApi: discoveryApiRef, + oauthRequestApi: oauthRequestApiRef, @@ -85,7 +88,7 @@ export const apis: AnyApiFactory[] = [ + discoveryApi, + oauthRequestApi, + provider: { -+ id: 'oidc', ++ id: 'oidc', // This has to be 'oidc' or OAuth2 will not use oidc protocol + title: 'OIDC provider', + icon: () => null, + }, @@ -107,13 +110,13 @@ request ID, profile, email and user read permissions. ### The Resolver -Resolvers exist to map user identity from the 3rd party (in this case OIDC provider) to -the backstage user identity, for a detailed explanation check the [Identity Resolver][1] -page, it explains how to write a custom resolver as well as linking the built in resolvers -of backstage. +Resolvers exist to map user identity from the 3rd party (in this case an azure IDP +provider) to the backstage user identity, for a detailed explanation check the +[Identity Resolver][1] page, it explains how to write a custom resolver as well as +linking the built in resolvers of backstage. -As an example if you're setting up OIDC provider with Microsoft, you could use the built -in Microsoft resolvers, or create one yourself in `packages/backend/src/plugins/auth.ts`: +As an example if you're setting up OIDC provider with Azure IDP, you could reuse +the built in resolvers, or create one yourself in `packages/backend/src/plugins/auth.ts`: ```diff import { @@ -132,7 +135,7 @@ export default async function createPlugin( tokenManager: env.tokenManager, providerFactories: { ...defaultAuthProviderFactories, -+ oidc: providers.oidc.create({ ++ azureOIDC: providers.oidc.create({ + signIn: { + resolver(info, ctx) { + const userRef = stringifyEntityRef({ @@ -158,7 +161,7 @@ export default async function createPlugin( We are using the `OAuth2` wrapper to delegate the authentication to the 3rd party using the OIDC protocol, as such, it depends on the specific wrapper what has to be configured. -As an example we'll configure OIDC with Microsoft, to do so we need to +As an example we'll configure OIDC with `azureOIDC`, to do so we need to [Create app registration][2] in the Azure console, the only difference is that the `http://localhost:7007/api/auth/microsoft/handler/frame` URL needs to change to `http://localhost:7007/api/auth/oidc/handler/frame`. @@ -174,7 +177,7 @@ auth: session: secret: ${SESSION_SECRET} providers: - oidc: + azureOIDC: # Note that you must define a session secret (see above) since the oidc provider requires session support. # Note that by default, this provider will use the 'none' prompt which assumes that your are already logged on in the IDP. # You should set prompt to: From 6389362f0dcbdf0e7cea34b0ada6268bcf98e0de Mon Sep 17 00:00:00 2001 From: hram_wh Date: Wed, 28 Sep 2022 11:51:03 +0530 Subject: [PATCH 020/127] requested changes made Signed-off-by: hram_wh --- .../EntityContextMenu/EntityContextMenu.tsx | 30 ++++--------------- 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx index 92ed8c02a6..989682fde6 100644 --- a/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx @@ -27,13 +27,13 @@ import { makeStyles } from '@material-ui/core/styles'; import BugReportIcon from '@material-ui/icons/BugReport'; import MoreVert from '@material-ui/icons/MoreVert'; import FileCopyTwoToneIcon from '@material-ui/icons/FileCopyTwoTone'; -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { IconComponent } from '@backstage/core-plugin-api'; import { useEntityPermission } from '@backstage/plugin-catalog-react'; import { catalogEntityDeletePermission } from '@backstage/plugin-catalog-common'; import { BackstageTheme } from '@backstage/theme'; import { UnregisterEntity, UnregisterEntityOptions } from './UnregisterEntity'; -import Snackbar from '@material-ui/core/Snackbar'; +import { useApi, alertApiRef } from '@backstage/core-plugin-api'; /** @public */ export type EntityContextMenuClassKey = 'button'; @@ -86,23 +86,13 @@ export function EntityContextMenu(props: EntityContextMenuProps) { setAnchorEl(undefined); }; - const [state, setState] = useState({ - open: false, - vertical: 'top', - horizontal: 'right', - }); + const alertApi = useApi(alertApiRef); - const { open, vertical, horizontal } = state; - - const handleClose = () => { - setState({ ...state, open: false }); - }; - - const copyToClipboard = () => { + const copyToClipboard = useCallback(() => { navigator.clipboard .writeText(window.location.toString()) - .then(() => setState({ ...state, open: true })); - }; + .then(() => alertApi.post({ message: 'Copied!', severity: 'info' })); + }, []); const extraItems = UNSTABLE_extraContextMenuItems && [ ...UNSTABLE_extraContextMenuItems.map(item => ( @@ -174,14 +164,6 @@ export function EntityContextMenu(props: EntityContextMenuProps) { - From 4efadb69682bb59786828e74166d80bc9fe53659 Mon Sep 17 00:00:00 2001 From: Crevil Date: Thu, 11 Aug 2022 13:34:08 +0200 Subject: [PATCH 021/127] Respect initial filter Signed-off-by: Crevil --- .changeset/calm-moose-fetch.md | 8 + plugins/catalog-react/api-report.md | 4 +- .../EntityKindPicker.test.tsx | 178 +++++++++++--- .../EntityKindPicker/EntityKindPicker.tsx | 219 +++++++++++------- plugins/catalog-react/src/filters.ts | 15 +- .../src/hooks/useEntityListProvider.test.tsx | 7 + .../CatalogKindHeader/CatalogKindHeader.tsx | 9 + .../CatalogPage/DefaultCatalogPage.tsx | 2 +- .../components/CatalogTable/CatalogTable.tsx | 9 +- 9 files changed, 321 insertions(+), 130 deletions(-) create mode 100644 .changeset/calm-moose-fetch.md diff --git a/.changeset/calm-moose-fetch.md b/.changeset/calm-moose-fetch.md new file mode 100644 index 0000000000..29d581bf23 --- /dev/null +++ b/.changeset/calm-moose-fetch.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-catalog-react': minor +'@backstage/plugin-catalog': patch +--- + +Implement `EntityKindPicker` which allows users to filter the catalog kinds much like the `CatalogKindHeader`. + +The new picker is more accessible though listed as any other filter options in the catalog. diff --git a/plugins/catalog-react/api-report.md b/plugins/catalog-react/api-report.md index ee43a0a5c1..8f95f5cb13 100644 --- a/plugins/catalog-react/api-report.md +++ b/plugins/catalog-react/api-report.md @@ -161,7 +161,7 @@ export type EntityFilter = { export class EntityKindFilter implements EntityFilter { constructor(value: string); // (undocumented) - getCatalogFilters(): Record; + getCatalogFilters(): Record; // (undocumented) toQueryValue(): string; // (undocumented) @@ -176,7 +176,7 @@ export const EntityKindPicker: ( // @public export interface EntityKindPickerProps { // (undocumented) - hidden: boolean; + hidden?: boolean; // (undocumented) initialFilter?: string; } diff --git a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx index d6404fcf07..65ad3f227a 100644 --- a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx +++ b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.test.tsx @@ -14,46 +14,170 @@ * limitations under the License. */ -import { render } from '@testing-library/react'; -import React from 'react'; -import { MockEntityListContextProvider } from '../../testUtils/providers'; +import { GetEntityFacetsResponse } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; +import { ApiProvider } from '@backstage/core-app-api'; +import { alertApiRef } from '@backstage/core-plugin-api'; +import { renderWithEffects, TestApiRegistry } from '@backstage/test-utils'; +import { fireEvent, waitFor } from '@testing-library/react'; +import { capitalize } from 'lodash'; +import { default as React } from 'react'; +import { catalogApiRef } from '../../api'; import { EntityKindFilter } from '../../filters'; +import { MockEntityListContextProvider } from '../../testUtils/providers'; import { EntityKindPicker } from './EntityKindPicker'; +const entities: Entity[] = [ + { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'component', + }, + }, + { + apiVersion: '1', + kind: 'Domain', + metadata: { + name: 'domain', + }, + }, + { + apiVersion: '1', + kind: 'Group', + metadata: { + name: 'group', + }, + }, +]; + describe('', () => { + const apis = TestApiRegistry.from( + [ + catalogApiRef, + { + getEntityFacets: jest.fn().mockResolvedValue({ + facets: { + kind: entities.map(e => ({ + value: e.kind, + count: 1, + })), + }, + } as GetEntityFacetsResponse), + }, + ], + [ + alertApiRef, + { + post: jest.fn(), + }, + ], + ); + + it('renders available entity kinds', async () => { + const rendered = await renderWithEffects( + + + + + , + ); + expect(rendered.getByText('Kind')).toBeInTheDocument(); + + const input = rendered.getByTestId('select'); + fireEvent.click(input); + + await waitFor(() => rendered.getByText('Domain')); + + entities.forEach(entity => { + expect( + rendered.getByRole('option', { + name: capitalize(entity.kind as string), + }), + ).toBeInTheDocument(); + }); + }); + it('sets the selected kind filter', async () => { const updateFilters = jest.fn(); - render( - - , + const rendered = await renderWithEffects( + + + + + , + ); + const input = rendered.getByTestId('select'); + fireEvent.click(input); + + await waitFor(() => rendered.getByText('Domain')); + fireEvent.click(rendered.getByText('Domain')); + + expect(updateFilters).toHaveBeenLastCalledWith({ + kind: new EntityKindFilter('domain'), + }); + }); + + it('respects the query parameter filter value', async () => { + const updateFilters = jest.fn(); + const queryParameters = { kind: 'group' }; + await renderWithEffects( + + + + , + , ); + expect(updateFilters).toHaveBeenLastCalledWith({ + kind: new EntityKindFilter('group'), + }); + }); + + it('responds to external queryParameters changes', async () => { + const updateFilters = jest.fn(); + const rendered = await renderWithEffects( + + + + + , + ); expect(updateFilters).toHaveBeenLastCalledWith({ kind: new EntityKindFilter('component'), }); - }); - - it('respects the query parameter filter value', () => { - const updateFilters = jest.fn(); - const queryParameters = { kind: 'API' }; - render( - - , + rendered.rerender( + + + + + , ); - expect(updateFilters).toHaveBeenLastCalledWith({ - kind: new EntityKindFilter('API'), + kind: new EntityKindFilter('domain'), }); }); }); diff --git a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx index 774d07c544..36ac93101a 100644 --- a/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx +++ b/plugins/catalog-react/src/components/EntityKindPicker/EntityKindPicker.tsx @@ -14,115 +14,168 @@ * limitations under the License. */ -import { useApi } from '@backstage/core-plugin-api'; -import { - Box, - Checkbox, - FormControlLabel, - makeStyles, - TextField, - Typography, -} from '@material-ui/core'; -import CheckBoxIcon from '@material-ui/icons/CheckBox'; -import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank'; -import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; -import { Autocomplete } from '@material-ui/lab'; -import React, { useEffect, useMemo, useState } from 'react'; +import { Select } from '@backstage/core-components'; +import { alertApiRef, useApi } from '@backstage/core-plugin-api'; +import { Box } from '@material-ui/core'; +import capitalize from 'lodash/capitalize'; +import sortBy from 'lodash/sortBy'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import useAsync from 'react-use/lib/useAsync'; import { catalogApiRef } from '../../api'; import { EntityKindFilter } from '../../filters'; import { useEntityList } from '../../hooks'; -const useStyles = makeStyles( - { - input: {}, - }, - { - name: 'CatalogReactEntityKindPicker', - }, -); +function useAvailableKinds() { + const catalogApi = useApi(catalogApiRef); -const icon = ; -const checkedIcon = ; + const [availableKinds, setAvailableKinds] = useState([]); -/** @public */ -export const EntityKindPicker = () => { - const classes = useStyles(); const { - updateFilters, + error, + loading, + value: facets, + } = useAsync(async () => { + const facet = 'kind'; + const items = await catalogApi + .getEntityFacets({ + facets: [facet], + }) + .then(response => response.facets[facet] || []); + + return items; + }, [catalogApi]); + + const facetsRef = useRef(facets); + useEffect(() => { + const oldFacets = facetsRef.current; + facetsRef.current = facets; + // Delay processing hook until facets load updates have settled to generate list of kinds; + // This prevents resetting the kind filter due to saved kind value from query params not matching the + // empty set of kind values while values are still being loaded; also only run this hook on changes + // to facets + if (loading || oldFacets === facets || !facets) { + return; + } + + const newKinds = [ + ...new Set( + sortBy(facets, f => f.value).map(f => + f.value.toLocaleLowerCase('en-US'), + ), + ), + ]; + + setAvailableKinds(newKinds); + }, [loading, facets, setAvailableKinds]); + + return { loading, error, availableKinds }; +} + +function useEntityKindFilter(opts: { initialFilter: string }): { + loading: boolean; + error?: Error; + availableKinds: string[]; + selectedKind: string; + setSelectedKind: (kind: string) => void; +} { + const { filters, - queryParameters: { kind: kindsParameter }, + queryParameters: { kind: kindParameter }, + updateFilters, } = useEntityList(); - const catalogApi = useApi(catalogApiRef); - const { value: availableKinds } = useAsync(async () => { - const facet = 'kind'; - const { facets } = await catalogApi.getEntityFacets({ - facets: [facet], - }); - - return facets[facet].map(({ value }) => value); - }, [filters.kind]); - - const queryParamKinds = useMemo( - () => [kindsParameter].flat().filter(Boolean) as string[], - [kindsParameter], + const flattenedQueryKind = useMemo( + () => [kindParameter].flat()[0], + [kindParameter], ); - const [selectedKinds, setSelectedKinds] = useState( - queryParamKinds.length ? queryParamKinds : filters.kind?.getKinds() ?? [], + const [selectedKind, setSelectedKind] = useState( + flattenedQueryKind ?? filters.kind?.value ?? opts.initialFilter, ); // Set selected kinds on query parameter updates; this happens at initial page load and from // external updates to the page location. useEffect(() => { - if (queryParamKinds.length) { - setSelectedKinds(queryParamKinds); + if (flattenedQueryKind) { + setSelectedKind(flattenedQueryKind); } - }, [queryParamKinds]); + }, [flattenedQueryKind]); + + // Set selected kind from filters; this happens when the kind filter is + // updated from another component + useEffect(() => { + if (filters.kind?.value) { + setSelectedKind(filters.kind?.value); + } + }, [filters.kind]); + + const { availableKinds, loading, error } = useAvailableKinds(); useEffect(() => { updateFilters({ - kind: selectedKinds.length - ? new EntityKindFilter(selectedKinds) - : undefined, + kind: selectedKind ? new EntityKindFilter(selectedKind) : undefined, }); - }, [selectedKinds, updateFilters]); + }, [selectedKind, updateFilters]); - if (!availableKinds?.length) return null; + return { + loading, + error, + availableKinds, + selectedKind, + setSelectedKind, + }; +} - return ( +/** + * Props for {@link EntityKindPicker}. + * + * @public + */ +export interface EntityKindPickerProps { + initialFilter?: string; + hidden?: boolean; +} + +/** @public */ +export const EntityKindPicker = (props: EntityKindPickerProps) => { + const { hidden, initialFilter = 'component' } = props; + + const alertApi = useApi(alertApiRef); + + const { error, availableKinds, selectedKind, setSelectedKind } = + useEntityKindFilter({ + initialFilter: initialFilter, + }); + + useEffect(() => { + if (error) { + alertApi.post({ + message: `Failed to load entity kinds`, + severity: 'error', + }); + } + if (initialFilter) { + setSelectedKind(initialFilter); + } + }, [error, alertApi, initialFilter, setSelectedKind]); + + if (availableKinds?.length === 0 || error) return null; + + const items = [ + ...availableKinds.map((kind: string) => ({ + value: kind, + label: capitalize(kind), + })), + ]; + + return hidden ? null : ( - - Kinds - setSelectedKinds(value)} - renderOption={(option, { selected }) => ( - - } - label={option} - /> - )} - size="small" - popupIcon={} - renderInput={params => ( - - )} - /> - +