Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add readonly view to user management #143438

Merged
merged 4 commits into from
Oct 19, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
watson marked this conversation as resolved.
Show resolved Hide resolved

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