Implement useEntityOwnership
Signed-off-by: Fredrik Adelöw <freben@gmail.com>
This commit is contained in:
@@ -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, ...);
|
||||
```
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user