Chore: legacy access control cleanup for frontend team pages (#75005)

* clean up legacy access control code for teams

* remove editorsCanAdmin config from the frontend

* add editorsCanAdmin config option back for the frontend
This commit is contained in:
Ieva 2023-10-09 10:32:44 +01:00 committed by GitHub
parent 5549cd7a76
commit d3f69fd34a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 35 additions and 730 deletions

View File

@ -5179,12 +5179,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Unexpected any. Specify a different type.", "0"],
[0, 0, 0, "Unexpected any. Specify a different type.", "1"]
],
"public/app/features/teams/TeamMemberRow.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/teams/TeamMembers.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/teams/state/reducers.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],

View File

@ -3,9 +3,9 @@ import userEvent from '@testing-library/user-event';
import React from 'react';
import { TestProvider } from 'test/helpers/TestProvider';
import { contextSrv, User } from 'app/core/services/context_srv';
import { contextSrv } from 'app/core/services/context_srv';
import { OrgRole, Team } from '../../types';
import { Team } from '../../types';
import { Props, TeamList } from './TeamList';
import { getMockTeam, getMultipleMockTeams } from './__mocks__/teamMocks';
@ -30,18 +30,11 @@ const setup = (propOverrides?: object) => {
totalPages: 0,
page: 0,
hasFetched: false,
editorsCanAdmin: false,
perPage: 10,
signedInUser: {
id: 1,
orgRole: OrgRole.Viewer,
} as User,
};
Object.assign(props, propOverrides);
contextSrv.user = props.signedInUser;
render(
<TestProvider>
<TeamList {...props} />
@ -62,11 +55,6 @@ describe('TeamList', () => {
teams: getMultipleMockTeams(1),
totalCount: 1,
hasFetched: true,
editorsCanAdmin: true,
signedInUser: {
id: 1,
orgRole: OrgRole.Editor,
} as User,
});
expect(screen.getByRole('link', { name: /new team/i })).not.toHaveStyle('pointer-events: none');
@ -80,11 +68,6 @@ describe('TeamList', () => {
teams: getMultipleMockTeams(1),
totalCount: 1,
hasFetched: true,
editorsCanAdmin: true,
signedInUser: {
id: 1,
orgRole: OrgRole.Viewer,
} as User,
});
expect(screen.getByRole('link', { name: /new team/i })).toHaveStyle('pointer-events: none');
@ -95,7 +78,7 @@ describe('TeamList', () => {
it('should call delete team', async () => {
const mockDelete = jest.fn();
const mockTeam = getMockTeam();
jest.spyOn(contextSrv, 'hasAccessInMetadata').mockReturnValue(true);
jest.spyOn(contextSrv, 'hasPermissionInMetadata').mockReturnValue(true);
setup({ deleteTeam: mockDelete, teams: [mockTeam], totalCount: 1, hasFetched: true });
await userEvent.click(screen.getByRole('button', { name: `Delete team ${mockTeam.name}` }));
await userEvent.click(screen.getByRole('button', { name: 'Delete' }));

View File

@ -18,7 +18,6 @@ import {
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { Page } from 'app/core/components/Page/Page';
import { fetchRoleOptions } from 'app/core/components/RolePicker/api';
import { config } from 'app/core/config';
import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction, Role, StoreState, Team } from 'app/types';
@ -26,7 +25,6 @@ import { TeamRolePicker } from '../../core/components/RolePicker/TeamRolePicker'
import { Avatar } from '../admin/Users/Avatar';
import { deleteTeam, loadTeams, changePage, changeQuery, changeSort } from './state/actions';
import { isPermissionTeamAdmin } from './state/selectors';
type Cell<T extends keyof Team = keyof Team> = CellProps<Team, Team[T]>;
export interface OwnProps {}
@ -44,8 +42,6 @@ export const TeamList = ({
deleteTeam,
changeQuery,
totalPages,
signedInUser,
editorsCanAdmin,
page,
changePage,
changeSort,
@ -110,16 +106,7 @@ export const TeamList = ({
id: 'edit',
header: '',
cell: ({ row: { original } }: Cell) => {
const isTeamAdmin = isPermissionTeamAdmin({
permission: original.permission,
editorsCanAdmin,
signedInUser,
});
const canReadTeam = contextSrv.hasAccessInMetadata(
AccessControlAction.ActionTeamsRead,
original,
isTeamAdmin
);
const canReadTeam = contextSrv.hasPermissionInMetadata(AccessControlAction.ActionTeamsRead, original);
return canReadTeam ? (
<a href={`org/teams/edit/${original.id}`} aria-label={`Edit team ${original.name}`}>
<Tooltip content={'Edit team'}>
@ -133,16 +120,7 @@ export const TeamList = ({
id: 'delete',
header: '',
cell: ({ row: { original } }: Cell) => {
const isTeamAdmin = isPermissionTeamAdmin({
permission: original.permission,
editorsCanAdmin,
signedInUser,
});
const canDelete = contextSrv.hasAccessInMetadata(
AccessControlAction.ActionTeamsDelete,
original,
isTeamAdmin
);
const canDelete = contextSrv.hasPermissionInMetadata(AccessControlAction.ActionTeamsDelete, original);
return (
<DeleteButton
@ -155,7 +133,7 @@ export const TeamList = ({
},
},
],
[displayRolePicker, editorsCanAdmin, roleOptions, signedInUser, deleteTeam]
[displayRolePicker, roleOptions, deleteTeam]
);
return (
@ -219,8 +197,6 @@ function mapStateToProps(state: StoreState) {
noTeams: state.teams.noTeams,
totalPages: state.teams.totalPages,
hasFetched: state.teams.hasFetched,
editorsCanAdmin: config.editorsCanAdmin, // this makes the feature toggle mockable/controllable from tests,
signedInUser: contextSrv.user, // this makes the feature toggle mockable/controllable from tests,
};
}

View File

@ -1,78 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { TeamPermissionLevel } from '../../types';
import { TeamMemberRow, Props } from './TeamMemberRow';
import { getMockTeamMember } from './__mocks__/teamMocks';
const setup = (propOverrides?: object) => {
const props: Props = {
member: getMockTeamMember(),
syncEnabled: false,
editorsCanAdmin: false,
signedInUserIsTeamAdmin: false,
updateTeamMember: jest.fn(),
removeTeamMember: jest.fn(),
};
Object.assign(props, propOverrides);
render(
<table>
<tbody>
<TeamMemberRow {...props} />
</tbody>
</table>
);
};
describe('Render', () => {
it('should render team member labels when sync enabled', () => {
const member = getMockTeamMember();
member.labels = ['LDAP'];
setup({ member, syncEnabled: true });
expect(screen.getByText('LDAP')).toBeInTheDocument();
});
describe('when feature toggle editorsCanAdmin is turned on', () => {
it('should render permissions select if user is team admin', () => {
const member = getMockTeamMember();
setup({ editorsCanAdmin: true, signedInUserIsTeamAdmin: true, member });
expect(screen.getByLabelText(`Select member's ${member.name} permission level`)).toBeInTheDocument();
});
});
describe('when feature toggle editorsCanAdmin is turned off', () => {
it('should not render permissions', () => {
const member = getMockTeamMember();
setup({ editorsCanAdmin: false, signedInUserIsTeamAdmin: true, member });
expect(screen.queryByLabelText(`Select member's ${member.name} permission level`)).not.toBeInTheDocument();
});
});
});
describe('Functions', () => {
it('should remove member on remove button click', async () => {
const member = getMockTeamMember();
const mockRemove = jest.fn();
setup({ member, removeTeamMember: mockRemove, editorsCanAdmin: true, signedInUserIsTeamAdmin: true });
await userEvent.click(screen.getByRole('button', { name: `Remove team member ${member.name}` }));
await userEvent.click(screen.getByRole('button', { name: 'Delete' }));
expect(mockRemove).toHaveBeenCalledWith(member.userId);
});
it('should update permission for user in team', async () => {
const member = getMockTeamMember();
const mockUpdate = jest.fn();
setup({ member, editorsCanAdmin: true, signedInUserIsTeamAdmin: true, updateTeamMember: mockUpdate });
const permission = TeamPermissionLevel.Admin;
const expectedTeamMember = { ...member, permission };
await userEvent.click(screen.getByLabelText(`Select member's ${member.name} permission level`));
await userEvent.click(screen.getByText('Admin'));
expect(mockUpdate).toHaveBeenCalledWith(expectedTeamMember);
});
});

View File

@ -1,115 +0,0 @@
import React, { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { SelectableValue } from '@grafana/data';
import { Select, DeleteButton } from '@grafana/ui';
import { TagBadge } from 'app/core/components/TagFilter/TagBadge';
import { WithFeatureToggle } from 'app/core/components/WithFeatureToggle';
import { TeamMember, teamsPermissionLevels, TeamPermissionLevel } from 'app/types';
import { updateTeamMember, removeTeamMember } from './state/actions';
const mapDispatchToProps = {
removeTeamMember,
updateTeamMember,
};
const connector = connect(null, mapDispatchToProps);
interface OwnProps {
member: TeamMember;
syncEnabled: boolean;
editorsCanAdmin: boolean;
signedInUserIsTeamAdmin: boolean;
}
export type Props = ConnectedProps<typeof connector> & OwnProps;
export class TeamMemberRow extends PureComponent<Props> {
constructor(props: Props) {
super(props);
this.renderLabels = this.renderLabels.bind(this);
this.renderPermissions = this.renderPermissions.bind(this);
}
onRemoveMember(member: TeamMember) {
this.props.removeTeamMember(member.userId);
}
onPermissionChange = (item: SelectableValue<TeamPermissionLevel>, member: TeamMember) => {
const permission = item.value;
const updatedTeamMember: TeamMember = {
...member,
permission: permission as number,
};
this.props.updateTeamMember(updatedTeamMember);
};
renderPermissions(member: TeamMember) {
const { editorsCanAdmin, signedInUserIsTeamAdmin } = this.props;
const value = teamsPermissionLevels.find((dp) => dp.value === member.permission)!;
return (
<WithFeatureToggle featureToggle={editorsCanAdmin}>
<td className="width-5 team-permissions">
{signedInUserIsTeamAdmin ? (
<Select
isSearchable={false}
options={teamsPermissionLevels}
onChange={(item) => this.onPermissionChange(item, member)}
value={value}
width={32}
aria-label={`Select member's ${member.name} permission level`}
/>
) : (
<span>{value.label}</span>
)}
</td>
</WithFeatureToggle>
);
}
renderLabels(labels: string[]) {
if (!labels) {
return <td />;
}
return (
<td>
{labels.map((label) => (
<TagBadge key={label} label={label} removeIcon={false} count={0} />
))}
</td>
);
}
render() {
const { member, syncEnabled, signedInUserIsTeamAdmin } = this.props;
return (
<tr key={member.userId}>
<td className="width-4 text-center">
<img
alt={`Avatar for team member "${member.name}"`}
className="filter-table__avatar"
src={member.avatarUrl}
/>
</td>
<td>{member.login}</td>
<td>{member.email}</td>
<td>{member.name}</td>
{this.renderPermissions(member)}
{syncEnabled && this.renderLabels(member.labels)}
<td className="text-right">
<DeleteButton
aria-label={`Remove team member ${member.name}`}
size="sm"
disabled={!signedInUserIsTeamAdmin}
onConfirm={() => this.onRemoveMember(member)}
/>
</td>
</tr>
);
}
}
export default connector(TeamMemberRow);

View File

@ -1,68 +0,0 @@
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { Provider } from 'react-redux';
import { mockToolkitActionCreator } from 'test/core/redux/mocks';
import { User } from 'app/core/services/context_srv';
import { configureStore } from 'app/store/configureStore';
import { OrgRole, TeamMember } from '../../types';
import { Props, TeamMembers } from './TeamMembers';
import { getMockTeamMembers } from './__mocks__/teamMocks';
import { setSearchMemberQuery } from './state/reducers';
const signedInUserId = 1;
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => ({
get: jest.fn().mockResolvedValue([{ userId: 1, login: 'Test' }]),
}),
config: {
...jest.requireActual('@grafana/runtime').config,
bootData: { navTree: [], user: {} },
},
}));
const setup = (propOverrides?: object) => {
const store = configureStore();
const props: Props = {
members: [] as TeamMember[],
searchMemberQuery: '',
setSearchMemberQuery: mockToolkitActionCreator(setSearchMemberQuery),
addTeamMember: jest.fn(),
syncEnabled: false,
editorsCanAdmin: false,
signedInUser: {
id: signedInUserId,
isGrafanaAdmin: false,
orgRole: OrgRole.Viewer,
} as User,
};
Object.assign(props, propOverrides);
render(
<Provider store={store}>
<TeamMembers {...props} />
</Provider>
);
};
describe('TeamMembers', () => {
it('should render team members', async () => {
setup({ members: getMockTeamMembers(1, 1) });
expect(await screen.findAllByRole('row')).toHaveLength(2);
});
it('should add user to a team', async () => {
const mockAdd = jest.fn();
setup({ addTeamMember: mockAdd });
await userEvent.type(screen.getByLabelText('User picker'), 'Test{enter}');
await userEvent.click(screen.getByRole('button', { name: 'Add to team' }));
await waitFor(() => expect(mockAdd).toHaveBeenCalledWith(1));
});
});

View File

@ -1,149 +0,0 @@
import React, { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { SelectableValue } from '@grafana/data';
import { Button, FilterInput, Label, InlineField } from '@grafana/ui';
import { SlideDown } from 'app/core/components/Animations/SlideDown';
import { CloseButton } from 'app/core/components/CloseButton/CloseButton';
import { UserPicker } from 'app/core/components/Select/UserPicker';
import { TagBadge } from 'app/core/components/TagFilter/TagBadge';
import { WithFeatureToggle } from 'app/core/components/WithFeatureToggle';
import { config } from 'app/core/config';
import { contextSrv } from 'app/core/services/context_srv';
import { TeamMember, OrgUser } from 'app/types';
import TeamMemberRow from './TeamMemberRow';
import { addTeamMember } from './state/actions';
import { setSearchMemberQuery } from './state/reducers';
import { getSearchMemberQuery, isSignedInUserTeamAdmin } from './state/selectors';
function mapStateToProps(state: any) {
return {
searchMemberQuery: getSearchMemberQuery(state.team),
editorsCanAdmin: config.editorsCanAdmin, // this makes the feature toggle mockable/controllable from tests,
signedInUser: contextSrv.user, // this makes the feature toggle mockable/controllable from tests,
};
}
const mapDispatchToProps = {
addTeamMember,
setSearchMemberQuery,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
interface OwnProps {
members: TeamMember[];
syncEnabled: boolean;
}
export type Props = ConnectedProps<typeof connector> & OwnProps;
export interface State {
isAdding: boolean;
newTeamMember?: SelectableValue<OrgUser['userId']> | null;
}
export class TeamMembers extends PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = { isAdding: false, newTeamMember: null };
}
onSearchQueryChange = (value: string) => {
this.props.setSearchMemberQuery(value);
};
onToggleAdding = () => {
this.setState({ isAdding: !this.state.isAdding });
};
onUserSelected = (user: SelectableValue<OrgUser['userId']>) => {
this.setState({ newTeamMember: user });
};
onAddUserToTeam = async () => {
this.props.addTeamMember(this.state.newTeamMember!.id);
this.setState({ newTeamMember: null });
};
renderLabels(labels: string[]) {
if (!labels) {
return <td />;
}
return (
<td>
{labels.map((label) => (
<TagBadge key={label} label={label} removeIcon={false} count={0} />
))}
</td>
);
}
render() {
const { isAdding } = this.state;
const { searchMemberQuery, members, syncEnabled, editorsCanAdmin, signedInUser } = this.props;
const isTeamAdmin = isSignedInUserTeamAdmin({ members, editorsCanAdmin, signedInUser });
return (
<div>
<div className="page-action-bar">
<InlineField grow>
<FilterInput placeholder="Search members" value={searchMemberQuery} onChange={this.onSearchQueryChange} />
</InlineField>
<Button className="pull-right" onClick={this.onToggleAdding} disabled={isAdding || !isTeamAdmin}>
Add member
</Button>
</div>
<SlideDown in={isAdding}>
<div className="cta-form">
<CloseButton aria-label="Close 'Add team member' dialogue" onClick={this.onToggleAdding} />
<Label htmlFor="user-picker">Add team member</Label>
<div className="gf-form-inline">
<UserPicker inputId="user-picker" onSelected={this.onUserSelected} className="min-width-30" />
{this.state.newTeamMember && (
<Button type="submit" onClick={this.onAddUserToTeam}>
Add to team
</Button>
)}
</div>
</div>
</SlideDown>
<div className="admin-list-table">
<table className="filter-table filter-table--hover form-inline">
<thead>
<tr>
<th />
<th>Login</th>
<th>Email</th>
<th>Name</th>
<WithFeatureToggle featureToggle={editorsCanAdmin}>
<th>Permission</th>
</WithFeatureToggle>
{syncEnabled && <th />}
<th style={{ width: '1%' }} />
</tr>
</thead>
<tbody>
{members &&
members.map((member) => (
<TeamMemberRow
key={member.userId}
member={member}
syncEnabled={syncEnabled}
editorsCanAdmin={editorsCanAdmin}
signedInUserIsTeamAdmin={isTeamAdmin}
/>
))}
</tbody>
</table>
</div>
</div>
);
}
}
export default connector(TeamMembers);

View File

@ -5,9 +5,8 @@ import { TestProvider } from 'test/helpers/TestProvider';
import { createTheme } from '@grafana/data';
import { getRouteComponentProps } from 'app/core/navigation/__mocks__/routeProps';
import { User } from 'app/core/services/context_srv';
import { OrgRole, Team, TeamMember } from '../../types';
import { Team } from '../../types';
import { Props, TeamPages } from './TeamPages';
import { getMockTeam } from './__mocks__/teamMocks';
@ -18,9 +17,8 @@ jest.mock('app/core/components/Select/UserPicker', () => {
jest.mock('app/core/services/context_srv', () => ({
contextSrv: {
accessControlEnabled: () => false,
hasPermissionInMetadata: () => false,
hasAccessInMetadata: () => true,
accessControlEnabled: () => true,
hasPermissionInMetadata: () => true,
user: {},
},
}));
@ -36,7 +34,7 @@ jest.mock('@grafana/runtime', () => ({
stateInfo: '',
licenseUrl: '',
},
featureToggles: { accesscontrol: false },
featureToggles: { accesscontrol: true },
bootData: { navTree: [], user: {} },
buildInfo: {
edition: 'Open Source',
@ -76,17 +74,9 @@ const setup = (propOverrides?: object) => {
pageNav: { text: 'Cool team ' },
teamId: 1,
loadTeam: jest.fn(),
loadTeamMembers: jest.fn(),
pageName: 'members',
team: {} as Team,
members: [] as TeamMember[],
editorsCanAdmin: false,
theme: createTheme(),
signedInUser: {
id: 1,
isGrafanaAdmin: false,
orgRole: OrgRole.Viewer,
} as User,
};
Object.assign(props, propOverrides);
@ -99,13 +89,6 @@ const setup = (propOverrides?: object) => {
};
describe('TeamPages', () => {
it('should render member page if team not empty', async () => {
setup({
team: getMockTeam(),
});
expect(await screen.findByRole('button', { name: 'Add member' })).toBeInTheDocument();
});
it('should render settings and preferences page', async () => {
setup({
team: getMockTeam(),
@ -128,26 +111,4 @@ describe('TeamPages', () => {
expect(await screen.findByText('Team group sync')).toBeInTheDocument();
});
describe('when feature toggle editorsCanAdmin is turned on', () => {
it('should render settings page if user is team admin', async () => {
setup({
team: getMockTeam(),
pageName: 'settings',
preferences: {
homeDashboardUID: 'home-dashboard',
theme: 'Default',
timezone: 'Default',
},
editorsCanAdmin: true,
signedInUser: {
id: 1,
isGrafanaAdmin: false,
orgRole: OrgRole.Admin,
} as User,
});
expect(await screen.findByText('Team settings')).toBeInTheDocument();
});
});
});

View File

@ -2,7 +2,6 @@ import { includes } from 'lodash';
import React, { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { NavModelItem } from '@grafana/data';
import { featureEnabled } from '@grafana/runtime';
import { Themeable2, withTheme2 } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
@ -14,12 +13,11 @@ import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction, StoreState } from 'app/types';
import TeamGroupSync, { TeamSyncUpgradeContent } from './TeamGroupSync';
import TeamMembers from './TeamMembers';
import TeamPermissions from './TeamPermissions';
import TeamSettings from './TeamSettings';
import { loadTeam, loadTeamMembers } from './state/actions';
import { loadTeam } from './state/actions';
import { getTeamLoadingNav } from './state/navModel';
import { getTeam, getTeamMembers, isSignedInUserTeamAdmin } from './state/selectors';
import { getTeam } from './state/selectors';
interface TeamPageRouteParams {
id: string;
@ -52,22 +50,17 @@ function mapStateToProps(state: StoreState, props: OwnProps) {
const pageName = props.match.params.page ?? defaultPage;
const teamLoadingNav = getTeamLoadingNav(pageName);
const pageNav = getNavModel(state.navIndex, `team-${pageName}-${teamId}`, teamLoadingNav).main;
const members = getTeamMembers(state.team);
return {
pageNav,
teamId: teamId,
pageName: pageName,
team,
members,
editorsCanAdmin: config.editorsCanAdmin, // this makes the feature toggle mockable/controllable from tests,
signedInUser: contextSrv.user, // this makes the feature toggle mockable/controllable from tests,
};
}
const mapDispatchToProps = {
loadTeam,
loadTeamMembers,
};
const connector = connect(mapStateToProps, mapDispatchToProps);
@ -92,10 +85,6 @@ export class TeamPages extends PureComponent<Props, State> {
const { loadTeam, teamId } = this.props;
this.setState({ isLoading: true });
const team = await loadTeam(teamId);
// With accesscontrol, the TeamPermissions will fetch team members
if (!contextSrv.accessControlEnabled()) {
await this.props.loadTeamMembers();
}
this.setState({ isLoading: false });
return team;
}
@ -118,49 +107,25 @@ export class TeamPages extends PureComponent<Props, State> {
return text1.toLocaleLowerCase() === text2.toLocaleLowerCase();
};
hideTabsFromNonTeamAdmin = (pageNav: NavModelItem, isSignedInUserTeamAdmin: boolean) => {
if (contextSrv.accessControlEnabled()) {
return pageNav;
}
if (!isSignedInUserTeamAdmin && pageNav && pageNav.children) {
pageNav.children
.filter((navItem) => !this.textsAreEqual(navItem.text, PageTypes.Members))
.map((navItem) => {
navItem.hideFromTabs = true;
});
}
return pageNav;
};
renderPage(isSignedInUserTeamAdmin: boolean): React.ReactNode {
renderPage(): React.ReactNode {
const { isSyncEnabled } = this.state;
const { members, team } = this.props;
const { team } = this.props;
const currentPage = this.getCurrentPage();
const canReadTeam = contextSrv.hasAccessInMetadata(
AccessControlAction.ActionTeamsRead,
team!,
isSignedInUserTeamAdmin
);
const canReadTeamPermissions = contextSrv.hasAccessInMetadata(
const canReadTeam = contextSrv.hasPermissionInMetadata(AccessControlAction.ActionTeamsRead, team!);
const canReadTeamPermissions = contextSrv.hasPermissionInMetadata(
AccessControlAction.ActionTeamsPermissionsRead,
team!,
isSignedInUserTeamAdmin
team!
);
const canWriteTeamPermissions = contextSrv.hasAccessInMetadata(
const canWriteTeamPermissions = contextSrv.hasPermissionInMetadata(
AccessControlAction.ActionTeamsPermissionsWrite,
team!,
isSignedInUserTeamAdmin
team!
);
switch (currentPage) {
case PageTypes.Members:
if (contextSrv.accessControlEnabled()) {
if (canReadTeamPermissions) {
return <TeamPermissions team={team!} />;
} else {
return <TeamMembers syncEnabled={isSyncEnabled} members={members} />;
}
case PageTypes.Settings:
return canReadTeam && <TeamSettings team={team!} />;
@ -183,13 +148,12 @@ export class TeamPages extends PureComponent<Props, State> {
}
render() {
const { team, pageNav, members, editorsCanAdmin, signedInUser } = this.props;
const isTeamAdmin = isSignedInUserTeamAdmin({ members, editorsCanAdmin, signedInUser });
const { team, pageNav } = this.props;
return (
<Page navId="teams" pageNav={this.hideTabsFromNonTeamAdmin(pageNav, isTeamAdmin)}>
<Page navId="teams" pageNav={pageNav}>
<Page.Contents isLoading={this.state.isLoading}>
{team && Object.keys(team).length !== 0 && this.renderPage(isTeamAdmin)}
{team && Object.keys(team).length !== 0 && this.renderPage()}
</Page.Contents>
</Page>
);

View File

@ -30,8 +30,12 @@ export const TeamSettings = ({ team, updateTeam }: Props) => {
const [pendingRoles, setPendingRoles] = useState<Role[]>([]);
const canUpdateRoles =
contextSrv.hasPermission(AccessControlAction.ActionUserRolesAdd) &&
contextSrv.hasPermission(AccessControlAction.ActionUserRolesRemove);
contextSrv.hasPermission(AccessControlAction.ActionTeamsRolesAdd) &&
contextSrv.hasPermission(AccessControlAction.ActionTeamsRolesRemove);
const canListRoles =
contextSrv.hasPermissionInMetadata(AccessControlAction.ActionTeamsRolesList, team) &&
contextSrv.hasPermission(AccessControlAction.ActionRolesList);
return (
<VerticalGroup spacing="lg">
@ -57,12 +61,12 @@ export const TeamSettings = ({ team, updateTeam }: Props) => {
<Input {...register('name', { required: true })} id="name-input" />
</Field>
{contextSrv.licensedAccessControlEnabled() && (
{contextSrv.licensedAccessControlEnabled() && canListRoles && (
<Field label="Role">
<TeamRolePicker
teamId={team.id}
roleOptions={roleOptions}
disabled={false}
disabled={!canUpdateRoles}
apply={true}
onApplyRoles={setPendingRoles}
pendingRoles={pendingRoles}

View File

@ -23,25 +23,6 @@ export const getMockTeam = (i = 1, overrides = {}): Team => {
};
};
export const getMockTeamMembers = (amount: number, teamAdminId: number): TeamMember[] => {
const teamMembers: TeamMember[] = [];
for (let i = 1; i <= amount; i++) {
teamMembers.push({
userId: i,
teamId: 1,
avatarUrl: 'some/url/',
email: 'test@test.com',
name: 'testName',
login: `testUser-${i}`,
labels: ['label 1', 'label 2'],
permission: i === teamAdminId ? TeamPermissionLevel.Admin : TeamPermissionLevel.Member,
});
}
return teamMembers;
};
export const getMockTeamMember = (): TeamMember => {
return {
userId: 1,

View File

@ -1,9 +1,7 @@
import { User } from 'app/core/services/context_srv';
import { TeamState } from '../../../types';
import { getMockTeam } from '../__mocks__/teamMocks';
import { Team, TeamGroup, TeamState, OrgRole } from '../../../types';
import { getMockTeam, getMockTeamMembers } from '../__mocks__/teamMocks';
import { getTeam, getTeamMembers, isSignedInUserTeamAdmin, Config } from './selectors';
import { getTeam } from './selectors';
describe('Team selectors', () => {
describe('Get team', () => {
@ -21,111 +19,4 @@ describe('Team selectors', () => {
expect(team).toEqual(mockTeam);
});
});
describe('Get members', () => {
const mockTeamMembers = getMockTeamMembers(5, 5);
it('should return team members', () => {
const mockState: TeamState = {
team: {} as Team,
searchMemberQuery: '',
members: mockTeamMembers,
groups: [] as TeamGroup[],
};
const members = getTeamMembers(mockState);
expect(members).toEqual(mockTeamMembers);
});
});
});
const signedInUserId = 1;
const setup = (configOverrides?: Partial<Config>) => {
const defaultConfig: Config = {
editorsCanAdmin: false,
members: getMockTeamMembers(5, 5),
signedInUser: {
id: signedInUserId,
isGrafanaAdmin: false,
orgRole: OrgRole.Viewer,
} as User,
};
return { ...defaultConfig, ...configOverrides };
};
describe('isSignedInUserTeamAdmin', () => {
describe('when feature toggle editorsCanAdmin is turned off', () => {
it('should return true', () => {
const config = setup();
const result = isSignedInUserTeamAdmin(config);
expect(result).toBe(true);
});
});
describe('when feature toggle editorsCanAdmin is turned on', () => {
it('should return true if signed in user is grafanaAdmin', () => {
const config = setup({
editorsCanAdmin: true,
signedInUser: {
id: signedInUserId,
isGrafanaAdmin: true,
orgRole: OrgRole.Viewer,
} as User,
});
const result = isSignedInUserTeamAdmin(config);
expect(result).toBe(true);
});
it('should return true if signed in user is org admin', () => {
const config = setup({
editorsCanAdmin: true,
signedInUser: {
id: signedInUserId,
isGrafanaAdmin: false,
orgRole: OrgRole.Admin,
} as User,
});
const result = isSignedInUserTeamAdmin(config);
expect(result).toBe(true);
});
it('should return true if signed in user is team admin', () => {
const config = setup({
members: getMockTeamMembers(5, signedInUserId),
editorsCanAdmin: true,
signedInUser: {
id: signedInUserId,
isGrafanaAdmin: false,
orgRole: OrgRole.Viewer,
} as User,
});
const result = isSignedInUserTeamAdmin(config);
expect(result).toBe(true);
});
it('should return false if signed in user is not grafanaAdmin, org admin or team admin', () => {
const config = setup({
editorsCanAdmin: true,
signedInUser: {
id: signedInUserId,
isGrafanaAdmin: false,
orgRole: OrgRole.Viewer,
} as User,
});
const result = isSignedInUserTeamAdmin(config);
expect(result).toBe(false);
});
});
});

View File

@ -1,7 +1,5 @@
import { User } from 'app/core/services/context_srv';
import { Team, TeamState, TeamMember, OrgRole, TeamPermissionLevel } from 'app/types';
import { Team, TeamState } from 'app/types';
export const getSearchMemberQuery = (state: TeamState) => state.searchMemberQuery;
export const getTeamGroups = (state: TeamState) => state.groups;
export const getTeam = (state: TeamState, currentTeamId: any): Team | null => {
@ -11,40 +9,3 @@ export const getTeam = (state: TeamState, currentTeamId: any): Team | null => {
return null;
};
export const getTeamMembers = (state: TeamState) => {
const regex = RegExp(state.searchMemberQuery, 'i');
return state.members.filter((member) => {
return regex.test(member.login) || regex.test(member.email) || regex.test(member.name);
});
};
export interface Config {
members: TeamMember[];
editorsCanAdmin: boolean;
signedInUser: User;
}
export const isSignedInUserTeamAdmin = (config: Config): boolean => {
const { members, signedInUser, editorsCanAdmin } = config;
const userInMembers = members.find((m) => m.userId === signedInUser.id);
const permission = userInMembers ? userInMembers.permission : TeamPermissionLevel.Member;
return isPermissionTeamAdmin({ permission, signedInUser, editorsCanAdmin });
};
export interface PermissionConfig {
permission: TeamPermissionLevel;
editorsCanAdmin: boolean;
signedInUser: User;
}
export const isPermissionTeamAdmin = (config: PermissionConfig): boolean => {
const { permission, signedInUser, editorsCanAdmin } = config;
const isAdmin = signedInUser.isGrafanaAdmin || signedInUser.orgRole === OrgRole.Admin;
const userIsTeamAdmin = permission === TeamPermissionLevel.Admin;
const isSignedInUserTeamAdmin = isAdmin || userIsTeamAdmin;
return isSignedInUserTeamAdmin || !editorsCanAdmin;
};