diff --git a/public/app/core/components/RolePicker/RolePicker.tsx b/public/app/core/components/RolePicker/RolePicker.tsx index b7c1e8de11c..9fed8ad8b43 100644 --- a/public/app/core/components/RolePicker/RolePicker.tsx +++ b/public/app/core/components/RolePicker/RolePicker.tsx @@ -16,8 +16,9 @@ export interface Props { disabled?: boolean; builtinRolesDisabled?: boolean; showBuiltInRole?: boolean; - onRolesChange: (newRoles: string[]) => void; + onRolesChange: (newRoles: Role[]) => void; onBuiltinRoleChange?: (newRole: OrgRole) => void; + updateDisabled?: boolean; } export const RolePicker = ({ @@ -30,6 +31,7 @@ export const RolePicker = ({ showBuiltInRole, onRolesChange, onBuiltinRoleChange, + updateDisabled, }: Props): JSX.Element | null => { const [isOpen, setOpen] = useState(false); const [selectedRoles, setSelectedRoles] = useState(appliedRoles); @@ -39,8 +41,9 @@ export const RolePicker = ({ const ref = useRef(null); useEffect(() => { + setSelectedBuiltInRole(builtInRole); setSelectedRoles(appliedRoles); - }, [appliedRoles]); + }, [appliedRoles, builtInRole]); useEffect(() => { const dimensions = ref?.current?.getBoundingClientRect(); @@ -94,7 +97,7 @@ export const RolePicker = ({ setSelectedBuiltInRole(role); }; - const onUpdate = (newRoles: string[], newBuiltInRole?: OrgRole) => { + const onUpdate = (newRoles: Role[], newBuiltInRole?: OrgRole) => { if (onBuiltinRoleChange && newBuiltInRole) { onBuiltinRoleChange(newBuiltInRole); } @@ -144,6 +147,7 @@ export const RolePicker = ({ showGroups={query.length === 0 || query.trim() === ''} builtinRolesDisabled={builtinRolesDisabled} showBuiltInRole={showBuiltInRole} + updateDisabled={updateDisabled || false} offset={offset} /> )} diff --git a/public/app/core/components/RolePicker/RolePickerMenu.tsx b/public/app/core/components/RolePicker/RolePickerMenu.tsx index 0d845321c5a..29487fae656 100644 --- a/public/app/core/components/RolePicker/RolePickerMenu.tsx +++ b/public/app/core/components/RolePicker/RolePickerMenu.tsx @@ -39,8 +39,9 @@ interface RolePickerMenuProps { showBuiltInRole?: boolean; onSelect: (roles: Role[]) => void; onBuiltInRoleSelect?: (role: OrgRole) => void; - onUpdate: (newRoles: string[], newBuiltInRole?: OrgRole) => void; + onUpdate: (newRoles: Role[], newBuiltInRole?: OrgRole) => void; onClear?: () => void; + updateDisabled?: boolean; offset: number; } @@ -55,6 +56,7 @@ export const RolePickerMenu = ({ onBuiltInRoleSelect, onUpdate, onClear, + updateDisabled, offset, }: RolePickerMenuProps): JSX.Element => { const [selectedOptions, setSelectedOptions] = useState(appliedRoles); @@ -166,11 +168,12 @@ export const RolePickerMenu = ({ const onUpdateInternal = () => { const selectedCustomRoles: string[] = []; + // TODO: needed? for (const key in selectedOptions) { const roleUID = selectedOptions[key]?.uid; selectedCustomRoles.push(roleUID); } - onUpdate(selectedCustomRoles, selectedBuiltInRole); + onUpdate(selectedOptions, selectedBuiltInRole); }; return ( @@ -270,7 +273,7 @@ export const RolePickerMenu = ({ Clear all diff --git a/public/app/core/components/RolePicker/TeamRolePicker.tsx b/public/app/core/components/RolePicker/TeamRolePicker.tsx index dafbd3e78ab..103174a48fa 100644 --- a/public/app/core/components/RolePicker/TeamRolePicker.tsx +++ b/public/app/core/components/RolePicker/TeamRolePicker.tsx @@ -4,6 +4,7 @@ import { useAsyncFn } from 'react-use'; import { Role } from 'app/types'; import { RolePicker } from './RolePicker'; +// @ts-ignore import { fetchTeamRoles, updateTeamRoles } from './api'; export interface Props { @@ -29,7 +30,7 @@ export const TeamRolePicker: FC = ({ teamId, orgId, roleOptions, disabled getTeamRoles(); }, [orgId, teamId, getTeamRoles]); - const onRolesChange = async (roles: string[]) => { + const onRolesChange = async (roles: Role[]) => { await updateTeamRoles(roles, teamId, orgId); await getTeamRoles(); }; diff --git a/public/app/core/components/RolePicker/UserRolePicker.tsx b/public/app/core/components/RolePicker/UserRolePicker.tsx index e32bcb746ae..363c4d90721 100644 --- a/public/app/core/components/RolePicker/UserRolePicker.tsx +++ b/public/app/core/components/RolePicker/UserRolePicker.tsx @@ -16,6 +16,9 @@ export interface Props { builtInRoles?: { [key: string]: Role[] }; disabled?: boolean; builtinRolesDisabled?: boolean; + updateDisabled?: boolean; + onApplyRoles?: (newRoles: Role[], userId: number, orgId: number | undefined) => void; + pendingRoles?: Role[]; } export const UserRolePicker: FC = ({ @@ -27,9 +30,17 @@ export const UserRolePicker: FC = ({ builtInRoles, disabled, builtinRolesDisabled, + updateDisabled, + onApplyRoles, + pendingRoles, }) => { const [{ loading, value: appliedRoles = [] }, getUserRoles] = useAsyncFn(async () => { try { + if (updateDisabled) { + if (pendingRoles?.length! > 0) { + return pendingRoles; + } + } if (contextSrv.hasPermission(AccessControlAction.ActionUserRolesList)) { return await fetchUserRoles(userId, orgId); } @@ -38,29 +49,39 @@ export const UserRolePicker: FC = ({ console.error('Error loading options'); } return []; - }, [orgId, userId]); + }, [orgId, userId, pendingRoles]); useEffect(() => { - getUserRoles(); - }, [orgId, userId, getUserRoles]); + // only load roles when there is an Org selected + if (orgId) { + getUserRoles(); + } + }, [orgId, getUserRoles, pendingRoles]); - const onRolesChange = async (roles: string[]) => { - await updateUserRoles(roles, userId, orgId); - await getUserRoles(); + const onRolesChange = async (roles: Role[]) => { + if (!updateDisabled) { + await updateUserRoles(roles, userId, orgId); + await getUserRoles(); + } else { + if (onApplyRoles) { + onApplyRoles(roles, userId, orgId); + } + } }; return ( ); }; diff --git a/public/app/core/components/RolePicker/api.ts b/public/app/core/components/RolePicker/api.ts index 20ea7076242..9f07f61a724 100644 --- a/public/app/core/components/RolePicker/api.ts +++ b/public/app/core/components/RolePicker/api.ts @@ -38,11 +38,12 @@ export const fetchUserRoles = async (userId: number, orgId?: number): Promise { +export const updateUserRoles = (roles: Role[], userId: number, orgId?: number) => { let userRolesUrl = `/api/access-control/users/${userId}/roles`; if (orgId) { userRolesUrl += `?targetOrgId=${orgId}`; } + const roleUids = roles.flatMap((x) => x.uid); return getBackendSrv().put(userRolesUrl, { orgId, roleUids, @@ -66,11 +67,13 @@ export const fetchTeamRoles = async (teamId: number, orgId?: number): Promise { +export const updateTeamRoles = (roles: Role[], teamId: number, orgId?: number) => { let teamRolesUrl = `/api/access-control/teams/${teamId}/roles`; if (orgId) { teamRolesUrl += `?targetOrgId=${orgId}`; } + const roleUids = roles.flatMap((x) => x.uid); + return getBackendSrv().put(teamRolesUrl, { orgId, roleUids, diff --git a/public/app/core/components/Select/OrgPicker.tsx b/public/app/core/components/Select/OrgPicker.tsx index a991d393646..35a1d33d467 100644 --- a/public/app/core/components/Select/OrgPicker.tsx +++ b/public/app/core/components/Select/OrgPicker.tsx @@ -4,7 +4,7 @@ import { useAsyncFn } from 'react-use'; import { SelectableValue } from '@grafana/data'; import { AsyncSelect } from '@grafana/ui'; import { getBackendSrv } from 'app/core/services/backend_srv'; -import { Organization } from 'app/types'; +import { Organization, UserOrg } from 'app/types'; export type OrgSelectItem = SelectableValue; @@ -13,9 +13,10 @@ export interface Props { className?: string; inputId?: string; autoFocus?: boolean; + excludeOrgs?: UserOrg[]; } -export function OrgPicker({ onSelected, className, inputId, autoFocus }: Props) { +export function OrgPicker({ onSelected, className, inputId, autoFocus, excludeOrgs }: Props) { // For whatever reason the autoFocus prop doesn't seem to work // with AsyncSelect, hence this workaround. Maybe fixed in a later version? useEffect(() => { @@ -26,7 +27,16 @@ export function OrgPicker({ onSelected, className, inputId, autoFocus }: Props) const [orgOptionsState, getOrgOptions] = useAsyncFn(async () => { const orgs: Organization[] = await getBackendSrv().get('/api/orgs'); - return orgs.map((org) => ({ value: { id: org.id, name: org.name }, label: org.name })); + const allOrgs = orgs.map((org) => ({ value: { id: org.id, name: org.name }, label: org.name })); + if (excludeOrgs) { + let idArray = excludeOrgs.map((anOrg) => anOrg.orgId); + const filteredOrgs = allOrgs.filter((item) => { + return !idArray.includes(item.value.id); + }); + return filteredOrgs; + } else { + return allOrgs; + } }); return ( diff --git a/public/app/features/admin/UserOrgs.tsx b/public/app/features/admin/UserOrgs.tsx index 1531ab70df4..bb90b80a665 100644 --- a/public/app/features/admin/UserOrgs.tsx +++ b/public/app/features/admin/UserOrgs.tsx @@ -17,10 +17,10 @@ import { withTheme, } from '@grafana/ui'; import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker'; -import { fetchRoleOptions } from 'app/core/components/RolePicker/api'; +import { fetchRoleOptions, updateUserRoles } from 'app/core/components/RolePicker/api'; import { OrgPicker, OrgSelectItem } from 'app/core/components/Select/OrgPicker'; import { contextSrv } from 'app/core/core'; -import { AccessControlAction, Organization, OrgRole, UserDTO, UserOrg } from 'app/types'; +import { AccessControlAction, Organization, OrgRole, Role, UserDTO, UserOrg } from 'app/types'; import { OrgRolePicker } from './OrgRolePicker'; @@ -88,7 +88,13 @@ export class UserOrgs extends PureComponent { )} - + ); @@ -155,8 +161,9 @@ class UnThemedOrgRow extends PureComponent { } } - onOrgRemove = () => { - const { org } = this.props; + onOrgRemove = async () => { + const { org, user } = this.props; + user && (await updateUserRoles([], user.id, org.orgId)); this.props.onOrgRemove(org.orgId); }; @@ -272,7 +279,8 @@ const getAddToOrgModalStyles = stylesFactory(() => ({ interface AddToOrgModalProps { isOpen: boolean; - + user?: UserDTO; + userOrgs: UserOrg[]; onOrgAdd(orgId: number, role: string): void; onDismiss?(): void; @@ -281,16 +289,32 @@ interface AddToOrgModalProps { interface AddToOrgModalState { selectedOrg: Organization | null; role: OrgRole; + roleOptions: Role[]; + pendingOrgId: number | null; + pendingUserId: number | null; + pendingRoles: Role[]; } export class AddToOrgModal extends PureComponent { state: AddToOrgModalState = { selectedOrg: null, - role: OrgRole.Admin, + role: OrgRole.Viewer, + roleOptions: [], + pendingOrgId: null, + pendingUserId: null, + pendingRoles: [], }; onOrgSelect = (org: OrgSelectItem) => { - this.setState({ selectedOrg: org.value! }); + const userOrg = this.props.userOrgs.find((userOrg) => userOrg.orgId === org.value?.id); + this.setState({ selectedOrg: org.value!, role: userOrg?.role || OrgRole.Viewer }); + if (contextSrv.licensedAccessControlEnabled()) { + if (contextSrv.hasPermission(AccessControlAction.ActionRolesList)) { + fetchRoleOptions(org.value?.id) + .then((roles) => this.setState({ roleOptions: roles })) + .catch((e) => console.error(e)); + } + } }; onOrgRoleChange = (newRole: OrgRole) => { @@ -299,20 +323,48 @@ export class AddToOrgModal extends PureComponent { + onAddUserToOrg = async () => { const { selectedOrg, role } = this.state; this.props.onOrgAdd(selectedOrg!.id, role); + // add the stored userRoles also + if (contextSrv.licensedAccessControlEnabled()) { + if (contextSrv.hasPermission(AccessControlAction.OrgUsersRoleUpdate)) { + if (this.state.pendingUserId) { + await updateUserRoles(this.state.pendingRoles, this.state.pendingUserId!, this.state.pendingOrgId!); + // clear pending state + this.state.pendingOrgId = null; + this.state.pendingRoles = []; + this.state.pendingUserId = null; + } + } + } }; onCancel = () => { + // clear selectedOrg when modal is canceled + this.setState({ + selectedOrg: null, + pendingRoles: [], + pendingOrgId: null, + pendingUserId: null, + }); if (this.props.onDismiss) { this.props.onDismiss(); } }; + onRoleUpdate = async (roles: Role[], userId: number, orgId: number | undefined) => { + // keep the new role assignments for user + this.setState({ + pendingRoles: roles, + pendingOrgId: orgId!, + pendingUserId: userId, + }); + }; + render() { - const { isOpen } = this.props; - const { role } = this.state; + const { isOpen, user, userOrgs } = this.props; + const { role, roleOptions, selectedOrg } = this.state; const styles = getAddToOrgModalStyles(); return ( - + - - + + {contextSrv.accessControlEnabled() ? ( + + ) : ( + + )} - diff --git a/public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx b/public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx index ace8e467279..9b26f984a95 100644 --- a/public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountCreatePage.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { connect } from 'react-redux'; import { useHistory } from 'react-router-dom'; @@ -6,6 +6,10 @@ import { NavModel } from '@grafana/data'; import { getBackendSrv } from '@grafana/runtime'; import { Form, Button, Input, Field } from '@grafana/ui'; import Page from 'app/core/components/Page/Page'; +import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker'; +import { fetchBuiltinRoles, fetchRoleOptions, updateUserRoles } from 'app/core/components/RolePicker/api'; +import { contextSrv } from 'app/core/core'; +import { AccessControlAction, OrgRole, Role, ServiceAccountCreateApiResponse, ServiceAccountDTO } from 'app/types'; import { getNavModel } from '../../core/selectors/navModel'; import { StoreState } from '../../types'; @@ -13,23 +17,89 @@ import { StoreState } from '../../types'; interface ServiceAccountCreatePageProps { navModel: NavModel; } -interface ServiceAccountDTO { - name: string; -} const createServiceAccount = async (sa: ServiceAccountDTO) => getBackendSrv().post('/api/serviceaccounts/', sa); +const updateServiceAccount = async (id: number, sa: ServiceAccountDTO) => + getBackendSrv().patch(`/api/serviceaccounts/${id}`, sa); const ServiceAccountCreatePage: React.FC = ({ navModel }) => { + const [roleOptions, setRoleOptions] = useState([]); + const [builtinRoles, setBuiltinRoles] = useState<{ [key: string]: Role[] }>({}); + const [pendingRoles, setPendingRoles] = useState([]); + + const currentOrgId = contextSrv.user.orgId; + const [serviceAccount, setServiceAccount] = useState({ + id: 0, + orgId: contextSrv.user.orgId, + role: OrgRole.Viewer, + tokens: 0, + name: '', + login: '', + isDisabled: false, + createdAt: '', + teams: [], + }); + + useEffect(() => { + async function fetchOptions() { + try { + if (contextSrv.hasPermission(AccessControlAction.ActionRolesList)) { + let options = await fetchRoleOptions(currentOrgId); + setRoleOptions(options); + } + + if (contextSrv.hasPermission(AccessControlAction.ActionBuiltinRolesList)) { + const builtInRoles = await fetchBuiltinRoles(currentOrgId); + setBuiltinRoles(builtInRoles); + } + } catch (e) { + console.error('Error loading options', e); + } + } + if (contextSrv.licensedAccessControlEnabled()) { + fetchOptions(); + } + }, [currentOrgId]); + const history = useHistory(); const onSubmit = useCallback( async (data: ServiceAccountDTO) => { - await createServiceAccount(data); + data.role = serviceAccount.role; + const response = await createServiceAccount(data); + try { + const newAccount: ServiceAccountCreateApiResponse = { + avatarUrl: response.avatarUrl, + id: response.id, + isDisabled: response.isDisabled, + login: response.login, + name: response.name, + orgId: response.orgId, + role: response.role, + tokens: response.tokens, + }; + await updateServiceAccount(response.id, data); + await updateUserRoles(pendingRoles, newAccount.id, newAccount.orgId); + } catch (e) { + console.error(e); + } history.push('/org/serviceaccounts/'); }, - [history] + [history, serviceAccount.role, pendingRoles] ); + const onRoleChange = (role: OrgRole) => { + setServiceAccount({ + ...serviceAccount, + role: role, + }); + }; + + const onPendingRolesUpdate = (roles: Role[], userId: number, orgId: number | undefined) => { + // keep the new role assignments for user + setPendingRoles(roles); + }; + return ( @@ -46,6 +116,22 @@ const ServiceAccountCreatePage: React.FC = ({ nav > + {contextSrv.accessControlEnabled() && ( + + onRoleChange(newRole)} + builtinRolesDisabled={false} + roleOptions={roleOptions} + updateDisabled={true} + onApplyRoles={onPendingRolesUpdate} + pendingRoles={pendingRoles} + /> + + )} ); diff --git a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx index 5527c781ffd..9f0e5778a45 100644 --- a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx @@ -66,16 +66,23 @@ const ServiceAccountsListPage = ({ const styles = useStyles2(getStyles); useEffect(() => { - fetchServiceAccounts(); - if (contextSrv.licensedAccessControlEnabled()) { - fetchACOptions(); - } + const fetchData = async () => { + await fetchServiceAccounts(); + if (contextSrv.licensedAccessControlEnabled()) { + await fetchACOptions(); + } + }; + fetchData(); }, [fetchServiceAccounts, fetchACOptions]); const onRoleChange = async (role: OrgRole, serviceAccount: ServiceAccountDTO) => { const updatedServiceAccount = { ...serviceAccount, role: role }; await updateServiceAccount(updatedServiceAccount); + // need to refetch to display the new value in the list await fetchServiceAccounts(); + if (contextSrv.licensedAccessControlEnabled()) { + fetchACOptions(); + } }; return ( diff --git a/public/app/types/serviceaccount.ts b/public/app/types/serviceaccount.ts index 3448e99b546..1139e25ac82 100644 --- a/public/app/types/serviceaccount.ts +++ b/public/app/types/serviceaccount.ts @@ -38,6 +38,17 @@ export interface ServiceAccountDTO extends WithAccessControlMetadata { role: OrgRole; } +export interface ServiceAccountCreateApiResponse { + avatarUrl?: string; + id: number; + isDisabled: boolean; + login: string; + name: string; + orgId: number; + role: OrgRole; + tokens: number; +} + export interface ServiceAccountProfileState { serviceAccount: ServiceAccountDTO; isLoading: boolean;