[RBAC] - minor UI bug fixes (#1185)
# What this PR does - remove hardcoded references to "Admin" and "Editor". Instead, use the `determineRequiredAuthString` and `generateMissingPermissionMessage` methods from `utils/authorization` which conditionally show the permission or role depending on whether RBAC is enabled or not - fix bug on list users page that always showed "Loading...".  - only show users the user's table if they are allowed, otherwise show them this - RBAC enabled  - RBAC disabled  - `Schedules Editor` role minor issue - Viewers are not allowed to list users by default, so granting schedule edit permission to them won’t allow them to see who rotations are assigned to or assign a rotation to a user. To make this a more straightforward user experience, instead of saying "No options found", we will tell the user that they are missing a particular role/permission to view these users: Before:  After:  ## Which issue(s) this PR fixes - closes #971 ## Checklist - [x] Tests updated - [ ] Documentation added (N/A) - [x] `CHANGELOG.md` updated
This commit is contained in:
parent
a47fc7048e
commit
63e91f896b
15 changed files with 152 additions and 84 deletions
|
|
@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
### Fixed
|
||||
|
||||
- Fix bugs related to creating contact point for Grafana Alerting integration
|
||||
- Fix minor UI bug on OnCall users page where it would idefinitely show a "Loading..." message
|
||||
- Only show OnCall user's table to users that are authorized
|
||||
- Fixed NPE in ScheduleUserDetails component ([#1229](https://github.com/grafana/oncall/issues/1229))
|
||||
|
||||
## v1.1.19 (2023-01-25)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { SortableContainer, SortableElement, SortableHandle } from 'react-sortab
|
|||
import Text from 'components/Text/Text';
|
||||
import RemoteSelect from 'containers/RemoteSelect/RemoteSelect';
|
||||
import { User } from 'models/user/user.types';
|
||||
import { UserActions } from 'utils/authorization';
|
||||
|
||||
import { fromPlainArray, toPlainArray } from './UserGroups.helpers';
|
||||
import { Item } from './UserGroups.types';
|
||||
|
|
@ -114,6 +115,7 @@ const UserGroups = (props: UserGroupsProps) => {
|
|||
onChange={handleUserAdd}
|
||||
showError={showError}
|
||||
maxMenuHeight={150}
|
||||
requiredUserAction={UserActions.UserSettingsWrite}
|
||||
/>
|
||||
<SortableList
|
||||
renderItem={renderItem}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { WithPermissionControl } from 'containers/WithPermissionControl/WithPerm
|
|||
import { ApiToken } from 'models/api_token/api_token.types';
|
||||
import { WithStoreProps } from 'state/types';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
import { isUserActionAllowed, UserActions } from 'utils/authorization';
|
||||
import { generateMissingPermissionMessage, isUserActionAllowed, UserActions } from 'utils/authorization';
|
||||
|
||||
import ApiTokenForm from './ApiTokenForm';
|
||||
|
||||
|
|
@ -21,6 +21,7 @@ import styles from './ApiTokenSettings.module.css';
|
|||
const cx = cn.bind(styles);
|
||||
|
||||
const MAX_TOKENS_PER_USER = 5;
|
||||
const REQUIRED_PERMISSION_TO_VIEW = UserActions.APIKeysWrite;
|
||||
|
||||
interface ApiTokensProps extends WithStoreProps {}
|
||||
|
||||
|
|
@ -67,6 +68,15 @@ class ApiTokens extends React.Component<ApiTokensProps, any> {
|
|||
},
|
||||
];
|
||||
|
||||
const authorizedToViewAPIKeys = isUserActionAllowed(REQUIRED_PERMISSION_TO_VIEW);
|
||||
|
||||
let emptyText = 'Loading...';
|
||||
if (!authorizedToViewAPIKeys) {
|
||||
emptyText = `${generateMissingPermissionMessage(REQUIRED_PERMISSION_TO_VIEW)} to be able to view API tokens.`;
|
||||
} else if (apiTokens) {
|
||||
emptyText = 'No tokens found';
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GTable
|
||||
|
|
@ -92,13 +102,7 @@ class ApiTokens extends React.Component<ApiTokensProps, any> {
|
|||
className="api-keys"
|
||||
showHeader={!isMobile}
|
||||
data={apiTokens}
|
||||
emptyText={
|
||||
isUserActionAllowed(UserActions.APIKeysWrite)
|
||||
? apiTokens
|
||||
? 'No tokens found'
|
||||
: 'Loading...'
|
||||
: 'API tokens are available only for users with Admin permissions'
|
||||
}
|
||||
emptyText={emptyText}
|
||||
columns={columns}
|
||||
/>
|
||||
{showCreateTokenModal && (
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import React, { useCallback, useEffect, useMemo, useReducer } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useReducer, useState } from 'react';
|
||||
|
||||
import { SelectableValue } from '@grafana/data';
|
||||
import { AsyncMultiSelect, AsyncSelect } from '@grafana/ui';
|
||||
import { inject, observer } from 'mobx-react';
|
||||
|
||||
import { makeRequest } from 'network';
|
||||
import { makeRequest, isNetworkError } from 'network';
|
||||
import { UserAction, generateMissingPermissionMessage } from 'utils/authorization';
|
||||
|
||||
interface RemoteSelectProps {
|
||||
autoFocus?: boolean;
|
||||
|
|
@ -24,6 +25,7 @@ interface RemoteSelectProps {
|
|||
getOptionLabel?: (item: SelectableValue) => React.ReactNode;
|
||||
showError?: boolean;
|
||||
maxMenuHeight?: number;
|
||||
requiredUserAction?: UserAction;
|
||||
}
|
||||
|
||||
const RemoteSelect = inject('store')(
|
||||
|
|
@ -45,9 +47,12 @@ const RemoteSelect = inject('store')(
|
|||
openMenuOnFocus = true,
|
||||
showError,
|
||||
maxMenuHeight,
|
||||
requiredUserAction,
|
||||
} = props;
|
||||
|
||||
const getOptions = (data: any[]) => {
|
||||
const [noOptionsMessage, setNoOptionsMessage] = useState<string>('No options found');
|
||||
|
||||
const getOptions = (data: any[]): SelectableValue[] => {
|
||||
return data.map((option: any) => ({
|
||||
value: option[valueField],
|
||||
label: option[fieldToShow],
|
||||
|
|
@ -62,17 +67,23 @@ const RemoteSelect = inject('store')(
|
|||
|
||||
const [options, setOptions] = useReducer(mergeOptions, []);
|
||||
|
||||
useEffect(() => {
|
||||
makeRequest(href, {}).then((data) => {
|
||||
setOptions(getOptions(data.results || data));
|
||||
});
|
||||
const loadOptionsCallback = useCallback(async (query?: string): Promise<SelectableValue[]> => {
|
||||
try {
|
||||
const data = await makeRequest(href, { params: { search: query } });
|
||||
const options = getOptions(data.results || data);
|
||||
setOptions(options);
|
||||
|
||||
return options;
|
||||
} catch (e) {
|
||||
if (isNetworkError(e) && e.response.status === 403 && requiredUserAction) {
|
||||
setNoOptionsMessage(generateMissingPermissionMessage(requiredUserAction));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadOptionsCallback = useCallback((query: string) => {
|
||||
return makeRequest(href, { params: { search: query } }).then((data) => {
|
||||
setOptions(getOptions(data.results || data));
|
||||
return getOptions(data.results || data);
|
||||
});
|
||||
useEffect(() => {
|
||||
loadOptionsCallback();
|
||||
}, []);
|
||||
|
||||
const onChangeCallback = useCallback(
|
||||
|
|
@ -119,6 +130,7 @@ const RemoteSelect = inject('store')(
|
|||
defaultOptions={options}
|
||||
loadOptions={loadOptionsCallback}
|
||||
getOptionLabel={getOptionLabel}
|
||||
noOptionsMessage={noOptionsMessage}
|
||||
invalid={showError}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -133,7 +133,6 @@ export const TabsContent = observer(({ id, activeTab, onTabChange, isDesktopOrLa
|
|||
) : (
|
||||
<PhoneVerification userPk={id} />
|
||||
))}
|
||||
{/* TODO: we should probably hide this tab when a user (ie. Admin) is viewing the user settings for another user. Would it make sense for an Admin to be able to link their mobile app to another user's profile */}
|
||||
{activeTab === UserSettingsTab.MobileAppConnection && <MobileAppConnection userPk={id} />}
|
||||
{activeTab === UserSettingsTab.SlackInfo && <SlackTab />}
|
||||
{activeTab === UserSettingsTab.TelegramInfo && <TelegramInfo />}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ interface RequestConfig {
|
|||
validateStatus?: (status: number) => boolean;
|
||||
}
|
||||
|
||||
export const isNetworkError = axios.isAxiosError;
|
||||
|
||||
export const makeRequest = async <RT = any>(path: string, config: RequestConfig) => {
|
||||
const { method = 'GET', params, data, validateStatus } = config;
|
||||
|
||||
|
|
|
|||
|
|
@ -127,7 +127,6 @@ export const pages: { [id: string]: PageDefinition } = [
|
|||
icon: 'table',
|
||||
id: 'live-settings',
|
||||
text: 'Env Variables',
|
||||
role: 'Admin',
|
||||
hideFromTabsFn: (store: RootBaseStore) => {
|
||||
const hasLiveSettings = store.hasFeature(AppFeature.LiveSettings);
|
||||
return isTopNavbar() || !hasLiveSettings;
|
||||
|
|
@ -139,7 +138,6 @@ export const pages: { [id: string]: PageDefinition } = [
|
|||
icon: 'cloud',
|
||||
id: 'cloud',
|
||||
text: 'Cloud',
|
||||
role: 'Admin',
|
||||
hideFromTabsFn: (store: RootBaseStore) => {
|
||||
const hasCloudFeature = store.hasFeature(AppFeature.CloudConnection);
|
||||
return isTopNavbar() || !hasCloudFeature;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { WithStoreProps } from 'state/types';
|
|||
import { useStore } from 'state/useStore';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
import { openErrorNotification } from 'utils';
|
||||
import { determineRequiredAuthString, UserActions } from 'utils/authorization';
|
||||
import { PLUGIN_ROOT } from 'utils/consts';
|
||||
|
||||
import styles from './CloudPage.module.css';
|
||||
|
|
@ -261,10 +262,9 @@ const CloudPage = observer((props: CloudPageProps) => {
|
|||
|
||||
<div style={{ width: '100%' }}>
|
||||
<Text type="secondary">
|
||||
{/* TODO: should probably update this message? */}
|
||||
{
|
||||
'Ask your users to sign up in Grafana Cloud, verify phone number and feel free to set up SMS & phone call notifications in personal settings! Only users with Admin or Editor role will be synced.'
|
||||
}
|
||||
{`Ask your users to sign up in Grafana Cloud, verify phone number and feel free to set up SMS & phone call notifications in personal settings! Users must have ${determineRequiredAuthString(
|
||||
UserActions.NotificationsRead
|
||||
)} in order to be synced.`}
|
||||
</Text>
|
||||
|
||||
<GTable
|
||||
|
|
@ -280,15 +280,9 @@ const CloudPage = observer((props: CloudPageProps) => {
|
|||
{matched_users_count === 1 ? '' : 's'}
|
||||
{` matched between OSS and Cloud OnCall`}
|
||||
</Text>
|
||||
{syncingUsers ? (
|
||||
<Button variant="primary" onClick={syncUsers} icon="sync" disabled>
|
||||
Syncing...
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="primary" onClick={syncUsers} icon="sync">
|
||||
Sync users (Editors and Admins)
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="primary" onClick={syncUsers} icon="sync" disabled={syncingUsers}>
|
||||
{syncingUsers ? 'Syncing...' : 'Sync users'}
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import { User as UserType } from 'models/user/user.types';
|
|||
import { PageProps, WithStoreProps } from 'state/types';
|
||||
import { withMobXProviderContext } from 'state/withStore';
|
||||
import LocationHelper from 'utils/LocationHelper';
|
||||
import { isUserActionAllowed, UserActions } from 'utils/authorization';
|
||||
import { generateMissingPermissionMessage, isUserActionAllowed, UserActions } from 'utils/authorization';
|
||||
import { PLUGIN_ROOT } from 'utils/consts';
|
||||
|
||||
import { getUserRowClassNameFn } from './Users.helpers';
|
||||
|
|
@ -35,6 +35,7 @@ const cx = cn.bind(styles);
|
|||
interface UsersProps extends WithStoreProps, PageProps, RouteComponentProps<{ id: string }> {}
|
||||
|
||||
const ITEMS_PER_PAGE = 100;
|
||||
const REQUIRED_PERMISSION_TO_VIEW_USERS = UserActions.UserSettingsWrite;
|
||||
|
||||
interface UsersState extends PageBaseState {
|
||||
page: number;
|
||||
|
|
@ -43,6 +44,7 @@ interface UsersState extends PageBaseState {
|
|||
usersFilters?: {
|
||||
searchTerm: string;
|
||||
};
|
||||
initialUsersLoaded: boolean;
|
||||
}
|
||||
|
||||
@observer
|
||||
|
|
@ -56,10 +58,9 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
},
|
||||
|
||||
errorData: initErrorDataState(),
|
||||
initialUsersLoaded: false,
|
||||
};
|
||||
|
||||
initialUsersLoaded = false;
|
||||
|
||||
async componentDidMount() {
|
||||
const {
|
||||
query: { p },
|
||||
|
|
@ -74,18 +75,19 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
const { usersFilters, page } = this.state;
|
||||
const { userStore } = store;
|
||||
|
||||
if (!isUserActionAllowed(UserActions.UserSettingsWrite)) {
|
||||
if (!isUserActionAllowed(REQUIRED_PERMISSION_TO_VIEW_USERS)) {
|
||||
return;
|
||||
}
|
||||
|
||||
LocationHelper.update({ p: page }, 'partial');
|
||||
return await userStore.updateItems(usersFilters, page);
|
||||
await userStore.updateItems(usersFilters, page);
|
||||
|
||||
this.setState({ initialUsersLoaded: true });
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps: UsersProps) {
|
||||
if (!this.initialUsersLoaded && isUserActionAllowed(UserActions.UserSettingsWrite)) {
|
||||
if (!this.state.initialUsersLoaded) {
|
||||
this.updateUsers();
|
||||
this.initialUsersLoaded = true;
|
||||
}
|
||||
|
||||
if (prevProps.match.params.id !== this.props.match.params.id) {
|
||||
|
|
@ -117,7 +119,7 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
};
|
||||
|
||||
render() {
|
||||
const { usersFilters, userPkToEdit, page, errorData } = this.state;
|
||||
const { usersFilters, userPkToEdit, page, errorData, initialUsersLoaded } = this.state;
|
||||
const {
|
||||
store,
|
||||
match: {
|
||||
|
|
@ -165,6 +167,8 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
|
||||
const { count, results } = userStore.getSearchResult();
|
||||
|
||||
const authorizedToViewUsers = isUserActionAllowed(REQUIRED_PERMISSION_TO_VIEW_USERS);
|
||||
|
||||
return (
|
||||
<PageErrorHandlingWrapper
|
||||
errorData={errorData}
|
||||
|
|
@ -182,10 +186,12 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
<LegacyNavHeading>
|
||||
<Text.Title level={3}>Users</Text.Title>
|
||||
</LegacyNavHeading>
|
||||
<Text type="secondary">
|
||||
To manage permissions or add users, please visit{' '}
|
||||
<a href="/org/users">Grafana user management</a>
|
||||
</Text>
|
||||
{authorizedToViewUsers && (
|
||||
<Text type="secondary">
|
||||
To manage permissions or add users, please visit{' '}
|
||||
<a href="/org/users">Grafana user management</a>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<PluginLink query={{ page: 'users', id: 'me' }}>
|
||||
|
|
@ -194,7 +200,7 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
</Button>
|
||||
</PluginLink>
|
||||
</div>
|
||||
{isUserActionAllowed(UserActions.UserSettingsRead) ? (
|
||||
{authorizedToViewUsers ? (
|
||||
<>
|
||||
<div className={cx('user-filters-container')}>
|
||||
<UsersFilters
|
||||
|
|
@ -213,7 +219,7 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
</div>
|
||||
|
||||
<GTable
|
||||
emptyText={results ? 'No users found' : 'Loading...'}
|
||||
emptyText={initialUsersLoaded ? 'No users found' : 'Loading...'}
|
||||
rowKey="pk"
|
||||
data={results}
|
||||
columns={columns}
|
||||
|
|
@ -230,8 +236,9 @@ class Users extends React.Component<UsersProps, UsersState> {
|
|||
/* @ts-ignore */
|
||||
title={
|
||||
<>
|
||||
You don't have enough permissions to view other users because you are not Admin.{' '}
|
||||
<PluginLink query={{ page: 'users', id: 'me' }}>Click here</PluginLink> to open your profile
|
||||
{generateMissingPermissionMessage(REQUIRED_PERMISSION_TO_VIEW_USERS)} to be able to view OnCall
|
||||
users. <PluginLink query={{ page: 'users', id: 'me' }}>Click here</PluginLink> to open your
|
||||
profile
|
||||
</>
|
||||
}
|
||||
severity="info"
|
||||
|
|
|
|||
|
|
@ -535,7 +535,7 @@
|
|||
{
|
||||
"role": {
|
||||
"name": "User Settings Reader",
|
||||
"description": "Read-only access to OnCall User Settings",
|
||||
"description": "Read-only access to own OnCall User Settings",
|
||||
"permissions": [
|
||||
{ "action": "plugins.app:access", "scope": "plugins:id:grafana-oncall-app" },
|
||||
{ "action": "grafana-oncall-app.user-settings:read" }
|
||||
|
|
@ -546,7 +546,7 @@
|
|||
{
|
||||
"role": {
|
||||
"name": "User Settings Editor",
|
||||
"description": "Read/write access to own OnCall User Settings",
|
||||
"description": "Read/write access to own OnCall User Settings + ability to view basic information about other OnCall users",
|
||||
"permissions": [
|
||||
{ "action": "plugins.app:access", "scope": "plugins:id:grafana-oncall-app" },
|
||||
{ "action": "grafana-oncall-app.user-settings:read" },
|
||||
|
|
|
|||
|
|
@ -34,19 +34,19 @@ exports[`PluginState.generateUnknownErrorMsg it returns the proper error message
|
|||
Refresh your page and try again, or try removing your plugin configuration and reconfiguring."
|
||||
`;
|
||||
|
||||
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a 400 AxiosError properly - has custom error message: false 1`] = `
|
||||
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a 400 network error properly - has custom error message: false 1`] = `
|
||||
"An unknown error occured when trying to install the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct (NOTE: your OnCall API URL is currently being taken from process.env of your UI)?
|
||||
Refresh your page and try again, or try removing your plugin configuration and reconfiguring."
|
||||
`;
|
||||
|
||||
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a 400 AxiosError properly - has custom error message: true 1`] = `"ohhhh nooo an error"`;
|
||||
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a 400 network error properly - has custom error message: true 1`] = `"ohhhh nooo an error"`;
|
||||
|
||||
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a non-400 AxiosError properly - status code: 409 1`] = `
|
||||
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a non-400 network error properly - status code: 409 1`] = `
|
||||
"An unknown error occured when trying to install the plugin. Are you sure that your OnCall API URL, http://hello.com, is correct (NOTE: your OnCall API URL is currently being taken from process.env of your UI)?
|
||||
Refresh your page and try again, or try removing your plugin configuration and reconfiguring."
|
||||
`;
|
||||
|
||||
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a non-400 AxiosError properly - status code: 502 1`] = `
|
||||
exports[`PluginState.getHumanReadableErrorFromOnCallError it handles a non-400 network error properly - status code: 502 1`] = `
|
||||
"Could not communicate with your OnCall API at http://hello.com (NOTE: your OnCall API URL is currently being taken from process.env of your UI).
|
||||
Validate that the URL is correct, your OnCall API is running, and that it is accessible from your Grafana instance."
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import axios from 'axios';
|
||||
import { OnCallAppPluginMeta, OnCallPluginMetaJSONData, OnCallPluginMetaSecureJSONData } from 'types';
|
||||
|
||||
import { makeRequest } from 'network';
|
||||
import { makeRequest, isNetworkError } from 'network';
|
||||
import FaroHelper from 'utils/faro';
|
||||
|
||||
export type UpdateGrafanaPluginSettingsProps = {
|
||||
|
|
@ -76,7 +75,7 @@ class PluginState {
|
|||
);
|
||||
const consoleMsg = `occured while trying to ${installationVerb} the plugin w/ the OnCall backend`;
|
||||
|
||||
if (axios.isAxiosError(e)) {
|
||||
if (isNetworkError(e)) {
|
||||
const { status: statusCode } = e.response;
|
||||
|
||||
console.warn(`An HTTP related error ${consoleMsg}`, e.response);
|
||||
|
|
@ -100,7 +99,7 @@ class PluginState {
|
|||
errorMsg = unknownErrorMsg;
|
||||
}
|
||||
} else {
|
||||
// a non-axios related error occured.. this scenario shouldn't occur...
|
||||
// a non-network related error occured.. this scenario shouldn't occur...
|
||||
console.warn(`An unknown error ${consoleMsg}`, e);
|
||||
errorMsg = unknownErrorMsg;
|
||||
}
|
||||
|
|
@ -115,12 +114,12 @@ class PluginState {
|
|||
): string => {
|
||||
let errorMsg: string;
|
||||
|
||||
if (axios.isAxiosError(e)) {
|
||||
if (isNetworkError(e)) {
|
||||
// The user likely put in a bogus URL for the OnCall API URL
|
||||
console.warn('An HTTP related error occured while trying to provision the plugin w/ Grafana', e.response);
|
||||
errorMsg = this.generateInvalidOnCallApiURLErrorMsg(onCallApiUrl, onCallApiUrlIsConfiguredThroughEnvVar);
|
||||
} else {
|
||||
// a non-axios related error occured.. this scenario shouldn't occur...
|
||||
// a non-network related error occured.. this scenario shouldn't occur...
|
||||
console.warn('An unknown error occured while trying to provision the plugin w/ Grafana', e);
|
||||
errorMsg = this.generateUnknownErrorMsg(onCallApiUrl, installationVerb, onCallApiUrlIsConfiguredThroughEnvVar);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { makeRequest as makeRequestOriginal } from 'network';
|
||||
import { makeRequest as makeRequestOriginal, isNetworkError as isNetworkErrorOriginal } from 'network';
|
||||
|
||||
import PluginState, { InstallationVerb, PluginSyncStatusResponse, UpdateGrafanaPluginSettingsProps } from './';
|
||||
|
||||
const makeRequest = makeRequestOriginal as jest.Mock<ReturnType<typeof makeRequestOriginal>>;
|
||||
const isNetworkError = isNetworkErrorOriginal as unknown as jest.Mock<ReturnType<typeof isNetworkErrorOriginal>>;
|
||||
|
||||
jest.mock('network');
|
||||
|
||||
|
|
@ -13,7 +14,7 @@ afterEach(() => {
|
|||
const ONCALL_BASE_URL = '/plugin';
|
||||
const GRAFANA_PLUGIN_SETTINGS_URL = '/api/plugins/grafana-oncall-app/settings';
|
||||
|
||||
const generateMockAxiosError = (status: number, data = {}) => ({ isAxiosError: true, response: { status, ...data } });
|
||||
const generateMockNetworkError = (status: number, data = {}) => ({ response: { status, ...data } });
|
||||
|
||||
describe('PluginState.generateOnCallApiUrlConfiguredThroughEnvVarMsg', () => {
|
||||
test.each([true, false])(
|
||||
|
|
@ -54,10 +55,12 @@ describe('PluginState.getHumanReadableErrorFromOnCallError', () => {
|
|||
console.warn = () => {};
|
||||
});
|
||||
|
||||
test.each([502, 409])('it handles a non-400 AxiosError properly - status code: %s', (status) => {
|
||||
test.each([502, 409])('it handles a non-400 network error properly - status code: %s', (status) => {
|
||||
isNetworkError.mockReturnValueOnce(true);
|
||||
|
||||
expect(
|
||||
PluginState.getHumanReadableErrorFromOnCallError(
|
||||
generateMockAxiosError(status),
|
||||
generateMockNetworkError(status),
|
||||
'http://hello.com',
|
||||
'install',
|
||||
true
|
||||
|
|
@ -66,19 +69,23 @@ describe('PluginState.getHumanReadableErrorFromOnCallError', () => {
|
|||
});
|
||||
|
||||
test.each([true, false])(
|
||||
'it handles a 400 AxiosError properly - has custom error message: %s',
|
||||
'it handles a 400 network error properly - has custom error message: %s',
|
||||
(hasCustomErrorMessage) => {
|
||||
const axiosError = generateMockAxiosError(400) as any;
|
||||
isNetworkError.mockReturnValueOnce(true);
|
||||
|
||||
const networkError = generateMockNetworkError(400) as any;
|
||||
if (hasCustomErrorMessage) {
|
||||
axiosError.response.data = { error: 'ohhhh nooo an error' };
|
||||
networkError.response.data = { error: 'ohhhh nooo an error' };
|
||||
}
|
||||
expect(
|
||||
PluginState.getHumanReadableErrorFromOnCallError(axiosError, 'http://hello.com', 'install', true)
|
||||
PluginState.getHumanReadableErrorFromOnCallError(networkError, 'http://hello.com', 'install', true)
|
||||
).toMatchSnapshot();
|
||||
}
|
||||
);
|
||||
|
||||
test('it handles an unknown error properly', () => {
|
||||
isNetworkError.mockReturnValueOnce(false);
|
||||
|
||||
expect(
|
||||
PluginState.getHumanReadableErrorFromOnCallError(new Error('asdfasdf'), 'http://hello.com', 'install', true)
|
||||
).toMatchSnapshot();
|
||||
|
|
@ -86,23 +93,27 @@ describe('PluginState.getHumanReadableErrorFromOnCallError', () => {
|
|||
});
|
||||
|
||||
describe('PluginState.getHumanReadableErrorFromGrafanaProvisioningError', () => {
|
||||
test.each([true, false])('it handles an error properly', (isAxiosError) => {
|
||||
beforeEach(() => {
|
||||
console.warn = () => {};
|
||||
});
|
||||
|
||||
test.each([true, false])('it handles an error properly - network error: %s', (networkError) => {
|
||||
const onCallApiUrl = 'http://hello.com';
|
||||
const installationVerb = 'install';
|
||||
const onCallApiUrlIsConfiguredThroughEnvVar = true;
|
||||
const axiosError = generateMockAxiosError(400);
|
||||
const nonAxiosError = new Error('oh noooo');
|
||||
const error = isAxiosError ? axiosError : nonAxiosError;
|
||||
const error = networkError ? generateMockNetworkError(400) : new Error('oh noooo');
|
||||
|
||||
const mockGenerateInvalidOnCallApiURLErrorMsgResult = 'asdadslkjfkjlsd';
|
||||
const mockGenerateUnknownErrorMsgResult = 'asdadslkjfkjlsd';
|
||||
|
||||
isNetworkError.mockReturnValueOnce(networkError);
|
||||
|
||||
PluginState.generateInvalidOnCallApiURLErrorMsg = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(mockGenerateInvalidOnCallApiURLErrorMsgResult);
|
||||
PluginState.generateUnknownErrorMsg = jest.fn().mockReturnValueOnce(mockGenerateUnknownErrorMsgResult);
|
||||
|
||||
const expectedErrorMsg = isAxiosError
|
||||
const expectedErrorMsg = networkError
|
||||
? mockGenerateInvalidOnCallApiURLErrorMsgResult
|
||||
: mockGenerateUnknownErrorMsgResult;
|
||||
|
||||
|
|
@ -115,7 +126,7 @@ describe('PluginState.getHumanReadableErrorFromGrafanaProvisioningError', () =>
|
|||
)
|
||||
).toEqual(expectedErrorMsg);
|
||||
|
||||
if (isAxiosError) {
|
||||
if (networkError) {
|
||||
expect(PluginState.generateInvalidOnCallApiURLErrorMsg).toHaveBeenCalledTimes(1);
|
||||
expect(PluginState.generateInvalidOnCallApiURLErrorMsg).toHaveBeenCalledWith(
|
||||
onCallApiUrl,
|
||||
|
|
|
|||
|
|
@ -63,6 +63,32 @@ describe('isUserActionAllowed', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('determineRequiredAuthString', () => {
|
||||
const testPerm = auth.UserActions.UserSettingsRead;
|
||||
|
||||
test.each([
|
||||
[true, `${testPerm.permission} permission`],
|
||||
[false, `${testPerm.fallbackMinimumRoleRequired} role`],
|
||||
])('RBAC enabled: %s', (rbacEnabled, expected) => {
|
||||
config.featureToggles.accessControlOnCall = rbacEnabled;
|
||||
|
||||
expect(auth.determineRequiredAuthString(testPerm)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateMissingPermissionMessage', () => {
|
||||
const testPerm = auth.UserActions.UserSettingsRead;
|
||||
|
||||
test.each([
|
||||
[true, `You are missing the ${testPerm.permission} permission`],
|
||||
[false, `You are missing the ${testPerm.fallbackMinimumRoleRequired} role`],
|
||||
])('RBAC enabled: %s', (rbacEnabled, expected) => {
|
||||
config.featureToggles.accessControlOnCall = rbacEnabled;
|
||||
|
||||
expect(auth.generateMissingPermissionMessage(testPerm)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generatePermissionString', () => {
|
||||
test('it properly builds permission strings with prefixes', () => {
|
||||
expect(auth.generatePermissionString(auth.Resource.API_KEYS, auth.Action.READ, true)).toEqual(
|
||||
|
|
|
|||
|
|
@ -90,12 +90,24 @@ export const userHasMinimumRequiredRole = (minimumRoleRequired: OrgRole): boolea
|
|||
*
|
||||
* As a fallback (second argument), for cases where RBAC is not enabled for a grafana instance, rely on basic roles
|
||||
*/
|
||||
export const isUserActionAllowed = ({ permission, fallbackMinimumRoleRequired }: UserAction): boolean => {
|
||||
if (config.featureToggles.accessControlOnCall) {
|
||||
return !!contextSrv.user.permissions?.[permission];
|
||||
}
|
||||
return userHasMinimumRequiredRole(fallbackMinimumRoleRequired);
|
||||
};
|
||||
export const isUserActionAllowed = ({ permission, fallbackMinimumRoleRequired }: UserAction): boolean =>
|
||||
config.featureToggles.accessControlOnCall
|
||||
? !!contextSrv.user.permissions?.[permission]
|
||||
: userHasMinimumRequiredRole(fallbackMinimumRoleRequired);
|
||||
|
||||
/**
|
||||
* Given a `UserAction`, returns the permission or fallback-role, prefixed with "permission" or "role" respectively
|
||||
* depending on whether or not RBAC is enabled/disabled
|
||||
*/
|
||||
export const determineRequiredAuthString = ({ permission, fallbackMinimumRoleRequired }: UserAction): string =>
|
||||
config.featureToggles.accessControlOnCall ? `${permission} permission` : `${fallbackMinimumRoleRequired} role`;
|
||||
|
||||
/**
|
||||
* Can be used to generate a user-friendly message about which permission is missing. Method is RBAC-aware
|
||||
* and shows user the missing permission/basic-role depending on whether or not RBAC is enabled/disabled
|
||||
*/
|
||||
export const generateMissingPermissionMessage = (permission: UserAction): string =>
|
||||
`You are missing the ${determineRequiredAuthString(permission)}`;
|
||||
|
||||
export const generatePermissionString = (resource: Resource, action: Action, includePrefix: boolean): string =>
|
||||
`${includePrefix ? `${ONCALL_PERMISSION_PREFIX}.` : ''}${resource}:${action}`;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue