Skip to content

Commit

Permalink
Add readonly view to user management (#143438)
Browse files Browse the repository at this point in the history
Co-authored-by: Kibana Machine <[email protected]>
  • Loading branch information
thomheymann and kibanamachine authored Oct 19, 2022
1 parent 6c5d816 commit 53f3034
Show file tree
Hide file tree
Showing 15 changed files with 548 additions and 175 deletions.
77 changes: 77 additions & 0 deletions x-pack/plugins/security/public/components/use_badge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { renderHook } from '@testing-library/react-hooks';
import React from 'react';

import { coreMock } from '@kbn/core/public/mocks';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';

import type { ChromeBadge } from './use_badge';
import { useBadge } from './use_badge';

describe('useBadge', () => {
it('should add badge to chrome', async () => {
const coreStart = coreMock.createStart();
const badge: ChromeBadge = {
text: 'text',
tooltip: 'text',
};
renderHook(useBadge, {
initialProps: badge,
wrapper: ({ children }) => (
<KibanaContextProvider services={coreStart}>{children}</KibanaContextProvider>
),
});

expect(coreStart.chrome.setBadge).toHaveBeenLastCalledWith(badge);
});

it('should remove badge from chrome on unmount', async () => {
const coreStart = coreMock.createStart();
const badge: ChromeBadge = {
text: 'text',
tooltip: 'text',
};
const { unmount } = renderHook(useBadge, {
initialProps: badge,
wrapper: ({ children }) => (
<KibanaContextProvider services={coreStart}>{children}</KibanaContextProvider>
),
});

expect(coreStart.chrome.setBadge).toHaveBeenLastCalledWith(badge);

unmount();

expect(coreStart.chrome.setBadge).toHaveBeenLastCalledWith();
});

it('should update chrome when badge changes', async () => {
const coreStart = coreMock.createStart();
const badge1: ChromeBadge = {
text: 'text',
tooltip: 'text',
};
const { rerender } = renderHook(useBadge, {
initialProps: badge1,
wrapper: ({ children }) => (
<KibanaContextProvider services={coreStart}>{children}</KibanaContextProvider>
),
});

expect(coreStart.chrome.setBadge).toHaveBeenLastCalledWith(badge1);

const badge2: ChromeBadge = {
text: 'text2',
tooltip: 'text2',
};
rerender(badge2);

expect(coreStart.chrome.setBadge).toHaveBeenLastCalledWith(badge2);
});
});
37 changes: 37 additions & 0 deletions x-pack/plugins/security/public/components/use_badge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import type { DependencyList } from 'react';
import { useEffect } from 'react';

import type { ChromeBadge } from '@kbn/core-chrome-browser';
import type { CoreStart } from '@kbn/core/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';

export type { ChromeBadge };

/**
* Renders a badge in the Kibana chrome.
* @param badge Params of the badge or `undefined` to render no badge.
* @param badge.iconType Icon type of the badge shown in the Kibana chrome.
* @param badge.text Title of tooltip displayed when hovering the badge.
* @param badge.tooltip Description of tooltip displayed when hovering the badge.
* @param deps If present, badge will be updated or removed if the values in the list change.
*/
export function useBadge(
badge: ChromeBadge | undefined,
deps: DependencyList = [badge?.iconType, badge?.text, badge?.tooltip]
) {
const { services } = useKibana<CoreStart>();

useEffect(() => {
if (badge) {
services.chrome.setBadge(badge);
return () => services.chrome.setBadge();
}
}, deps); // eslint-disable-line react-hooks/exhaustive-deps
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { renderHook } from '@testing-library/react-hooks';
import React from 'react';

import { coreMock } from '@kbn/core/public/mocks';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';

import { useCapabilities } from './use_capabilities';

describe('useCapabilities', () => {
it('should return capabilities', async () => {
const coreStart = coreMock.createStart();

const { result } = renderHook(useCapabilities, {
wrapper: ({ children }) => (
<KibanaContextProvider services={coreStart}>{children}</KibanaContextProvider>
),
});

expect(result.current).toEqual(coreStart.application.capabilities);
});

it('should return capabilities scoped by feature', async () => {
const coreStart = coreMock.createStart();
coreStart.application.capabilities = {
...coreStart.application.capabilities,
users: {
save: true,
},
};

const { result } = renderHook(useCapabilities, {
initialProps: 'users',
wrapper: ({ children }) => (
<KibanaContextProvider services={coreStart}>{children}</KibanaContextProvider>
),
});

expect(result.current).toEqual({ save: true });
});
});
32 changes: 32 additions & 0 deletions x-pack/plugins/security/public/components/use_capabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import type { Capabilities } from '@kbn/core-capabilities-common';
import type { CoreStart } from '@kbn/core/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';

type FeatureCapabilities = Capabilities[string];

/**
* Returns capabilities for a specific feature, or alternatively the entire capabilities object.
* @param featureId ID of feature
*/
export function useCapabilities(): Capabilities;
export function useCapabilities<T extends FeatureCapabilities = FeatureCapabilities>(
featureId: string
): T;
export function useCapabilities<T extends FeatureCapabilities = FeatureCapabilities>(
featureId?: string
) {
const { services } = useKibana<CoreStart>();

if (featureId) {
return services.application.capabilities[featureId] as T;
}

return services.application.capabilities;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

import { i18n } from '@kbn/i18n';

import { useBadge } from '../../components/use_badge';
import { useCapabilities } from '../../components/use_capabilities';

export interface ReadonlyBadgeProps {
featureId: string;
tooltip: string;
}

export const ReadonlyBadge = ({ featureId, tooltip }: ReadonlyBadgeProps) => {
const { save } = useCapabilities(featureId);
useBadge(
save
? undefined
: {
iconType: 'glasses',
text: i18n.translate('xpack.security.management.readonlyBadge.text', {
defaultMessage: 'Read only',
}),
tooltip,
},
[save]
);
return null;
};
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,26 @@ jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({
describe('CreateUserPage', () => {
jest.setTimeout(15_000);

const coreStart = coreMock.createStart();
const theme$ = themeServiceMock.createTheme$();
let history = createMemoryHistory({ initialEntries: ['/create'] });
const authc = securityMock.createSetup().authc;

beforeEach(() => {
history = createMemoryHistory({ initialEntries: ['/create'] });
authc.getCurrentUser.mockClear();
coreStart.http.delete.mockClear();
coreStart.http.get.mockClear();
coreStart.http.post.mockClear();
coreStart.application.capabilities = {
...coreStart.application.capabilities,
users: {
save: true,
},
};
});

it('creates user when submitting form and redirects back', async () => {
const coreStart = coreMock.createStart();
const history = createMemoryHistory({ initialEntries: ['/create'] });
const authc = securityMock.createSetup().authc;
coreStart.http.post.mockResolvedValue({});

const { findByRole, findByLabelText } = render(
Expand Down Expand Up @@ -57,11 +71,26 @@ describe('CreateUserPage', () => {
});
});

it('validates form', async () => {
const coreStart = coreMock.createStart();
const history = createMemoryHistory({ initialEntries: ['/create'] });
const authc = securityMock.createSetup().authc;
it('redirects back when viewing with readonly privileges', async () => {
coreStart.application.capabilities = {
...coreStart.application.capabilities,
users: {
save: false,
},
};

render(
<Providers services={coreStart} theme$={theme$} authc={authc} history={history}>
<CreateUserPage />
</Providers>
);

await waitFor(() => {
expect(history.location.pathname).toBe('/');
});
});

it('validates form', async () => {
coreStart.http.get.mockResolvedValueOnce([]);
coreStart.http.get.mockResolvedValueOnce([
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,25 @@

import { EuiPageHeader, EuiSpacer } from '@elastic/eui';
import type { FunctionComponent } from 'react';
import React from 'react';
import React, { useEffect } from 'react';
import { useHistory } from 'react-router-dom';

import { FormattedMessage } from '@kbn/i18n-react';

import { useCapabilities } from '../../../components/use_capabilities';
import { UserForm } from './user_form';

export const CreateUserPage: FunctionComponent = () => {
const history = useHistory();
const readOnly = !useCapabilities('users').save;
const backToUsers = () => history.push('/');

useEffect(() => {
if (readOnly) {
backToUsers();
}
}, [readOnly]); // eslint-disable-line react-hooks/exhaustive-deps

return (
<>
<EuiPageHeader
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ describe('EditUserPage', () => {
coreStart.http.post.mockClear();
coreStart.notifications.toasts.addDanger.mockClear();
coreStart.notifications.toasts.addSuccess.mockClear();
coreStart.application.capabilities = {
...coreStart.application.capabilities,
users: {
save: true,
},
};
});

it('warns when viewing deactivated user', async () => {
Expand Down Expand Up @@ -125,4 +131,29 @@ describe('EditUserPage', () => {

await findByText(/Role .deprecated_role. is deprecated. Use .new_role. instead/i);
});

it('disables form when viewing with readonly privileges', async () => {
coreStart.http.get.mockResolvedValueOnce(userMock);
coreStart.http.get.mockResolvedValueOnce([]);
coreStart.application.capabilities = {
...coreStart.application.capabilities,
users: {
save: false,
},
};

const { findByRole, findAllByRole } = render(
<Providers services={coreStart} theme$={theme$} authc={authc} history={history}>
<EditUserPage username={userMock.username} />
</Providers>
);

await findByRole('button', { name: 'Back to users' });

const fields = await findAllByRole('textbox');
expect(fields.length).toBeGreaterThanOrEqual(1);
fields.forEach((field) => {
expect(field).toHaveProperty('disabled', true);
});
});
});
Loading

0 comments on commit 53f3034

Please sign in to comment.