Implement useEntityOwnership

Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
Fredrik Adelöw
2021-07-21 19:05:55 +02:00
parent ea6871d2d9
commit c5cb55803d
12 changed files with 529 additions and 81 deletions
+22
View File
@@ -0,0 +1,22 @@
---
'@backstage/plugin-catalog-react': minor
---
Introduce the `useEntityOwnership` hook, which implements the full new ownership model.
This also means a breaking change to the interface of `UserListFilter`. It no longer
accepts a user entity as input, but rather a function that checks ownership of an
entity. This function is taken from the above mentioned hook output. So if you are
instantiating the filter yourself, you will change from something like
```ts
const { entity } = useOwnUser();
const filter = new UserListFilter('owned', user, ...);
```
to
```ts
const { isOwnedEntity } = useEntityOwnership();
const filter = new UserListFilter('owned', isOwnedEntity, ...);
```
+11 -3
View File
@@ -720,6 +720,14 @@ export function useEntityListProvider<
EntityFilters extends DefaultEntityFilters = DefaultEntityFilters
>(): EntityListContextProps<EntityFilters>;
// Warning: (ae-missing-release-tag) "useEntityOwnership" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export function useEntityOwnership(): {
loading: boolean;
isOwnedEntity: (entity: Entity | EntityName) => boolean;
};
// Warning: (ae-forgotten-export) The symbol "EntityTypeReturn" needs to be exported by the entry point index.d.ts
// Warning: (ae-missing-release-tag) "useEntityTypeFilter" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -755,18 +763,18 @@ export function useRelatedEntities(
export class UserListFilter implements EntityFilter {
constructor(
value: UserListFilterKind,
user: UserEntity | undefined,
isOwnedEntity: (entity: Entity) => boolean,
isStarredEntity: (entity: Entity) => boolean,
);
// (undocumented)
filterEntity(entity: Entity): boolean;
// (undocumented)
readonly isOwnedEntity: (entity: Entity) => boolean;
// (undocumented)
readonly isStarredEntity: (entity: Entity) => boolean;
// (undocumented)
toQueryValue(): string;
// (undocumented)
readonly user: UserEntity | undefined;
// (undocumented)
readonly value: UserListFilterKind;
}
+2
View File
@@ -38,6 +38,7 @@
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@types/react": "^16.9",
"jwt-decode": "^3.1.0",
"lodash": "^4.17.15",
"qs": "^6.9.4",
"react": "^16.13.1",
@@ -54,6 +55,7 @@
"@testing-library/react-hooks": "^3.3.0",
"@testing-library/user-event": "^13.1.8",
"@types/jest": "^26.0.7",
"@types/jwt-decode": "^3.1.0",
"@types/node": "^14.14.32",
"cross-fetch": "^3.0.6",
"msw": "^0.29.0",
@@ -15,7 +15,7 @@
*/
import React from 'react';
import { fireEvent, render } from '@testing-library/react';
import { fireEvent, render, waitFor } from '@testing-library/react';
import {
Entity,
RELATION_OWNED_BY,
@@ -58,7 +58,8 @@ const mockCatalogApi = {
} as Partial<CatalogApi>;
const mockIdentityApi = {
getUserId: () => '',
getUserId: () => 'testUser',
getIdToken: async () => undefined,
} as Partial<IdentityApi>;
const apis = ApiRegistry.from([
@@ -68,6 +69,9 @@ const apis = ApiRegistry.from([
[storageApiRef, MockStorageApi.create()],
]);
const mockIsOwnedEntity = (entity: Entity) =>
entity.metadata.name === 'component-1';
const mockIsStarredEntity = (entity: Entity) =>
entity.metadata.name === 'component-3';
@@ -75,7 +79,9 @@ jest.mock('../../hooks', () => {
const actual = jest.requireActual('../../hooks');
return {
...actual,
useOwnUser: () => ({ value: mockUser }),
useEntityOwnership: () => ({
isOwnedEntity: mockIsOwnedEntity,
}),
useStarredEntities: () => ({
isStarredEntity: mockIsStarredEntity,
}),
@@ -161,7 +167,7 @@ describe('<UserListPicker />', () => {
).toEqual(['Owned', 'Starred', 'All']);
});
it('includes counts alongside each filter', () => {
it('includes counts alongside each filter', async () => {
const { getAllByRole } = render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider value={{ backendEntities }}>
@@ -172,14 +178,16 @@ describe('<UserListPicker />', () => {
// Material UI renders ListItemSecondaryActions outside the
// menuitem itself, so we pick off the next sibling.
expect(
getAllByRole('menuitem').map(
({ nextSibling }) => nextSibling?.textContent,
),
).toEqual(['2', '1', '4']);
await waitFor(() => {
expect(
getAllByRole('menuitem').map(
({ nextSibling }) => nextSibling?.textContent,
),
).toEqual(['1', '1', '4']);
});
});
it('respects other frontend filters in counts', () => {
it('respects other frontend filters in counts', async () => {
const { getAllByRole } = render(
<ApiProvider apis={apis}>
<MockEntityListContextProvider
@@ -193,11 +201,13 @@ describe('<UserListPicker />', () => {
</ApiProvider>,
);
expect(
getAllByRole('menuitem').map(
({ nextSibling }) => nextSibling?.textContent,
),
).toEqual(['1', '0', '2']);
await waitFor(() => {
expect(
getAllByRole('menuitem').map(
({ nextSibling }) => nextSibling?.textContent,
),
).toEqual(['1', '0', '2']);
});
});
it('respects the query parameter filter value', () => {
@@ -214,7 +224,7 @@ describe('<UserListPicker />', () => {
);
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter('owned', mockUser, mockIsStarredEntity),
user: new UserListFilter('owned', mockIsOwnedEntity, mockIsStarredEntity),
});
});
@@ -233,7 +243,11 @@ describe('<UserListPicker />', () => {
fireEvent.click(getByText('Starred'));
expect(updateFilters).toHaveBeenLastCalledWith({
user: new UserListFilter('starred', mockUser, mockIsStarredEntity),
user: new UserListFilter(
'starred',
mockIsOwnedEntity,
mockIsStarredEntity,
),
});
});
});
@@ -14,15 +14,11 @@
* limitations under the License.
*/
import React, { Fragment, useEffect, useMemo, useState } from 'react';
import { compact } from 'lodash';
import { UserListFilterKind } from '../../types';
import { UserListFilter } from '../../filters';
import {
useEntityListProvider,
useOwnUser,
useStarredEntities,
} from '../../hooks';
configApiRef,
IconComponent,
useApi,
} from '@backstage/core-plugin-api';
import {
Card,
List,
@@ -36,12 +32,16 @@ import {
} from '@material-ui/core';
import SettingsIcon from '@material-ui/icons/Settings';
import StarIcon from '@material-ui/icons/Star';
import { reduceEntityFilters } from '../../utils';
import { compact } from 'lodash';
import React, { Fragment, useEffect, useMemo, useState } from 'react';
import { UserListFilter } from '../../filters';
import {
configApiRef,
IconComponent,
useApi,
} from '@backstage/core-plugin-api';
useEntityListProvider,
useStarredEntities,
useEntityOwnership,
} from '../../hooks';
import { UserListFilterKind } from '../../types';
import { reduceEntityFilters } from '../../utils';
const useStyles = makeStyles<Theme>(theme => ({
root: {
@@ -136,20 +136,20 @@ export const UserListPicker = ({
queryParameters,
} = useEntityListProvider();
const { value: user } = useOwnUser();
const { isStarredEntity } = useStarredEntities();
const { isOwnedEntity } = useEntityOwnership();
const [selectedUserFilter, setSelectedUserFilter] = useState(
[queryParameters.user].flat()[0] ?? initialFilter,
);
// Static filters; used for generating counts of potentially unselected kinds
const ownedFilter = useMemo(
() => new UserListFilter('owned', user, isStarredEntity),
[user, isStarredEntity],
() => new UserListFilter('owned', isOwnedEntity, isStarredEntity),
[isOwnedEntity, isStarredEntity],
);
const starredFilter = useMemo(
() => new UserListFilter('starred', user, isStarredEntity),
[user, isStarredEntity],
() => new UserListFilter('starred', isOwnedEntity, isStarredEntity),
[isOwnedEntity, isStarredEntity],
);
useEffect(() => {
@@ -157,12 +157,12 @@ export const UserListPicker = ({
user: selectedUserFilter
? new UserListFilter(
selectedUserFilter as UserListFilterKind,
user,
isOwnedEntity,
isStarredEntity,
)
: undefined,
});
}, [selectedUserFilter, user, isStarredEntity, updateFilters]);
}, [selectedUserFilter, isOwnedEntity, isStarredEntity, updateFilters]);
// To show proper counts for each section, apply all other frontend filters _except_ the user
// filter that's controlled by this picker.
+5 -9
View File
@@ -14,14 +14,10 @@
* limitations under the License.
*/
import {
Entity,
UserEntity,
RELATION_OWNED_BY,
} from '@backstage/catalog-model';
import { EntityFilter, UserListFilterKind } from './types';
import { getEntityRelations, isOwnerOf } from './utils';
import { Entity, RELATION_OWNED_BY } from '@backstage/catalog-model';
import { formatEntityRefTitle } from './components/EntityRefLink';
import { EntityFilter, UserListFilterKind } from './types';
import { getEntityRelations } from './utils';
export class EntityKindFilter implements EntityFilter {
constructor(readonly value: string) {}
@@ -116,14 +112,14 @@ export class EntityLifecycleFilter implements EntityFilter {
export class UserListFilter implements EntityFilter {
constructor(
readonly value: UserListFilterKind,
readonly user: UserEntity | undefined,
readonly isOwnedEntity: (entity: Entity) => boolean,
readonly isStarredEntity: (entity: Entity) => boolean,
) {}
filterEntity(entity: Entity): boolean {
switch (this.value) {
case 'owned':
return this.user !== undefined && isOwnerOf(this.user, entity);
return this.isOwnedEntity(entity);
case 'starred':
return this.isStarredEntity(entity);
default:
+1
View File
@@ -25,3 +25,4 @@ export { useEntityTypeFilter } from './useEntityTypeFilter';
export { useOwnUser } from './useOwnUser';
export { useRelatedEntities } from './useRelatedEntities';
export { useStarredEntities } from './useStarredEntities';
export { useEntityOwnership } from './useEntityOwnership';
@@ -20,7 +20,7 @@ import { MemoryRouter as Router } from 'react-router-dom';
import { act, renderHook } from '@testing-library/react-hooks';
import { MockStorageApi } from '@backstage/test-utils';
import { CatalogApi } from '@backstage/catalog-client';
import { Entity, UserEntity } from '@backstage/catalog-model';
import { Entity } from '@backstage/catalog-model';
import {
EntityListProvider,
useEntityListProvider,
@@ -39,17 +39,6 @@ import {
storageApiRef,
} from '@backstage/core-plugin-api';
const mockUser: UserEntity = {
apiVersion: 'backstage.io/v1beta1',
kind: 'User',
metadata: {
name: 'guest',
},
spec: {
memberOf: [],
},
};
const entities: Entity[] = [
{
apiVersion: '1',
@@ -81,13 +70,12 @@ const mockConfigApi = {
getOptionalString: () => '',
} as Partial<ConfigApi>;
const mockIdentityApi: Partial<IdentityApi> = {
getUserId: () => 'guest@example.com',
getUserId: () => 'guest',
getIdToken: async () => undefined,
};
const mockCatalogApi: Partial<CatalogApi> = {
getEntities: jest
.fn()
.mockImplementation(() => Promise.resolve({ items: entities })),
getEntityByName: () => Promise.resolve(mockUser),
getEntities: jest.fn().mockImplementation(async () => ({ items: entities })),
getEntityByName: async () => undefined,
};
const apis = ApiRegistry.from([
[configApiRef, mockConfigApi],
@@ -117,7 +105,7 @@ const wrapper = ({
);
};
describe('<EntityListProvider/>', () => {
describe('<EntityListProvider />', () => {
beforeEach(() => {
jest.clearAllMocks();
});
@@ -171,18 +159,26 @@ describe('<EntityListProvider/>', () => {
const { result, waitFor } = renderHook(() => useEntityListProvider(), {
wrapper,
});
await waitFor(() => !!result.current.entities.length);
expect(result.current.entities.length).toBe(2);
expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(result.current.entities.length).toBe(2);
expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1);
});
act(() =>
result.current.updateFilters({
user: new UserListFilter('owned', mockUser, () => true),
user: new UserListFilter(
'owned',
entity => entity.metadata.name === 'component-1',
() => true,
),
}),
);
await waitFor(() => result.current.entities.length !== 2);
expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1);
expect(result.current.entities.length).toBe(1);
await waitFor(() => {
expect(result.current.entities.length).toBe(1);
expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1);
});
});
it('debounces multiple filter changes', async () => {
@@ -205,7 +201,7 @@ describe('<EntityListProvider/>', () => {
});
it('returns an error on catalogApi failure', async () => {
const { result, waitForNextUpdate, waitForValueToChange } = renderHook(
const { result, waitForValueToChange, waitFor } = renderHook(
() => useEntityListProvider(),
{
wrapper,
@@ -218,7 +214,8 @@ describe('<EntityListProvider/>', () => {
act(() => {
result.current.updateFilters({ kind: new EntityKindFilter('api') });
});
await waitForNextUpdate();
expect(result.current.error).toBeDefined();
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
});
});
@@ -0,0 +1,255 @@
/*
* 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 { CatalogApi } from '@backstage/catalog-client';
import {
ComponentEntity,
RELATION_MEMBER_OF,
RELATION_OWNED_BY,
UserEntity,
} from '@backstage/catalog-model';
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
import { IdentityApi, identityApiRef } from '@backstage/core-plugin-api';
import { renderHook } from '@testing-library/react-hooks';
import React from 'react';
import { catalogApiRef } from '../api';
import {
loadCatalogOwnerRefs,
loadIdentityOwnerRefs,
useEntityOwnership,
} from './useEntityOwnership';
describe('useEntityOwnership', () => {
type MockIdentityApi = jest.Mocked<
Pick<IdentityApi, 'getUserId' | 'getIdToken'>
>;
type MockCatalogApi = jest.Mocked<Pick<CatalogApi, 'getEntityByName'>>;
const mockIdentityApi: MockIdentityApi = {
getUserId: jest.fn(),
getIdToken: jest.fn(),
};
const mockCatalogApi: MockCatalogApi = {
getEntityByName: jest.fn(),
};
const identityApi = (mockIdentityApi as unknown) as IdentityApi;
const catalogApi = (mockCatalogApi as unknown) as CatalogApi;
const Wrapper = ({ children }: { children?: React.ReactNode }) => (
<ApiProvider
apis={ApiRegistry.with(identityApiRef, identityApi).with(
catalogApiRef,
catalogApi,
)}
>
{children}
</ApiProvider>
);
const ownedEntity: ComponentEntity = {
apiVersion: 'backstage.io/v1beta1',
kind: 'Component',
metadata: {
name: 'component1',
namespace: 'default',
},
spec: {
/* should not be accessed */
} as any,
relations: [
{
type: RELATION_OWNED_BY,
target: { kind: 'User', namespace: 'default', name: 'user1' },
},
{
type: RELATION_OWNED_BY,
target: { kind: 'Group', namespace: 'default', name: 'group1' },
},
],
};
const user2Entity: UserEntity = {
apiVersion: 'backstage.io/v1beta1',
kind: 'User',
metadata: {
name: 'user2',
namespace: 'default',
},
spec: {
/* should not be accessed */
} as any,
relations: [
{
type: RELATION_MEMBER_OF,
target: { kind: 'Group', namespace: 'default', name: 'group1' },
},
],
};
// these were generated on https://jwt.io, based off of its default example token
// no ent at all
const tokenNoEnt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
// "ent": []
const tokenEmptyEnt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJlbnQiOltdfQ.Khyza2whczkoC4wSCLBhBaBB9-ktIkk7gpXEgQPHhtY';
// "ent": ["user:default/user1"]
const tokenUserEnt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJlbnQiOlsidXNlcjpkZWZhdWx0L3VzZXIxIl19.CMCxjwI4rj_TD3uUoBNgFjkZI23LwRTbQnSPBxzncoY';
// "ent": ["user:default/user1", "group:default/group1"]
const tokenUserAndGroupEnt =
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJlbnQiOlsidXNlcjpkZWZhdWx0L3VzZXIxIiwiZ3JvdXA6ZGVmYXVsdC9ncm91cDEiXX0.ZZmZrogbQKx0hnForw63ETkyAhUyeoBE8Hgloi45rdg';
afterEach(() => {
jest.resetAllMocks();
});
describe('loadIdentityOwnerRefs', () => {
it('returns the user id when there is no relevant token info', async () => {
mockIdentityApi.getUserId.mockReturnValueOnce('foo');
mockIdentityApi.getIdToken.mockResolvedValueOnce(undefined);
await expect(loadIdentityOwnerRefs(identityApi)).resolves.toEqual([
'user:default/foo',
]);
mockIdentityApi.getUserId.mockReturnValueOnce('ns/foo');
mockIdentityApi.getIdToken.mockResolvedValueOnce(undefined);
await expect(loadIdentityOwnerRefs(identityApi)).resolves.toEqual([
'user:ns/foo',
]);
mockIdentityApi.getUserId.mockReturnValueOnce('user:ns/foo');
mockIdentityApi.getIdToken.mockResolvedValueOnce(undefined);
await expect(loadIdentityOwnerRefs(identityApi)).resolves.toEqual([
'user:ns/foo',
]);
mockIdentityApi.getUserId.mockReturnValueOnce('foo');
mockIdentityApi.getIdToken.mockResolvedValueOnce(tokenNoEnt);
await expect(loadIdentityOwnerRefs(identityApi)).resolves.toEqual([
'user:default/foo',
]);
mockIdentityApi.getUserId.mockReturnValueOnce('foo');
mockIdentityApi.getIdToken.mockResolvedValueOnce(tokenEmptyEnt);
await expect(loadIdentityOwnerRefs(identityApi)).resolves.toEqual([
'user:default/foo',
]);
});
it('returns both the user id and the token parts', async () => {
mockIdentityApi.getUserId.mockReturnValueOnce('foo');
mockIdentityApi.getIdToken.mockResolvedValueOnce(tokenUserEnt);
await expect(loadIdentityOwnerRefs(identityApi)).resolves.toEqual([
'user:default/foo',
'user:default/user1',
]);
mockIdentityApi.getUserId.mockReturnValueOnce('foo');
mockIdentityApi.getIdToken.mockResolvedValueOnce(tokenUserAndGroupEnt);
await expect(loadIdentityOwnerRefs(identityApi)).resolves.toEqual([
'user:default/foo',
'user:default/user1',
'group:default/group1',
]);
});
it('gracefully ignores broken token', async () => {
mockIdentityApi.getUserId.mockReturnValueOnce('foo');
mockIdentityApi.getIdToken.mockResolvedValueOnce('not a jwt');
await expect(loadIdentityOwnerRefs(identityApi)).resolves.toEqual([
'user:default/foo',
]);
});
});
describe('loadCatalogOwnerRefs', () => {
it('loads the first user from the catalog', async () => {
mockCatalogApi.getEntityByName.mockResolvedValueOnce(user2Entity);
await expect(
loadCatalogOwnerRefs(catalogApi, ['user:default/user2']),
).resolves.toEqual(['group:default/group1']);
expect(mockCatalogApi.getEntityByName).toBeCalledWith({
kind: 'user',
namespace: 'default',
name: 'user2',
});
});
it('gracefully handles missing user', async () => {
mockCatalogApi.getEntityByName.mockResolvedValueOnce(undefined);
await expect(
loadCatalogOwnerRefs(catalogApi, ['user:default/user2']),
).resolves.toEqual([]);
expect(mockCatalogApi.getEntityByName).toBeCalledWith({
kind: 'user',
namespace: 'default',
name: 'user2',
});
});
});
describe('useEntityOwnership', () => {
it('matches ownership via token claims', async () => {
mockIdentityApi.getUserId.mockReturnValue('foo');
mockIdentityApi.getIdToken.mockResolvedValue(tokenUserAndGroupEnt);
mockCatalogApi.getEntityByName.mockResolvedValue(undefined);
const { result, waitForValueToChange } = renderHook(
() => useEntityOwnership(),
{
wrapper: Wrapper,
},
);
expect(result.current.loading).toBe(true);
expect(result.current.isOwnedEntity(ownedEntity)).toBe(false);
await waitForValueToChange(() => result.current.loading);
expect(result.current.loading).toBe(false);
expect(result.current.isOwnedEntity(ownedEntity)).toBe(true);
});
it('matches ownership via catalog user entity', async () => {
mockIdentityApi.getUserId.mockReturnValue('user2');
mockIdentityApi.getIdToken.mockResolvedValue(undefined);
mockCatalogApi.getEntityByName.mockResolvedValue(user2Entity);
const { result, waitForValueToChange } = renderHook(
() => useEntityOwnership(),
{
wrapper: Wrapper,
},
);
expect(result.current.loading).toBe(true);
expect(result.current.isOwnedEntity(ownedEntity)).toBe(false);
await waitForValueToChange(() => result.current.loading);
expect(result.current.loading).toBe(false);
expect(result.current.isOwnedEntity(ownedEntity)).toBe(true);
expect(mockCatalogApi.getEntityByName).toBeCalledWith({
kind: 'user',
namespace: 'default',
name: 'user2',
});
});
});
});
@@ -0,0 +1,148 @@
/*
* 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 { CatalogApi } from '@backstage/catalog-client';
import {
Entity,
EntityName,
parseEntityRef,
RELATION_MEMBER_OF,
RELATION_OWNED_BY,
stringifyEntityRef,
} from '@backstage/catalog-model';
import {
IdentityApi,
identityApiRef,
useApi,
} from '@backstage/core-plugin-api';
import jwtDecoder from 'jwt-decode';
import { useMemo } from 'react';
import { useAsync } from 'react-use';
import { catalogApiRef } from '../api';
import { getEntityRelations } from '../utils/getEntityRelations';
// Takes a user ID from the identity, which can be on basically any form, and
// returns an entity ref. E.g. if the input is "foo", it returns
// "user:default/foo" to make sure it's a full ref.
function extendUserId(id: string): string {
try {
const ref = parseEntityRef(id, {
defaultKind: 'User',
defaultNamespace: 'default',
});
return stringifyEntityRef(ref);
} catch {
return id;
}
}
// Takes the relevant parts of the Backstage identity, and translates them into
// a list of entity refs on string form that represent the user's ownership
// connections.
export async function loadIdentityOwnerRefs(
identityApi: IdentityApi,
): Promise<string[]> {
const id = identityApi.getUserId();
const token = await identityApi.getIdToken();
const result: string[] = [];
if (id) {
result.push(extendUserId(id));
}
if (token) {
try {
const decoded = jwtDecoder(token) as any;
if (decoded?.ent) {
[decoded.ent]
.flat()
.filter(x => typeof x === 'string')
.map(x => x.toLocaleLowerCase('en-US'))
.forEach(x => result.push(x));
}
} catch {
// ignore
}
}
return result;
}
// Takes the relevant parts of the User entity corresponding to the Backstage
// identity, and translates them into a list of entity refs on string form that
// represent the user's ownership connections.
export async function loadCatalogOwnerRefs(
catalogApi: CatalogApi,
identityOwnerRefs: string[],
): Promise<string[]> {
const result = new Array<string>();
const primaryUserRef = identityOwnerRefs.find(ref => ref.startsWith('user:'));
if (primaryUserRef) {
const entity = await catalogApi.getEntityByName(
parseEntityRef(primaryUserRef),
);
if (entity) {
const memberOf = getEntityRelations(entity, RELATION_MEMBER_OF, {
kind: 'Group',
});
for (const group of memberOf) {
result.push(stringifyEntityRef(group));
}
}
}
return result;
}
/**
* Returns a function that checks whether the currently signed-in user is an
* owner of a given entity. When the hook is initially mounted, the loading
* flag will be true and the results returned from the function will always be
* false.
*/
export function useEntityOwnership(): {
loading: boolean;
isOwnedEntity: (entity: Entity | EntityName) => boolean;
} {
const identityApi = useApi(identityApiRef);
const catalogApi = useApi(catalogApiRef);
// Trigger load only on mount
const { loading, value: refs } = useAsync(async () => {
const identityRefs = await loadIdentityOwnerRefs(identityApi);
const catalogRefs = await loadCatalogOwnerRefs(catalogApi, identityRefs);
return new Set([...identityRefs, ...catalogRefs]);
}, []);
const isOwnedEntity = useMemo(() => {
const myOwnerRefs = new Set(refs ?? []);
return (entity: Entity | EntityName) => {
const entityOwnerRefs = ('metadata' in entity
? getEntityRelations(entity, RELATION_OWNED_BY)
: [entity]
).map(stringifyEntityRef);
for (const ref of entityOwnerRefs) {
if (myOwnerRefs.has(ref)) {
return true;
}
}
return false;
};
}, [refs]);
return useMemo(() => ({ loading, isOwnedEntity }), [loading, isOwnedEntity]);
}
@@ -55,7 +55,7 @@ describe('CatalogPage', () => {
name: 'Entity1',
},
spec: {
owner: 'tools@example.com',
owner: 'tools',
type: 'service',
},
relations: [
@@ -72,7 +72,7 @@ describe('CatalogPage', () => {
name: 'Entity2',
},
spec: {
owner: 'not-tools@example.com',
owner: 'not-tools',
type: 'service',
},
relations: [
@@ -108,7 +108,8 @@ describe('CatalogPage', () => {
displayName: 'Display Name',
};
const identityApi: Partial<IdentityApi> = {
getUserId: () => 'tools@example.com',
getUserId: () => 'tools',
getIdToken: async () => undefined,
getProfile: () => testProfile,
};
@@ -73,7 +73,11 @@ describe('CatalogTable component', () => {
value={{
entities,
filters: {
user: new UserListFilter('owned', undefined, () => false),
user: new UserListFilter(
'owned',
() => false,
() => false,
),
},
}}
>