mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
PublicDashboards: Email sharing users page (#67124)
This commit is contained in:
parent
35407142d0
commit
fc3737bf4f
@ -289,11 +289,32 @@ export const Pages = {
|
||||
},
|
||||
},
|
||||
UserListPage: {
|
||||
tabs: {
|
||||
allUsers: 'data-testid all-users-tab',
|
||||
orgUsers: 'data-testid org-users-tab',
|
||||
publicDashboardsUsers: 'data-testid public-dashboards-users-tab',
|
||||
users: 'data-testid users-tab',
|
||||
},
|
||||
org: {
|
||||
url: '/org/users',
|
||||
},
|
||||
admin: {
|
||||
url: '/admin/users',
|
||||
},
|
||||
publicDashboards: {
|
||||
container: 'data-testid public-dashboards-users-list',
|
||||
},
|
||||
UserListAdminPage: {
|
||||
container: 'data-testid user-list-admin-page',
|
||||
},
|
||||
UsersListPage: {
|
||||
container: 'data-testid users-list-page',
|
||||
},
|
||||
UsersListPublicDashboardsPage: {
|
||||
container: 'data-testid users-list-public-dashboards-page',
|
||||
DashboardsListModal: {
|
||||
listItem: (uid: string) => `data-testid dashboards-list-item-${uid}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
@ -3,6 +3,7 @@ import React, { ComponentType, useEffect, useMemo, memo } from 'react';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
|
||||
import {
|
||||
Icon,
|
||||
IconName,
|
||||
@ -32,6 +33,8 @@ export const addExtraFilters = (filter: ComponentType<FilterProps>) => {
|
||||
extraFilters.push(filter);
|
||||
};
|
||||
|
||||
const selectors = e2eSelectors.pages.UserListPage.UserListAdminPage;
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchUsers,
|
||||
changeQuery,
|
||||
@ -78,7 +81,7 @@ const UserListAdminPageUnConnected = ({
|
||||
|
||||
return (
|
||||
<Page.Contents>
|
||||
<div className="page-action-bar">
|
||||
<div className="page-action-bar" data-testid={selectors.container}>
|
||||
<div className="gf-form gf-form--grow">
|
||||
<FilterInput
|
||||
placeholder="Search user by login, email, or name."
|
||||
|
202
public/app/features/admin/UserListPage.test.tsx
Normal file
202
public/app/features/admin/UserListPage.test.tsx
Normal file
@ -0,0 +1,202 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
|
||||
import { GrafanaBootConfig } from '@grafana/runtime/src';
|
||||
import config from 'app/core/config';
|
||||
|
||||
import { TestProvider } from '../../../test/helpers/TestProvider';
|
||||
import { contextSrv } from '../../core/services/context_srv';
|
||||
|
||||
import UserListPage from './UserListPage';
|
||||
|
||||
const selectors = e2eSelectors.pages.UserListPage;
|
||||
const tabsSelector = selectors.tabs;
|
||||
|
||||
jest.mock('@grafana/runtime', () => ({
|
||||
...jest.requireActual('@grafana/runtime'),
|
||||
getBackendSrv: () => ({ get: jest.fn().mockResolvedValue([]) }),
|
||||
}));
|
||||
|
||||
jest.mock('./UserListAdminPage', () => ({
|
||||
UserListAdminPageContent: () => <div data-testid={selectors.UserListAdminPage.container} />,
|
||||
}));
|
||||
jest.mock('../users/UsersListPage', () => ({
|
||||
UsersListPageContent: () => <div data-testid={selectors.UsersListPage.container} />,
|
||||
}));
|
||||
jest.mock('./UserListPublicDashboardPage/UserListPublicDashboardPage', () => ({
|
||||
UserListPublicDashboardPage: () => <div data-testid={selectors.UsersListPublicDashboardsPage.container} />,
|
||||
}));
|
||||
|
||||
const renderPage = () => {
|
||||
render(
|
||||
<TestProvider>
|
||||
<UserListPage />
|
||||
</TestProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const enableEmailSharing = () => {
|
||||
config.featureToggles.publicDashboardsEmailSharing = true;
|
||||
config.featureToggles.publicDashboards = true;
|
||||
config.licenseInfo = { ...config.licenseInfo, enabledFeatures: { publicDashboardsEmailSharing: true } };
|
||||
};
|
||||
|
||||
let originalConfigData: GrafanaBootConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
originalConfigData = { ...config };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
config.featureToggles = originalConfigData.featureToggles;
|
||||
config.licenseInfo = originalConfigData.licenseInfo;
|
||||
});
|
||||
|
||||
describe('Tabs rendering', () => {
|
||||
it('should render All and Org Users tabs when user has permissions to read to org users and is admin', async () => {
|
||||
jest.spyOn(contextSrv, 'hasAccess').mockReturnValue(true);
|
||||
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true);
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByTestId(tabsSelector.allUsers)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(tabsSelector.orgUsers)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(tabsSelector.publicDashboardsUsers)).not.toBeInTheDocument();
|
||||
});
|
||||
it('should render All, Org and Public dashboard tabs when user has permissions to read org users, is admin and has email sharing enabled', async () => {
|
||||
jest.spyOn(contextSrv, 'hasAccess').mockReturnValue(true);
|
||||
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true);
|
||||
|
||||
enableEmailSharing();
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByTestId(tabsSelector.allUsers)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(tabsSelector.orgUsers)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(tabsSelector.publicDashboardsUsers)).toBeInTheDocument();
|
||||
});
|
||||
describe('No permissions to read org users or not admin', () => {
|
||||
[
|
||||
{
|
||||
hasOrgReadPermissions: false,
|
||||
isAdmin: true,
|
||||
},
|
||||
{
|
||||
hasOrgReadPermissions: true,
|
||||
isAdmin: false,
|
||||
},
|
||||
].forEach((scenario) => {
|
||||
it('should render no tabs when user has no permissions to read org users or is not admin', async () => {
|
||||
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(scenario.hasOrgReadPermissions);
|
||||
jest.spyOn(contextSrv, 'hasAccess').mockReturnValue(scenario.isAdmin);
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(screen.queryByTestId(tabsSelector.allUsers)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(tabsSelector.orgUsers)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(tabsSelector.publicDashboardsUsers)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
describe('No permissions to read org users or not admin but email sharing enabled', () => {
|
||||
[
|
||||
{
|
||||
title: 'user has no permissions to read org users',
|
||||
hasOrgReadPermissions: false,
|
||||
isAdmin: true,
|
||||
},
|
||||
{
|
||||
title: 'user is not admin',
|
||||
hasOrgReadPermissions: true,
|
||||
isAdmin: false,
|
||||
},
|
||||
].forEach((scenario) => {
|
||||
it(`should render User and Public dashboard tabs when ${scenario.title} but has email sharing enabled`, async () => {
|
||||
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(scenario.hasOrgReadPermissions);
|
||||
jest.spyOn(contextSrv, 'hasAccess').mockReturnValue(scenario.isAdmin);
|
||||
|
||||
enableEmailSharing();
|
||||
renderPage();
|
||||
|
||||
expect(screen.queryByTestId(tabsSelector.allUsers)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(tabsSelector.orgUsers)).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByTestId(tabsSelector.users)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(tabsSelector.publicDashboardsUsers)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tables rendering', () => {
|
||||
it('should render UserListAdminPage when user is admin', () => {
|
||||
jest.spyOn(contextSrv, 'hasAccess').mockReturnValue(true);
|
||||
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true);
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByTestId(tabsSelector.allUsers).className.includes('activeTabStyle')).toBeTruthy();
|
||||
expect(screen.getByTestId(tabsSelector.orgUsers)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByTestId(selectors.UserListAdminPage.container)).toBeInTheDocument();
|
||||
});
|
||||
it('should render UsersListPage when user is admin and has org read permissions', async () => {
|
||||
jest.spyOn(contextSrv, 'hasAccess').mockReturnValue(true);
|
||||
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true);
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(screen.getByTestId(tabsSelector.allUsers).className.includes('activeTabStyle')).toBeTruthy();
|
||||
expect(screen.getByTestId(tabsSelector.orgUsers)).toBeInTheDocument();
|
||||
expect(screen.getByTestId(selectors.UserListAdminPage.container)).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByTestId(tabsSelector.orgUsers));
|
||||
expect(screen.getByTestId(tabsSelector.orgUsers).className.includes('activeTabStyle')).toBeTruthy();
|
||||
expect(screen.getByTestId(selectors.UsersListPage.container)).toBeInTheDocument();
|
||||
});
|
||||
it('should render UsersListPage when user has org read permissions and is not admin', async () => {
|
||||
jest.spyOn(contextSrv, 'hasAccess').mockReturnValue(false);
|
||||
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true);
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(screen.queryByTestId(tabsSelector.allUsers)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(tabsSelector.orgUsers)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(tabsSelector.users)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(tabsSelector.publicDashboardsUsers)).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByTestId(selectors.UsersListPage.container)).toBeInTheDocument();
|
||||
});
|
||||
it('should render UserListPublicDashboardPage when user has email sharing enabled and is not admin', async () => {
|
||||
jest.spyOn(contextSrv, 'hasAccess').mockReturnValue(false);
|
||||
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(true);
|
||||
|
||||
enableEmailSharing();
|
||||
renderPage();
|
||||
|
||||
expect(screen.queryByTestId(tabsSelector.allUsers)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(tabsSelector.orgUsers)).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByTestId(tabsSelector.users)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId(tabsSelector.publicDashboardsUsers)).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByTestId(tabsSelector.publicDashboardsUsers));
|
||||
expect(screen.getByTestId(tabsSelector.publicDashboardsUsers).className.includes('activeTabStyle')).toBeTruthy();
|
||||
expect(screen.getByTestId(selectors.UsersListPublicDashboardsPage.container)).toBeInTheDocument();
|
||||
});
|
||||
it('should render UsersListPage when user is not admin and does not have nor org read perms neither email sharing enabled', async () => {
|
||||
jest.spyOn(contextSrv, 'hasAccess').mockReturnValue(false);
|
||||
jest.spyOn(contextSrv, 'hasPermission').mockReturnValue(false);
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(screen.queryByTestId(tabsSelector.allUsers)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(tabsSelector.orgUsers)).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByTestId(tabsSelector.users)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId(tabsSelector.publicDashboardsUsers)).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.getByTestId(selectors.UsersListPage.container)).toBeInTheDocument();
|
||||
});
|
||||
});
|
@ -2,6 +2,8 @@ import { css } from '@emotion/css';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
|
||||
import { config, featureEnabled } from '@grafana/runtime';
|
||||
import { useStyles2, TabsBar, Tab } from '@grafana/ui';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
|
||||
@ -10,31 +12,84 @@ import { AccessControlAction } from '../../types';
|
||||
import { UsersListPageContent } from '../users/UsersListPage';
|
||||
|
||||
import { UserListAdminPageContent } from './UserListAdminPage';
|
||||
import { UserListPublicDashboardPage } from './UserListPublicDashboardPage/UserListPublicDashboardPage';
|
||||
|
||||
enum TabView {
|
||||
ADMIN = 'admin',
|
||||
ORG = 'org',
|
||||
PUBLIC_DASHBOARDS = 'public-dashboards',
|
||||
}
|
||||
|
||||
const selectors = e2eSelectors.pages.UserListPage;
|
||||
|
||||
const PublicDashboardsTab = ({ view, setView }: { view: TabView | null; setView: (v: TabView | null) => void }) => (
|
||||
<Tab
|
||||
label="Public dashboard users"
|
||||
active={view === TabView.PUBLIC_DASHBOARDS}
|
||||
onChangeTab={() => setView(TabView.PUBLIC_DASHBOARDS)}
|
||||
data-testid={selectors.tabs.publicDashboardsUsers}
|
||||
/>
|
||||
);
|
||||
|
||||
const TAB_PAGE_MAP: Record<TabView, React.ReactElement> = {
|
||||
[TabView.ADMIN]: <UserListAdminPageContent />,
|
||||
[TabView.ORG]: <UsersListPageContent />,
|
||||
[TabView.PUBLIC_DASHBOARDS]: <UserListPublicDashboardPage />,
|
||||
};
|
||||
|
||||
export default function UserListPage() {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const hasAccessToAdminUsers = contextSrv.hasAccess(AccessControlAction.UsersRead, contextSrv.isGrafanaAdmin);
|
||||
const hasAccessToOrgUsers = contextSrv.hasPermission(AccessControlAction.OrgUsersRead);
|
||||
const styles = useStyles2(getStyles);
|
||||
const hasEmailSharingEnabled =
|
||||
Boolean(config.featureToggles.publicDashboards) &&
|
||||
Boolean(config.featureToggles.publicDashboardsEmailSharing) &&
|
||||
featureEnabled('publicDashboardsEmailSharing');
|
||||
|
||||
const [view, setView] = useState(() => {
|
||||
if (hasAccessToAdminUsers) {
|
||||
return 'admin';
|
||||
return TabView.ADMIN;
|
||||
} else if (hasAccessToOrgUsers) {
|
||||
return 'org';
|
||||
return TabView.ORG;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const showToggle = hasAccessToOrgUsers && hasAccessToAdminUsers;
|
||||
const showAdminAndOrgTabs = hasAccessToOrgUsers && hasAccessToAdminUsers;
|
||||
|
||||
return (
|
||||
<Page navId={'global-users'}>
|
||||
{showToggle && (
|
||||
{showAdminAndOrgTabs ? (
|
||||
<TabsBar className={styles.tabsMargin}>
|
||||
<Tab label="All users" active={view === 'admin'} onChangeTab={() => setView('admin')} />
|
||||
<Tab label="Organization users" active={view === 'org'} onChangeTab={() => setView('org')} />
|
||||
<Tab
|
||||
label="All users"
|
||||
active={view === TabView.ADMIN}
|
||||
onChangeTab={() => setView(TabView.ADMIN)}
|
||||
data-testid={selectors.tabs.allUsers}
|
||||
/>
|
||||
<Tab
|
||||
label="Organization users"
|
||||
active={view === TabView.ORG}
|
||||
onChangeTab={() => setView(TabView.ORG)}
|
||||
data-testid={selectors.tabs.orgUsers}
|
||||
/>
|
||||
{hasEmailSharingEnabled && <PublicDashboardsTab view={view} setView={setView} />}
|
||||
</TabsBar>
|
||||
) : (
|
||||
hasEmailSharingEnabled && (
|
||||
<TabsBar className={styles.tabsMargin}>
|
||||
<Tab
|
||||
label="Users"
|
||||
active={view === TabView.ORG}
|
||||
onChangeTab={() => setView(TabView.ORG)}
|
||||
data-testid={selectors.tabs.users}
|
||||
/>
|
||||
<PublicDashboardsTab view={view} setView={setView} />
|
||||
</TabsBar>
|
||||
)
|
||||
)}
|
||||
{view === 'admin' ? <UserListAdminPageContent /> : <UsersListPageContent />}
|
||||
{view ? TAB_PAGE_MAP[view] : <UsersListPageContent />}
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
@ -0,0 +1,108 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data/src';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
|
||||
import { Button, LoadingPlaceholder, Modal, ModalsController, useStyles2 } from '@grafana/ui/src';
|
||||
import { generatePublicDashboardUrl } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
|
||||
|
||||
import { useGetActiveUserDashboardsQuery } from '../../dashboard/api/publicDashboardApi';
|
||||
|
||||
const selectors = e2eSelectors.pages.UserListPage.UsersListPublicDashboardsPage.DashboardsListModal;
|
||||
export const DashboardsListModal = ({ email, onDismiss }: { email: string; onDismiss: () => void }) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const { data: dashboards, isLoading } = useGetActiveUserDashboardsQuery(email);
|
||||
|
||||
return (
|
||||
<Modal className={styles.modal} isOpen title="Public dashboards" onDismiss={onDismiss}>
|
||||
{isLoading ? (
|
||||
<div className={styles.loading}>
|
||||
<LoadingPlaceholder text="Loading..." />
|
||||
</div>
|
||||
) : (
|
||||
dashboards?.map((dash) => (
|
||||
<div key={dash.dashboardUid} className={styles.listItem} data-testid={selectors.listItem(dash.dashboardUid)}>
|
||||
<p className={styles.dashboardTitle}>{dash.dashboardTitle}</p>
|
||||
<div className={styles.urlsContainer}>
|
||||
<a
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
className={cx('external-link', styles.url)}
|
||||
href={generatePublicDashboardUrl(dash.publicDashboardAccessToken)}
|
||||
onClick={onDismiss}
|
||||
>
|
||||
Public dashboard URL
|
||||
</a>
|
||||
<span className={styles.urlsDivider}>•</span>
|
||||
<a
|
||||
className={cx('external-link', styles.url)}
|
||||
href={`/d/${dash.dashboardUid}?shareView=share`}
|
||||
onClick={onDismiss}
|
||||
>
|
||||
Public dashboard settings
|
||||
</a>
|
||||
</div>
|
||||
<hr className={styles.divider} />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export const DashboardsListModalButton = ({ email }: { email: string }) => (
|
||||
<ModalsController>
|
||||
{({ showModal, hideModal }) => (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon="question-circle"
|
||||
title="Open dashboards list"
|
||||
aria-label="Open dashboards list"
|
||||
onClick={() => showModal(DashboardsListModal, { email, onDismiss: hideModal })}
|
||||
/>
|
||||
)}
|
||||
</ModalsController>
|
||||
);
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
modal: css`
|
||||
width: 590px;
|
||||
`,
|
||||
loading: css`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
`,
|
||||
listItem: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.spacing(0.5)};
|
||||
`,
|
||||
divider: css`
|
||||
margin: ${theme.spacing(1.5, 0)};
|
||||
color: ${theme.colors.text.secondary};
|
||||
`,
|
||||
urlsContainer: css`
|
||||
display: flex;
|
||||
gap: ${theme.spacing(0.5)};
|
||||
|
||||
${theme.breakpoints.down('sm')} {
|
||||
flex-direction: column;
|
||||
}
|
||||
`,
|
||||
urlsDivider: css`
|
||||
color: ${theme.colors.text.secondary};
|
||||
${theme.breakpoints.down('sm')} {
|
||||
display: none;
|
||||
}
|
||||
`,
|
||||
dashboardTitle: css`
|
||||
font-size: ${theme.typography.body.fontSize};
|
||||
font-weight: ${theme.typography.fontWeightBold};
|
||||
margin-bottom: 0;
|
||||
`,
|
||||
url: css`
|
||||
font-size: ${theme.typography.body.fontSize};
|
||||
`,
|
||||
});
|
@ -0,0 +1,49 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data/src';
|
||||
import { Button, Modal, ModalsController, useStyles2 } from '@grafana/ui/src';
|
||||
import { SessionUser } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
|
||||
|
||||
const DeleteUserModal = ({ user, onDismiss }: { user: SessionUser; onDismiss: () => void }) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
return (
|
||||
<Modal className={styles.modal} isOpen title="Delete" onDismiss={onDismiss}>
|
||||
<p className={styles.description}>
|
||||
The user {user.email} is currently present in {user.totalDashboards} public dashboard(s). If you wish to remove
|
||||
this user, please navigate to the settings of the corresponding public dashboard.
|
||||
</p>
|
||||
<Modal.ButtonRow>
|
||||
<Button type="button" variant="secondary" onClick={onDismiss} fill="outline">
|
||||
Close
|
||||
</Button>
|
||||
</Modal.ButtonRow>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export const DeleteUserModalButton = ({ user }: { user: SessionUser }) => (
|
||||
<ModalsController>
|
||||
{({ showModal, hideModal }) => (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => showModal(DeleteUserModal, { user, onDismiss: hideModal })}
|
||||
icon="times"
|
||||
aria-label="Delete user"
|
||||
title="Delete user"
|
||||
/>
|
||||
)}
|
||||
</ModalsController>
|
||||
);
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
modal: css`
|
||||
width: 500px;
|
||||
`,
|
||||
description: css`
|
||||
font-size: ${theme.typography.body.fontSize};
|
||||
margin: 0;
|
||||
`,
|
||||
});
|
@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
|
||||
import { HorizontalGroup, Icon, Tag, Tooltip } from '@grafana/ui/src';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
|
||||
import { useGetActiveUsersQuery } from '../../dashboard/api/publicDashboardApi';
|
||||
|
||||
import { DashboardsListModalButton } from './DashboardsListModalButton';
|
||||
import { DeleteUserModalButton } from './DeleteUserModalButton';
|
||||
|
||||
const selectors = e2eSelectors.pages.UserListPage.publicDashboards;
|
||||
|
||||
export const UserListPublicDashboardPage = () => {
|
||||
const { data: users, isLoading } = useGetActiveUsersQuery();
|
||||
|
||||
return (
|
||||
<Page.Contents isLoading={isLoading}>
|
||||
<table className="filter-table form-inline" data-testid={selectors.container}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>
|
||||
<span>Activated </span>
|
||||
<Tooltip placement="top" content={'Earliest time user has been an active user to a dashboard'}>
|
||||
<Icon name="question-circle" />
|
||||
</Tooltip>
|
||||
</th>
|
||||
<th>Origin</th>
|
||||
<th>Role</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users?.map((user) => (
|
||||
<tr key={user.email}>
|
||||
<td className="max-width-10">
|
||||
<span className="ellipsis" title={user.email}>
|
||||
{user.email}
|
||||
</span>
|
||||
</td>
|
||||
<td className="max-width-10">{user.firstSeenAtAge}</td>
|
||||
<td className="max-width-10">
|
||||
<HorizontalGroup spacing="sm">
|
||||
<span>{user.totalDashboards} dashboard(s)</span>
|
||||
<DashboardsListModalButton email={user.email} />
|
||||
</HorizontalGroup>
|
||||
</td>
|
||||
<td className="max-width-10">
|
||||
<Tag name="Viewer" colorIndex={19} />
|
||||
</td>
|
||||
<td className="text-right">
|
||||
<DeleteUserModalButton user={user} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Page.Contents>
|
||||
);
|
||||
};
|
@ -7,6 +7,8 @@ import { createErrorNotification, createSuccessNotification } from 'app/core/cop
|
||||
import {
|
||||
PublicDashboard,
|
||||
PublicDashboardSettings,
|
||||
SessionDashboard,
|
||||
SessionUser,
|
||||
} from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
|
||||
import { DashboardModel } from 'app/features/dashboard/state';
|
||||
import { ListPublicDashboardResponse } from 'app/features/manage-dashboards/types';
|
||||
@ -42,7 +44,7 @@ const getConfigError = (err: unknown) => ({ error: isFetchError(err) && err.stat
|
||||
export const publicDashboardApi = createApi({
|
||||
reducerPath: 'publicDashboardApi',
|
||||
baseQuery: backendSrvBaseQuery({ baseUrl: '/api' }),
|
||||
tagTypes: ['PublicDashboard', 'AuditTablePublicDashboard'],
|
||||
tagTypes: ['PublicDashboard', 'AuditTablePublicDashboard', 'UsersWithActiveSessions', 'ActiveUserDashboards'],
|
||||
refetchOnMountOrArgChange: true,
|
||||
endpoints: (builder) => ({
|
||||
getPublicDashboard: builder.query<PublicDashboard | undefined, string>({
|
||||
@ -116,6 +118,18 @@ export const publicDashboardApi = createApi({
|
||||
url: '',
|
||||
}),
|
||||
}),
|
||||
getActiveUsers: builder.query<SessionUser[], void>({
|
||||
query: () => ({
|
||||
url: '/',
|
||||
}),
|
||||
providesTags: ['UsersWithActiveSessions'],
|
||||
}),
|
||||
getActiveUserDashboards: builder.query<SessionDashboard[], string>({
|
||||
query: () => ({
|
||||
url: '',
|
||||
}),
|
||||
providesTags: (result, _, email) => [{ type: 'ActiveUserDashboards', id: email }],
|
||||
}),
|
||||
listPublicDashboards: builder.query<ListPublicDashboardResponse[], void>({
|
||||
query: () => ({
|
||||
url: '/dashboards/public-dashboards',
|
||||
@ -139,6 +153,8 @@ export const publicDashboardApi = createApi({
|
||||
invalidatesTags: (result, error, { dashboardUid }) => [
|
||||
{ type: 'PublicDashboard', id: dashboardUid },
|
||||
'AuditTablePublicDashboard',
|
||||
'UsersWithActiveSessions',
|
||||
'ActiveUserDashboards',
|
||||
],
|
||||
}),
|
||||
}),
|
||||
@ -153,4 +169,6 @@ export const {
|
||||
useAddRecipientMutation,
|
||||
useDeleteRecipientMutation,
|
||||
useReshareAccessToRecipientMutation,
|
||||
useGetActiveUsersQuery,
|
||||
useGetActiveUserDashboardsQuery,
|
||||
} = publicDashboardApi;
|
||||
|
@ -120,7 +120,7 @@ const ConfigPublicDashboard = () => {
|
||||
{hasEmailSharingEnabled && <EmailSharingConfiguration />}
|
||||
<Field label="Dashboard URL" className={styles.publicUrl}>
|
||||
<Input
|
||||
value={generatePublicDashboardUrl(publicDashboard!)}
|
||||
value={generatePublicDashboardUrl(publicDashboard!.accessToken!)}
|
||||
readOnly
|
||||
disabled={!publicDashboard?.isEnabled}
|
||||
data-testid={selectors.CopyUrlInput}
|
||||
@ -129,7 +129,7 @@ const ConfigPublicDashboard = () => {
|
||||
data-testid={selectors.CopyUrlButton}
|
||||
variant="primary"
|
||||
disabled={!publicDashboard?.isEnabled}
|
||||
getText={() => generatePublicDashboardUrl(publicDashboard!)}
|
||||
getText={() => generatePublicDashboardUrl(publicDashboard!.accessToken!)}
|
||||
>
|
||||
Copy
|
||||
</ClipboardButton>
|
||||
|
@ -31,7 +31,7 @@ describe('generatePublicDashboardUrl', () => {
|
||||
updateConfig({ appUrl });
|
||||
let pubdash = { accessToken } as PublicDashboard;
|
||||
|
||||
expect(generatePublicDashboardUrl(pubdash)).toEqual(`${appUrl}public-dashboards/${accessToken}`);
|
||||
expect(generatePublicDashboardUrl(pubdash.accessToken!)).toEqual(`${appUrl}public-dashboards/${accessToken}`);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -25,6 +25,18 @@ export interface PublicDashboard extends PublicDashboardSettings {
|
||||
recipients?: Array<{ uid: string; recipient: string }>;
|
||||
}
|
||||
|
||||
export interface SessionDashboard {
|
||||
dashboardTitle: string;
|
||||
dashboardUid: string;
|
||||
publicDashboardAccessToken: string;
|
||||
}
|
||||
|
||||
export interface SessionUser {
|
||||
email: string;
|
||||
firstSeenAtAge: string;
|
||||
totalDashboards: number;
|
||||
}
|
||||
|
||||
// Instance methods
|
||||
export const dashboardHasTemplateVariables = (variables: VariableModel[]): boolean => {
|
||||
return variables.length > 0;
|
||||
@ -58,10 +70,10 @@ export const getUnsupportedDashboardDatasources = (panels: PanelModel[]): string
|
||||
*
|
||||
* All app urls from the Grafana boot config end with a slash.
|
||||
*
|
||||
* @param publicDashboard
|
||||
* @param accessToken
|
||||
*/
|
||||
export const generatePublicDashboardUrl = (publicDashboard: PublicDashboard): string => {
|
||||
return `${getConfig().appUrl}public-dashboards/${publicDashboard.accessToken}`;
|
||||
export const generatePublicDashboardUrl = (accessToken: string): string => {
|
||||
return `${getConfig().appUrl}public-dashboards/${accessToken}`;
|
||||
};
|
||||
|
||||
export const validEmailRegex = /^[A-Z\d._%+-]+@[A-Z\d.-]+\.[A-Z]{2,}$/i;
|
||||
|
@ -26,7 +26,7 @@ export const DeletePublicDashboardModal = ({
|
||||
onDismiss: () => void;
|
||||
}) => (
|
||||
<ConfirmModal
|
||||
isOpen={true}
|
||||
isOpen
|
||||
body={<Body title={dashboardTitle} />}
|
||||
onConfirm={onConfirm}
|
||||
onDismiss={onDismiss}
|
||||
|
@ -13,7 +13,7 @@ import { configureStore } from 'app/store/configureStore';
|
||||
|
||||
import { ListPublicDashboardResponse } from '../../types';
|
||||
|
||||
import { PublicDashboardListTable, viewPublicDashboardUrl } from './PublicDashboardListTable';
|
||||
import { PublicDashboardListTable } from './PublicDashboardListTable';
|
||||
|
||||
const publicDashboardListResponse: ListPublicDashboardResponse[] = [
|
||||
{
|
||||
@ -90,12 +90,6 @@ const renderPublicDashboardTable = async (waitForListRendering?: boolean) => {
|
||||
waitForListRendering && (await waitForElementToBeRemoved(screen.getByTestId('Spinner'), { timeout: 3000 }));
|
||||
};
|
||||
|
||||
describe('viewPublicDashboardUrl', () => {
|
||||
it('has the correct url', () => {
|
||||
expect(viewPublicDashboardUrl('abcd')).toEqual('public-dashboards/abcd');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Show table', () => {
|
||||
it('renders loader spinner while loading', async () => {
|
||||
await renderPublicDashboardTable();
|
||||
|
@ -6,9 +6,9 @@ import { GrafanaTheme2 } from '@grafana/data/src';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
|
||||
import { Link, ButtonGroup, LinkButton, Icon, Tag, useStyles2, Tooltip, useTheme2, Spinner } from '@grafana/ui/src';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { getConfig } from 'app/core/config';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { useListPublicDashboardsQuery } from 'app/features/dashboard/api/publicDashboardApi';
|
||||
import { generatePublicDashboardUrl } from 'app/features/dashboard/components/ShareModal/SharePublicDashboard/SharePublicDashboardUtils';
|
||||
import { isOrgAdmin } from 'app/features/plugins/admin/permissions';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
|
||||
@ -16,9 +16,6 @@ import { ListPublicDashboardResponse } from '../../types';
|
||||
|
||||
import { DeletePublicDashboardButton } from './DeletePublicDashboardButton';
|
||||
|
||||
export const viewPublicDashboardUrl = (accessToken: string): string =>
|
||||
`${getConfig().appUrl}public-dashboards/${accessToken}`;
|
||||
|
||||
export const PublicDashboardListTable = () => {
|
||||
const { width } = useWindowSize();
|
||||
const isMobile = width <= 480;
|
||||
@ -72,7 +69,7 @@ export const PublicDashboardListTable = () => {
|
||||
<td>
|
||||
<ButtonGroup className={styles.buttonGroup}>
|
||||
<LinkButton
|
||||
href={viewPublicDashboardUrl(pd.accessToken)}
|
||||
href={generatePublicDashboardUrl(pd.accessToken)}
|
||||
fill="text"
|
||||
size={responsiveSize}
|
||||
title={pd.isEnabled ? 'View public dashboard' : 'Public dashboard is disabled'}
|
||||
|
@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
|
||||
import { renderMarkdown } from '@grafana/data';
|
||||
import { selectors as e2eSelectors } from '@grafana/e2e-selectors/src';
|
||||
import { HorizontalGroup, Pagination, VerticalGroup } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/Page/Page';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
@ -46,6 +47,8 @@ export interface State {
|
||||
showInvites: boolean;
|
||||
}
|
||||
|
||||
const selectors = e2eSelectors.pages.UserListPage.UsersListPage;
|
||||
|
||||
export const UsersListPageUnconnected = ({
|
||||
users,
|
||||
page,
|
||||
@ -80,7 +83,7 @@ export const UsersListPageUnconnected = ({
|
||||
return <InviteesTable invitees={invitees} />;
|
||||
} else {
|
||||
return (
|
||||
<VerticalGroup spacing="md">
|
||||
<VerticalGroup spacing="md" data-testid={selectors.container}>
|
||||
<UsersTable
|
||||
users={users}
|
||||
orgId={contextSrv.user.orgId}
|
||||
|
Loading…
Reference in New Issue
Block a user