add a couple of tests for users screen (#2612)

# What this PR does

There are the following tests added:

 - admin is allowed to edit other profiles
 - editor is not allowed to edit other profiles

## Which issue(s) this PR fixes

https://github.com/grafana/oncall/issues/1586

## Checklist

- [x] Unit, integration, and e2e (if applicable) tests updated
- [ ] Documentation added (or `pr:no public docs` PR label added if not
required)
- [ ] `CHANGELOG.md` updated (or `pr:no changelog` PR label added if not
required)

---------

Co-authored-by: Rares Mardare <rares.mardare@grafana.com>
This commit is contained in:
Maxim Mordasov 2023-08-02 15:42:48 +03:00 committed by GitHub
parent 8eacbf2500
commit 36f9851003
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 396 additions and 285 deletions

View file

@ -479,7 +479,7 @@ jobs:
GRAFANA_VIEWER_PASSWORD: viewer
MAILSLURP_API_KEY: ${{ secrets.MAILSLURP_API_KEY }}
working-directory: ./grafana-plugin
run: yarn test:integration
run: yarn test:e2e
# spit out the engine, celery, and grafana logs, if the the e2e tests have failed
# can be helpful for debugging failing tests

View file

@ -7,7 +7,7 @@
- [Enabling RBAC for OnCall for local development](#enabling-rbac-for-oncall-for-local-development)
- [Django Silk Profiling](#django-silk-profiling)
- [Running backend services outside Docker](#running-backend-services-outside-docker)
- [UI Integration Tests](#ui-integration-tests)
- [UI E2E Tests](#ui-e2e-tests)
- [Useful `make` commands](#useful-make-commands)
- [Setting environment variables](#setting-environment-variables)
- [Slack application setup](#slack-application-setup)
@ -189,7 +189,7 @@ By default everything runs inside Docker. If you would like to run the backend s
- `make run-backend-server` - runs the HTTP server
- `make run-backend-celery` - runs Celery workers
## UI Integration Tests
## UI E2E Tests
We've developed a suite of "end-to-end" integration tests using [Playwright](https://playwright.dev/). These tests
are run on pull request CI builds. New features should ideally include a new/modified integration test.
@ -198,10 +198,10 @@ To run these tests locally simply do the following:
```bash
npx playwright install # install playwright dependencies
cp ./grafana-plugin/integration-tests/.env.example ./grafana-plugin/integration-tests/.env
cp ./grafana-plugin/e2e-tests/.env.example ./grafana-plugin/e2e-tests/.env
# you may need to tweak the values in ./grafana-plugin/.env according to your local setup
cd grafana-plugin
yarn test:integration
yarn test:e2e
```
## Useful `make` commands

View file

@ -19,4 +19,4 @@ frontend_enterprise
/test-results/
/playwright-report/
/playwright/.cache/
/integration-tests/storageState.json
/e2e-tests/storageState.json

View file

@ -0,0 +1,120 @@
import { test, expect, Page } from '../fixtures';
import { goToOnCallPage } from '../utils/navigation';
test.describe('Users screen actions', () => {
test("Admin is allowed to edit other users' profile", async ({ adminRolePage }) => {
await _testButtons(adminRolePage.page, 'button.edit-other-profile-button[disabled]');
});
test('Admin is allowed to view the list of users', async ({ adminRolePage }) => {
await _viewUsers(adminRolePage.page);
});
test('Viewer is not allowed to view the list of users', async ({ viewerRolePage }) => {
await _viewUsers(viewerRolePage.page, false);
});
test('Viewer cannot access restricted tabs from View My Profile', async ({ viewerRolePage }) => {
const { page } = viewerRolePage;
await _accessProfileTabs(page, ['tab-mobile-app', 'tab-phone-verification', 'tab-slack', 'tab-telegram'], false);
});
test('Editor is allowed to view the list of users', async ({ editorRolePage }) => {
await _viewUsers(editorRolePage.page);
});
test("Editor cannot view other users' data", async ({ editorRolePage }) => {
const { page } = editorRolePage;
await goToOnCallPage(page, 'users');
await page.waitForSelector('.current-user');
// check if these fields are Masked or Not (******)
const fieldIds = ['users-email', 'users-phone-number'];
for (let i = 0; i < fieldIds.length - 1; ++i) {
const currentUsername = page.locator(`.current-user [data-testid="${fieldIds[i]}"]`);
expect((await currentUsername.all()).length).toBe(1); // match for current user
(await currentUsername.all()).forEach((val) => expect(val).not.toHaveText('******'));
const otherUsername = page.locator(`.other-user [data-testid="${fieldIds[i]}"]`);
expect((await otherUsername.all()).length).toBeGreaterThan(1); // match for other users (>= 1)
(await otherUsername.all()).forEach((val) => expect(val).toHaveText('******'));
}
});
test('Editor can access tabs from View My Profile', async ({ editorRolePage }) => {
const { page } = editorRolePage;
// the other tabs depend on Cloud, skip for now
await _accessProfileTabs(page, ['tab-slack', 'tab-telegram'], true);
});
test("Editor is not allowed to edit other users' profile", async ({ editorRolePage }) => {
await _testButtons(editorRolePage.page, 'button.edit-other-profile-button:not([disabled])');
});
/*
* Helper methods
*/
async function _testButtons(page: Page, selector: string) {
await goToOnCallPage(page, 'users');
const usersTableElement = page.getByTestId('users-table');
await usersTableElement.waitFor({ state: 'visible' });
const buttonsList = await page.locator(selector);
expect(buttonsList).toHaveCount(0);
}
async function _accessProfileTabs(page: Page, tabs: string[], hasAccess: boolean) {
await goToOnCallPage(page, 'users');
await page.getByTestId('users-view-my-profile').click();
// the next queries could or could not resolve
// therefore we wait a generic 1000ms duration and assert based on visibility
await page.waitForTimeout(1000);
for (let i = 0; i < tabs.length - 1; ++i) {
const tab = page.getByTestId(tabs[i]);
if (await tab.isVisible()) {
await tab.click();
const query = page.getByText(
'You do not have permission to perform this action. Ask an admin to upgrade your permissions.'
);
if (hasAccess) {
await expect(query).toBeHidden();
} else {
await expect(query).toBeVisible();
}
}
}
}
async function _viewUsers(page: Page, isAllowedToView = true): Promise<void> {
await goToOnCallPage(page, 'users');
if (isAllowedToView) {
const usersTableElement = page.getByTestId('users-table');
await usersTableElement.waitFor({ state: 'visible' });
const userRowsContext = await usersTableElement.locator('tbody > tr').allTextContents();
expect(userRowsContext.length).toBeGreaterThan(0);
} else {
const missingPermissionsMessageElement = page.getByTestId('view-users-missing-permission-message');
await missingPermissionsMessageElement.waitFor({ state: 'visible' });
const missingPermissionMessage = await missingPermissionsMessageElement.textContent();
expect(missingPermissionMessage).toMatch(/You are missing the .* to be able to view OnCall users/);
}
}
});

View file

@ -1,34 +0,0 @@
import { test, expect, Page } from '../fixtures';
import { goToOnCallPage } from '../utils/navigation';
test.describe('view list of users', () => {
const testFlow = async (page: Page, isAllowedToView = true): Promise<void> => {
await goToOnCallPage(page, 'users');
if (isAllowedToView) {
const usersTableElement = page.getByTestId('users-table');
await usersTableElement.waitFor({ state: 'visible' });
const userRowsContext = await usersTableElement.locator('tbody > tr').allTextContents();
expect(userRowsContext.length).toBeGreaterThan(0);
} else {
const missingPermissionsMessageElement = page.getByTestId('view-users-missing-permission-message');
await missingPermissionsMessageElement.waitFor({ state: 'visible' });
const missingPermissionMessage = await missingPermissionsMessageElement.textContent();
expect(missingPermissionMessage).toMatch(/You are missing the .* to be able to view OnCall users/);
}
};
test('admin is allowed to', async ({ adminRolePage }) => {
await testFlow(adminRolePage.page);
});
test('editor is allowed to', async ({ editorRolePage }) => {
await testFlow(editorRolePage.page);
});
test('viewer is not allowed to', async ({ viewerRolePage }) => {
await testFlow(viewerRolePage.page, false);
});
});

View file

@ -21,5 +21,5 @@ module.exports = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testTimeout: 10000,
testPathIgnorePatterns: ['/node_modules/', '/integration-tests/'],
testPathIgnorePatterns: ['/node_modules/', '/e2e-tests/'],
};

View file

@ -11,7 +11,7 @@
"build:dev": "grafana-toolkit plugin:build --skipTest --skipLint",
"test": "jest --verbose",
"test:silent": "jest --silent",
"test:integration": "yarn playwright test",
"test:e2e": "yarn playwright test",
"dev": "grafana-toolkit plugin:dev",
"watch": "grafana-toolkit plugin:dev --watch",
"sign": "grafana-toolkit plugin:sign",

View file

@ -7,17 +7,17 @@ import { devices } from '@playwright/test';
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
require('dotenv').config({ path: path.resolve(process.cwd(), 'integration-tests/.env') });
require('dotenv').config({ path: path.resolve(process.cwd(), 'e2e-tests/.env') });
export const VIEWER_USER_STORAGE_STATE = path.join(__dirname, 'integration-tests/.auth/viewer.json');
export const EDITOR_USER_STORAGE_STATE = path.join(__dirname, 'integration-tests/.auth/editor.json');
export const ADMIN_USER_STORAGE_STATE = path.join(__dirname, 'integration-tests/.auth/admin.json');
export const VIEWER_USER_STORAGE_STATE = path.join(__dirname, 'e2e-tests/.auth/viewer.json');
export const EDITOR_USER_STORAGE_STATE = path.join(__dirname, 'e2e-tests/.auth/editor.json');
export const ADMIN_USER_STORAGE_STATE = path.join(__dirname, 'e2e-tests/.auth/admin.json');
/**
* See https://playwright.dev/docs/test-configuration.
*/
const config: PlaywrightTestConfig = {
testDir: './integration-tests',
testDir: './e2e-tests',
/* Maximum time all the tests can run for. */
globalTimeout: 20 * 60 * 1000, // 20 minutes

View file

@ -40,10 +40,7 @@ const MobileAppConnection = observer(({ userPk }: Props) => {
// Show link to cloud page for OSS instances with no cloud connection
if (store.hasFeature(AppFeature.CloudConnection) && !cloudStore.cloudConnectionStatus.cloud_connection_status) {
return (
<WithPermissionControlDisplay
userAction={UserActions.UserSettingsWrite}
message="You do not have permission to perform this action. Ask an admin to upgrade your permissions."
>
<WithPermissionControlDisplay userAction={UserActions.UserSettingsWrite}>
<VerticalGroup spacing="lg">
<Text type="secondary">Please connect Cloud OnCall to use the mobile app</Text>
<WithPermissionControlDisplay

View file

@ -8,7 +8,6 @@ import Block from 'components/GBlock/Block';
import MobileAppConnection from 'containers/MobileAppConnection/MobileAppConnection';
import { UserSettingsTab } from 'containers/UserSettings/UserSettings.types';
import { SlackTab } from 'containers/UserSettings/parts/tabs//SlackTab/SlackTab';
import CloudPhoneSettings from 'containers/UserSettings/parts/tabs/CloudPhoneSettings/CloudPhoneSettings';
import { NotificationSettingsTab } from 'containers/UserSettings/parts/tabs/NotificationSettingsTab';
import PhoneVerification from 'containers/UserSettings/parts/tabs/PhoneVerification/PhoneVerification';
import TelegramInfo from 'containers/UserSettings/parts/tabs/TelegramInfo/TelegramInfo';
@ -17,6 +16,8 @@ import { User } from 'models/user/user.types';
import { AppFeature } from 'state/features';
import { useStore } from 'state/useStore';
import CloudPhoneSettings from './tabs/CloudPhoneSettings/CloudPhoneSettings';
import styles from 'containers/UserSettings/parts/index.module.css';
const cx = cn.bind(styles);
@ -54,6 +55,7 @@ export const Tabs = ({
label="User Info"
key={UserSettingsTab.UserInfo}
onChangeTab={getTabClickHandler(UserSettingsTab.UserInfo)}
data-testid="tab-user-info"
/>
{showNotificationSettingsTab && (
<Tab
@ -61,6 +63,7 @@ export const Tabs = ({
label="Notification Settings"
key={UserSettingsTab.NotificationSettings}
onChangeTab={getTabClickHandler(UserSettingsTab.NotificationSettings)}
data-testid="tab-notification-settings"
/>
)}
<Tab
@ -68,6 +71,7 @@ export const Tabs = ({
label="Phone Verification"
key={UserSettingsTab.PhoneVerification}
onChangeTab={getTabClickHandler(UserSettingsTab.PhoneVerification)}
data-testid="tab-phone-verification"
/>
{showMobileAppConnectionTab && (
<Tab
@ -75,6 +79,7 @@ export const Tabs = ({
label="Mobile App Connection"
key={UserSettingsTab.MobileAppConnection}
onChangeTab={getTabClickHandler(UserSettingsTab.MobileAppConnection)}
data-testid="tab-mobile-app"
/>
)}
{showSlackConnectionTab && (
@ -83,6 +88,7 @@ export const Tabs = ({
label="Slack Connection"
key={UserSettingsTab.SlackInfo}
onChangeTab={getTabClickHandler(UserSettingsTab.SlackInfo)}
data-testid="tab-slack"
/>
)}
{showTelegramConnectionTab && (
@ -91,6 +97,7 @@ export const Tabs = ({
label="Telegram Connection"
key={UserSettingsTab.TelegramInfo}
onChangeTab={getTabClickHandler(UserSettingsTab.TelegramInfo)}
data-testid="tab-telegram"
/>
)}
</TabsBar>

View file

@ -111,27 +111,24 @@ const CloudPhoneSettings = observer((props: CloudPhoneSettingsProps) => {
};
return (
<>
<WithPermissionControlDisplay
userAction={UserActions.OtherSettingsWrite}
title="OnCall uses Grafana Cloud for SMS and phone call notifications"
message="You do not have permission to perform this action. Ask an admin to upgrade your permissions."
>
<VerticalGroup spacing="lg">
<Text.Title level={3}>OnCall uses Grafana Cloud for SMS and phone call notifications</Text.Title>
{syncing ? (
<Button icon="sync" variant="secondary" disabled>
Updating...
</Button>
) : (
<Button icon="sync" variant="secondary" onClick={syncUser} disabled={userStatus === 0}>
Reload from Cloud
</Button>
)}
{!syncing ? <UserCloudStatus /> : <LoadingPlaceholder text="Loading..." />}
</VerticalGroup>
</WithPermissionControlDisplay>
</>
<WithPermissionControlDisplay
userAction={UserActions.OtherSettingsWrite}
title="OnCall uses Grafana Cloud for SMS and phone call notifications"
>
<VerticalGroup spacing="lg">
<Text.Title level={3}>OnCall uses Grafana Cloud for SMS and phone call notifications</Text.Title>
{syncing ? (
<Button icon="sync" variant="secondary" disabled>
Updating...
</Button>
) : (
<Button icon="sync" variant="secondary" onClick={syncUser} disabled={userStatus === 0}>
Reload from Cloud
</Button>
)}
{!syncing ? <UserCloudStatus /> : <LoadingPlaceholder text="Loading..." />}
</VerticalGroup>
</WithPermissionControlDisplay>
);
});

View file

@ -6,6 +6,7 @@ import { observer } from 'mobx-react';
import PluginLink from 'components/PluginLink/PluginLink';
import Text from 'components/Text/Text';
import { WithPermissionControlDisplay } from 'containers/WithPermissionControl/WithPermissionControlDisplay';
import { WithPermissionControlTooltip } from 'containers/WithPermissionControl/WithPermissionControlTooltip';
import { User } from 'models/user/user.types';
import { rootStore } from 'state';
@ -187,31 +188,28 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => {
}
return (
<>
{isPhoneValid && !user.verified_phone_number && (
<>
<Alert severity="info" title="You will receive alerts to a new number after verification" />
<br />
</>
)}
{!isPhoneProviderConfigured && store.hasFeature(AppFeature.LiveSettings) && (
<>
<Alert
severity="warning"
// @ts-ignore
title={
<>
Can't verify phone. <PluginLink query={{ page: 'live-settings' }}> Check ENV variables</PluginLink> to
configure your provider.
</>
}
/>
<br />
</>
)}
<WithPermissionControlDisplay userAction={UserActions.OtherSettingsWrite}>
<VerticalGroup>
{isPhoneValid && !user.verified_phone_number && (
<Alert severity="info" title="You will receive alerts to a new number after verification" />
)}
{!isPhoneProviderConfigured && store.hasFeature(AppFeature.LiveSettings) && (
<>
<Alert
severity="warning"
title={
(
<Text type="primary">
Can't verify phone. <PluginLink query={{ page: 'live-settings' }}> Check ENV variables</PluginLink>{' '}
to configure your provider.
</Text>
) as any
}
/>
</>
)}
<Field
className={cx('phone__field')}
invalid={showPhoneInputError}
@ -224,8 +222,7 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => {
required
disabled={!isPhoneProviderConfigured || isPhoneDisabled}
placeholder="Please enter the phone number with country code, e.g. +12451111111"
// @ts-ignore
prefix={<Icon name="phone" />}
prefix={<Icon name={'phone' as any} />}
value={phone}
onChange={onChangePhoneCallback}
/>
@ -263,25 +260,23 @@ const PhoneVerification = observer((props: PhoneVerificationProps) => {
<label className={cx('switch__label')}>Hide my phone number from teammates</label>
</div>
)}
<PhoneVerificationButtonsGroup
action={action}
isCodeSent={isCodeSent}
isPhoneCallInitiated={isPhoneCallInitiated}
isButtonDisabled={isButtonDisabled}
isTestCallInProgress={userStore.isTestCallInProgress}
providerConfiguration={providerConfiguration}
onSubmitCallback={onSubmitCallback}
onVerifyCallback={onVerifyCallback}
handleMakeTestCallClick={handleMakeTestCallClick}
handleSendTestSmsClick={handleSendTestSmsClick}
onShowForgetScreen={() => setState({ showForgetScreen: true })}
user={user}
/>
</VerticalGroup>
<br />
<PhoneVerificationButtonsGroup
action={action}
isCodeSent={isCodeSent}
isPhoneCallInitiated={isPhoneCallInitiated}
isButtonDisabled={isButtonDisabled}
isTestCallInProgress={userStore.isTestCallInProgress}
providerConfiguration={providerConfiguration}
onSubmitCallback={onSubmitCallback}
onVerifyCallback={onVerifyCallback}
handleMakeTestCallClick={handleMakeTestCallClick}
handleSendTestSmsClick={handleSendTestSmsClick}
onShowForgetScreen={() => setState({ showForgetScreen: true })}
user={user}
/>
</>
</WithPermissionControlDisplay>
);
});
@ -363,7 +358,6 @@ function PhoneVerificationButtonsGroup({
</>
) : (
<HorizontalGroup>
{' '}
{providerConfiguration.verification_sms && (
<WithPermissionControlTooltip userAction={action}>
<Button

View file

@ -5,8 +5,10 @@ import cn from 'classnames/bind';
import Block from 'components/GBlock/Block';
import Text from 'components/Text/Text';
import { WithPermissionControlDisplay } from 'containers/WithPermissionControl/WithPermissionControlDisplay';
import { SlackNewIcon } from 'icons';
import { useStore } from 'state/useStore';
import { UserActions } from 'utils/authorization';
import { DOCS_SLACK_SETUP } from 'utils/consts';
import styles from './SlackTab.module.css';
@ -21,32 +23,34 @@ export const SlackTab = () => {
}, [slackStore]);
return (
<VerticalGroup spacing="lg">
<Block bordered withBackground className={cx('slack-infoblock', 'personal-slack-infoblock')}>
<VerticalGroup align="center" spacing="lg">
<SlackNewIcon />
<Text>
Personal Slack connection will allow you to manage alert groups in your connected team's Internal Slack
workspace.
</Text>
<Text>To setup personal Slack click the button below, choose workspace and click Allow.</Text>
<WithPermissionControlDisplay userAction={UserActions.UserSettingsWrite}>
<VerticalGroup spacing="lg">
<Block bordered withBackground className={cx('slack-infoblock', 'personal-slack-infoblock')}>
<VerticalGroup align="center" spacing="lg">
<SlackNewIcon />
<Text>
Personal Slack connection will allow you to manage alert groups in your connected team's Internal Slack
workspace.
</Text>
<Text>To setup personal Slack click the button below, choose workspace and click Allow.</Text>
<Text type="secondary">
More details in{' '}
<a href={DOCS_SLACK_SETUP} target="_blank" rel="noreferrer">
<Text type="link">our documentation</Text>
</a>
</Text>
<Text type="secondary">
More details in{' '}
<a href={DOCS_SLACK_SETUP} target="_blank" rel="noreferrer">
<Text type="link">our documentation</Text>
</a>
</Text>
<img
style={{ height: '350px', display: 'block', margin: '0 auto' }}
src="public/plugins/grafana-oncall-app/assets/img/slack_instructions.png"
/>
</VerticalGroup>
</Block>
<Button onClick={handleClickConnectSlackAccount}>
<Icon name="external-link-alt" className={cx('external-link-style')} /> Open Slack connection page
</Button>
</VerticalGroup>
<img
style={{ height: '350px', display: 'block', margin: '0 auto' }}
src="public/plugins/grafana-oncall-app/assets/img/slack_instructions.png"
/>
</VerticalGroup>
</Block>
<Button onClick={handleClickConnectSlackAccount}>
<Icon name="external-link-alt" className={cx('external-link-style')} /> Open Slack connection page
</Button>
</VerticalGroup>
</WithPermissionControlDisplay>
);
};

View file

@ -39,10 +39,7 @@ const TelegramInfo = observer((_props: TelegramInfoProps) => {
}, []);
return (
<WithPermissionControlDisplay
userAction={UserActions.UserSettingsWrite}
message="You do not have permission to perform this action. Ask an admin to upgrade your permissions."
>
<WithPermissionControlDisplay userAction={UserActions.UserSettingsWrite}>
{telegramConfigured || !store.hasFeature(AppFeature.LiveSettings) ? (
<VerticalGroup>
<Text.Title level={5}>Manual connection</Text.Title>

View file

@ -8,12 +8,17 @@ import { isUserActionAllowed, UserAction } from 'utils/authorization';
interface WithPermissionControlDisplayProps {
userAction: UserAction;
children: ReactElement;
message: string;
message?: string;
title?: string;
}
export const WithPermissionControlDisplay: React.FC<WithPermissionControlDisplayProps> = (props) => {
const { userAction, children, title, message } = props;
const {
userAction,
children,
title,
message = 'You do not have permission to perform this action. Ask an admin to upgrade your permissions.',
} = props;
const hasPermission = isUserActionAllowed(userAction);

View file

@ -121,55 +121,14 @@ class Users extends React.Component<UsersProps, UsersState> {
};
render() {
const { usersFilters, userPkToEdit, page, errorData, initialUsersLoaded } = this.state;
const { userPkToEdit, errorData } = this.state;
const {
store,
match: {
params: { id },
},
} = this.props;
const { userStore } = store;
const columns = [
{
width: '25%',
key: 'username',
title: 'User',
render: this.renderTitle,
},
{
width: '20%',
title: 'Status',
key: 'note',
render: this.renderStatus,
},
{
width: '20%',
title: 'Default Notifications',
key: 'notifications-chain',
render: this.renderNotificationsChain,
},
{
width: '20%',
title: 'Important Notifications',
key: 'important-notifications-chain',
render: this.renderImportantNotificationsChain,
},
{
width: '5%',
key: 'buttons',
render: this.renderButtons,
},
];
const handleClear = () =>
this.setState({ usersFilters: { searchTerm: '' } }, () => {
this.debouncedUpdateUsers();
});
const { count, results } = userStore.getSearchResult();
const authorizedToViewUsers = isUserActionAllowed(REQUIRED_PERMISSION_TO_VIEW_USERS);
const isAuthorizedToViewUsers = isUserActionAllowed(REQUIRED_PERMISSION_TO_VIEW_USERS);
return (
<PageErrorHandlingWrapper
@ -179,100 +138,126 @@ class Users extends React.Component<UsersProps, UsersState> {
itemNotFoundMessage={`User with id=${id} is not found. Please select user from the list.`}
>
{() => (
<>
<div className={cx('root')}>
<div className={cx('root', 'TEST-users-page')}>
<div className={cx('users-header')}>
<div style={{ display: 'flex', alignItems: 'baseline' }}>
<div>
<LegacyNavHeading>
<Text.Title level={3}>Users</Text.Title>
</LegacyNavHeading>
{authorizedToViewUsers && (
<Text type="secondary">
All Grafana users listed below to set notification preferences. To manage permissions or add
new users, please visit{' '}
<a href="/admin/users" target="_blank">
Grafana user management
</a>
</Text>
)}
</div>
</div>
<PluginLink query={{ page: 'users', id: 'me' }}>
<Button variant="primary" icon="user">
View my profile
</Button>
</PluginLink>
<div className={cx('root')}>
<div className={cx('users-header')}>
<div style={{ display: 'flex', alignItems: 'baseline' }}>
<div>
<LegacyNavHeading>
<Text.Title level={3}>Users</Text.Title>
</LegacyNavHeading>
{isAuthorizedToViewUsers && (
<Text type="secondary">
All Grafana users listed below to set notification preferences. To manage permissions or add new
users, please visit{' '}
<a href="/admin/users" target="_blank">
Grafana user management
</a>
</Text>
)}
</div>
{authorizedToViewUsers ? (
<>
<div className={cx('user-filters-container')}>
<UsersFilters
className={cx('users-filters')}
value={usersFilters}
onChange={this.handleUsersFiltersChange}
/>
<Button
variant="secondary"
icon="times"
onClick={handleClear}
className={cx('searchIntegrationClear')}
>
Clear filters
</Button>
</div>
<GTable
data-testid="users-table"
emptyText={initialUsersLoaded ? 'No users found' : 'Loading...'}
rowKey="pk"
data={results}
columns={columns}
rowClassName={getUserRowClassNameFn(userPkToEdit, userStore.currentUserPk)}
pagination={{
page,
total: Math.ceil((count || 0) / ITEMS_PER_PAGE),
onChange: this.handleChangePage,
}}
/>
</>
) : (
<Alert
/* @ts-ignore */
title={
<>
{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
</>
}
data-testid="view-users-missing-permission-message"
severity="info"
/>
)}
</div>
{userPkToEdit && <UserSettings id={userPkToEdit} onHide={this.handleHideUserSettings} />}
<PluginLink query={{ page: 'users', id: 'me' }}>
<Button variant="primary" icon="user" data-testid="users-view-my-profile">
View my profile
</Button>
</PluginLink>
</div>
</>
{this.renderContentIfAuthorized(isAuthorizedToViewUsers)}
{userPkToEdit && <UserSettings id={userPkToEdit} onHide={this.handleHideUserSettings} />}
</div>
)}
</PageErrorHandlingWrapper>
);
}
handleChangePage = (page: number) => {
this.setState({ page }, this.updateUsers);
};
renderContentIfAuthorized(authorizedToViewUsers: boolean) {
const {
store: { userStore },
} = this.props;
const { usersFilters, page, initialUsersLoaded, userPkToEdit } = this.state;
const { count, results } = userStore.getSearchResult();
const columns = this.getTableColumns();
const handleClear = () =>
this.setState({ usersFilters: { searchTerm: '' } }, () => {
this.debouncedUpdateUsers();
});
return (
<>
{authorizedToViewUsers ? (
<>
<div className={cx('user-filters-container')} data-testid="users-filters">
<UsersFilters
className={cx('users-filters')}
value={usersFilters}
onChange={this.handleUsersFiltersChange}
/>
<Button variant="secondary" icon="times" onClick={handleClear} className={cx('searchIntegrationClear')}>
Clear filters
</Button>
</div>
<GTable
data-testid="users-table"
emptyText={initialUsersLoaded ? 'No users found' : 'Loading...'}
rowKey="pk"
data={results}
columns={columns}
rowClassName={getUserRowClassNameFn(userPkToEdit, userStore.currentUserPk)}
pagination={{
page,
total: Math.ceil((count || 0) / ITEMS_PER_PAGE),
onChange: this.handleChangePage,
}}
/>
</>
) : (
<Alert
title={
(
<div data-testid="users-missing-permissions">
<Text type="primary">
{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
</Text>
</div>
) as any
}
data-testid="view-users-missing-permission-message"
severity="info"
/>
)}
</>
);
}
renderTitle = (user: UserType) => {
const {
store: { userStore },
} = this.props;
const isCurrent = userStore.currentUserPk === user.pk;
return (
<HorizontalGroup>
<Avatar className={cx('user-avatar')} size="large" src={user.avatar} />
<div>
<div>{user.username}</div>
<Text type="secondary">{user.email}</Text>
<div
className={cx({
'current-user': isCurrent,
'other-user': !isCurrent,
})}
>
<div data-testid="users-username">{user.username}</div>
<Text type="secondary" data-testid="users-email">
{user.email}
</Text>
<br />
<Text type="secondary">{user.verified_phone_number}</Text>
<Text type="secondary" data-testid="users-phone-number">
{user.verified_phone_number}
</Text>
</div>
</HorizontalGroup>
);
@ -311,7 +296,8 @@ class Users extends React.Component<UsersProps, UsersState> {
<WithPermissionControlTooltip userAction={action}>
<Button
className={cx({
'TEST-edit-my-own-settings-button': isCurrent,
'edit-my-profile-button': isCurrent,
'edit-other-profile-button': !isCurrent,
})}
fill="text"
>
@ -324,7 +310,11 @@ class Users extends React.Component<UsersProps, UsersState> {
};
renderStatus = (user: UserType) => {
const { store } = this.props;
const {
store,
store: { organizationStore, telegramChannelStore },
} = this.props;
if (user.hidden_fields === true) {
return null;
}
@ -345,8 +335,7 @@ class Users extends React.Component<UsersProps, UsersState> {
phone_verified = false;
switch (user.cloud_connection_status) {
case 0:
// Cloud is not connected, no need to show warning to the user
break;
break; // Cloud is not connected, no need to show warning to the user
case 1:
warnings.push('User not matched with cloud');
break;
@ -354,21 +343,18 @@ class Users extends React.Component<UsersProps, UsersState> {
warnings.push('Phone number is not verified in Grafana Cloud');
break;
case 3:
// Phone is verified in Grafana Cloud, no need to show warning to the user
phone_verified = true;
phone_verified = true; // Phone is verified in Grafana Cloud, no need to show warning to the user
break;
}
} else {
if (!phone_verified) {
warnings.push('Phone not verified');
}
} else if (!phone_verified) {
warnings.push('Phone not verified');
}
if (store.organizationStore.currentOrganization.slack_team_identity && !user.slack_user_identity) {
if (organizationStore.currentOrganization.slack_team_identity && !user.slack_user_identity) {
warnings.push('Slack profile is not connected');
}
let telegramChannelsExist = store.telegramChannelStore.currentTeamToTelegramChannel?.length > 0;
let telegramChannelsExist = telegramChannelStore.currentTeamToTelegramChannel?.length > 0;
if (store.hasFeature(AppFeature.Telegram) && telegramChannelsExist && !user.telegram_configuration) {
warnings.push('Telegram profile is not connected');
@ -397,6 +383,44 @@ class Users extends React.Component<UsersProps, UsersState> {
);
};
getTableColumns(): Array<{ width: string; key: string; title?: string; render }> {
return [
{
width: '25%',
key: 'username',
title: 'User',
render: this.renderTitle,
},
{
width: '20%',
title: 'Status',
key: 'note',
render: this.renderStatus,
},
{
width: '20%',
title: 'Default Notifications',
key: 'notifications-chain',
render: this.renderNotificationsChain,
},
{
width: '20%',
title: 'Important Notifications',
key: 'important-notifications-chain',
render: this.renderImportantNotificationsChain,
},
{
width: '5%',
key: 'buttons',
render: this.renderButtons,
},
];
}
handleChangePage = (page: number) => {
this.setState({ page }, this.updateUsers);
};
debouncedUpdateUsers = debounce(this.updateUsers, 500);
handleUsersFiltersChange = (usersFilters: any) => {

View file

@ -1,6 +1,6 @@
{
"extends": "@grafana/toolkit/src/config/tsconfig.plugin.json",
"include": ["src", "frontend_enterprise/src", "integration-tests", "playwright.config.ts"],
"include": ["src", "frontend_enterprise/src", "e2e-tests", "playwright.config.ts"],
"types": ["node", "@emotion/core"],
"compilerOptions": {
"rootDirs": ["src", "frontend_enterprise/src"],