UserOrgs.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  1. import { css, cx } from '@emotion/css';
  2. import React, { PureComponent, ReactElement } from 'react';
  3. import { GrafanaTheme, GrafanaTheme2 } from '@grafana/data';
  4. import {
  5. Button,
  6. ConfirmButton,
  7. Field,
  8. HorizontalGroup,
  9. Icon,
  10. Modal,
  11. stylesFactory,
  12. Themeable,
  13. Tooltip,
  14. useStyles2,
  15. useTheme,
  16. withTheme,
  17. } from '@grafana/ui';
  18. import { UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker';
  19. import { fetchRoleOptions, updateUserRoles } from 'app/core/components/RolePicker/api';
  20. import { OrgPicker, OrgSelectItem } from 'app/core/components/Select/OrgPicker';
  21. import { contextSrv } from 'app/core/core';
  22. import { AccessControlAction, Organization, OrgRole, Role, UserDTO, UserOrg } from 'app/types';
  23. import { OrgRolePicker } from './OrgRolePicker';
  24. interface Props {
  25. orgs: UserOrg[];
  26. user?: UserDTO;
  27. isExternalUser?: boolean;
  28. onOrgRemove: (orgId: number) => void;
  29. onOrgRoleChange: (orgId: number, newRole: OrgRole) => void;
  30. onOrgAdd: (orgId: number, role: OrgRole) => void;
  31. }
  32. interface State {
  33. showAddOrgModal: boolean;
  34. }
  35. export class UserOrgs extends PureComponent<Props, State> {
  36. addToOrgButtonRef = React.createRef<HTMLButtonElement>();
  37. state = {
  38. showAddOrgModal: false,
  39. };
  40. showOrgAddModal = () => {
  41. this.setState({ showAddOrgModal: true });
  42. };
  43. dismissOrgAddModal = () => {
  44. this.setState({ showAddOrgModal: false }, () => {
  45. this.addToOrgButtonRef.current?.focus();
  46. });
  47. };
  48. render() {
  49. const { user, orgs, isExternalUser, onOrgRoleChange, onOrgRemove, onOrgAdd } = this.props;
  50. const { showAddOrgModal } = this.state;
  51. const addToOrgContainerClass = css`
  52. margin-top: 0.8rem;
  53. `;
  54. const canAddToOrg = contextSrv.hasPermission(AccessControlAction.OrgUsersAdd);
  55. return (
  56. <>
  57. <h3 className="page-heading">Organizations</h3>
  58. <div className="gf-form-group">
  59. <div className="gf-form">
  60. <table className="filter-table form-inline">
  61. <tbody>
  62. {orgs.map((org, index) => (
  63. <OrgRow
  64. key={`${org.orgId}-${index}`}
  65. isExternalUser={isExternalUser}
  66. user={user}
  67. org={org}
  68. onOrgRoleChange={onOrgRoleChange}
  69. onOrgRemove={onOrgRemove}
  70. />
  71. ))}
  72. </tbody>
  73. </table>
  74. </div>
  75. <div className={addToOrgContainerClass}>
  76. {canAddToOrg && (
  77. <Button variant="secondary" onClick={this.showOrgAddModal} ref={this.addToOrgButtonRef}>
  78. Add user to organization
  79. </Button>
  80. )}
  81. </div>
  82. <AddToOrgModal
  83. user={user}
  84. userOrgs={orgs}
  85. isOpen={showAddOrgModal}
  86. onOrgAdd={onOrgAdd}
  87. onDismiss={this.dismissOrgAddModal}
  88. />
  89. </div>
  90. </>
  91. );
  92. }
  93. }
  94. const getOrgRowStyles = stylesFactory((theme: GrafanaTheme) => {
  95. return {
  96. removeButton: css`
  97. margin-right: 0.6rem;
  98. text-decoration: underline;
  99. color: ${theme.palette.blue95};
  100. `,
  101. label: css`
  102. font-weight: 500;
  103. `,
  104. disabledTooltip: css`
  105. display: flex;
  106. `,
  107. tooltipItem: css`
  108. margin-left: 5px;
  109. `,
  110. tooltipItemLink: css`
  111. color: ${theme.palette.blue95};
  112. `,
  113. rolePickerWrapper: css`
  114. display: flex;
  115. `,
  116. rolePicker: css`
  117. flex: auto;
  118. margin-right: ${theme.spacing.sm};
  119. `,
  120. };
  121. });
  122. interface OrgRowProps extends Themeable {
  123. user?: UserDTO;
  124. org: UserOrg;
  125. isExternalUser?: boolean;
  126. onOrgRemove: (orgId: number) => void;
  127. onOrgRoleChange: (orgId: number, newRole: OrgRole) => void;
  128. }
  129. class UnThemedOrgRow extends PureComponent<OrgRowProps> {
  130. state = {
  131. currentRole: this.props.org.role,
  132. isChangingRole: false,
  133. roleOptions: [],
  134. builtInRoles: {},
  135. };
  136. componentDidMount() {
  137. if (contextSrv.licensedAccessControlEnabled()) {
  138. if (contextSrv.hasPermission(AccessControlAction.ActionRolesList)) {
  139. fetchRoleOptions(this.props.org.orgId)
  140. .then((roles) => this.setState({ roleOptions: roles }))
  141. .catch((e) => console.error(e));
  142. }
  143. if (contextSrv.hasPermission(AccessControlAction.ActionBuiltinRolesList)) {
  144. fetchRoleOptions(this.props.org.orgId)
  145. .then((roles) => this.setState({ builtInRoles: roles }))
  146. .catch((e) => console.error(e));
  147. }
  148. }
  149. }
  150. onOrgRemove = async () => {
  151. const { org, user } = this.props;
  152. this.props.onOrgRemove(org.orgId);
  153. if (contextSrv.licensedAccessControlEnabled()) {
  154. if (contextSrv.hasPermission(AccessControlAction.OrgUsersRemove)) {
  155. user && (await updateUserRoles([], user.id, org.orgId));
  156. }
  157. }
  158. };
  159. onChangeRoleClick = () => {
  160. const { org } = this.props;
  161. this.setState({ isChangingRole: true, currentRole: org.role });
  162. };
  163. onOrgRoleChange = (newRole: OrgRole) => {
  164. this.setState({ currentRole: newRole });
  165. };
  166. onOrgRoleSave = () => {
  167. this.props.onOrgRoleChange(this.props.org.orgId, this.state.currentRole);
  168. };
  169. onCancelClick = () => {
  170. this.setState({ isChangingRole: false });
  171. };
  172. onBuiltinRoleChange = (newRole: OrgRole) => {
  173. this.props.onOrgRoleChange(this.props.org.orgId, newRole);
  174. };
  175. render() {
  176. const { user, org, isExternalUser, theme } = this.props;
  177. const { currentRole, isChangingRole } = this.state;
  178. const styles = getOrgRowStyles(theme);
  179. const labelClass = cx('width-16', styles.label);
  180. const canChangeRole = contextSrv.hasPermission(AccessControlAction.OrgUsersWrite);
  181. const canRemoveFromOrg = contextSrv.hasPermission(AccessControlAction.OrgUsersRemove);
  182. const rolePickerDisabled = isExternalUser || !canChangeRole;
  183. const inputId = `${org.name}-input`;
  184. return (
  185. <tr>
  186. <td className={labelClass}>
  187. <label htmlFor={inputId}>{org.name}</label>
  188. </td>
  189. {contextSrv.licensedAccessControlEnabled() ? (
  190. <td>
  191. <div className={styles.rolePickerWrapper}>
  192. <div className={styles.rolePicker}>
  193. <UserRolePicker
  194. userId={user?.id || 0}
  195. orgId={org.orgId}
  196. builtInRole={org.role}
  197. roleOptions={this.state.roleOptions}
  198. builtInRoles={this.state.builtInRoles}
  199. onBuiltinRoleChange={this.onBuiltinRoleChange}
  200. builtinRolesDisabled={rolePickerDisabled}
  201. />
  202. </div>
  203. {isExternalUser && <ExternalUserTooltip />}
  204. </div>
  205. </td>
  206. ) : (
  207. <>
  208. {isChangingRole ? (
  209. <td>
  210. <OrgRolePicker inputId={inputId} value={currentRole} onChange={this.onOrgRoleChange} autoFocus />
  211. </td>
  212. ) : (
  213. <td className="width-25">{org.role}</td>
  214. )}
  215. <td colSpan={1}>
  216. <div className="pull-right">
  217. {canChangeRole && (
  218. <ChangeOrgButton
  219. isExternalUser={isExternalUser}
  220. onChangeRoleClick={this.onChangeRoleClick}
  221. onCancelClick={this.onCancelClick}
  222. onOrgRoleSave={this.onOrgRoleSave}
  223. />
  224. )}
  225. </div>
  226. </td>
  227. </>
  228. )}
  229. <td colSpan={1}>
  230. <div className="pull-right">
  231. {canRemoveFromOrg && (
  232. <ConfirmButton
  233. confirmText="Confirm removal"
  234. confirmVariant="destructive"
  235. onCancel={this.onCancelClick}
  236. onConfirm={this.onOrgRemove}
  237. autoFocus
  238. >
  239. Remove from organization
  240. </ConfirmButton>
  241. )}
  242. </div>
  243. </td>
  244. </tr>
  245. );
  246. }
  247. }
  248. const OrgRow = withTheme(UnThemedOrgRow);
  249. const getAddToOrgModalStyles = stylesFactory(() => ({
  250. modal: css`
  251. width: 500px;
  252. `,
  253. buttonRow: css`
  254. text-align: center;
  255. `,
  256. modalContent: css`
  257. overflow: visible;
  258. `,
  259. }));
  260. interface AddToOrgModalProps {
  261. isOpen: boolean;
  262. user?: UserDTO;
  263. userOrgs: UserOrg[];
  264. onOrgAdd(orgId: number, role: string): void;
  265. onDismiss?(): void;
  266. }
  267. interface AddToOrgModalState {
  268. selectedOrg: Organization | null;
  269. role: OrgRole;
  270. roleOptions: Role[];
  271. pendingOrgId: number | null;
  272. pendingUserId: number | null;
  273. pendingRoles: Role[];
  274. }
  275. export class AddToOrgModal extends PureComponent<AddToOrgModalProps, AddToOrgModalState> {
  276. state: AddToOrgModalState = {
  277. selectedOrg: null,
  278. role: OrgRole.Viewer,
  279. roleOptions: [],
  280. pendingOrgId: null,
  281. pendingUserId: null,
  282. pendingRoles: [],
  283. };
  284. onOrgSelect = (org: OrgSelectItem) => {
  285. const userOrg = this.props.userOrgs.find((userOrg) => userOrg.orgId === org.value?.id);
  286. this.setState({ selectedOrg: org.value!, role: userOrg?.role || OrgRole.Viewer });
  287. if (contextSrv.licensedAccessControlEnabled()) {
  288. if (contextSrv.hasPermission(AccessControlAction.ActionRolesList)) {
  289. fetchRoleOptions(org.value?.id)
  290. .then((roles) => this.setState({ roleOptions: roles }))
  291. .catch((e) => console.error(e));
  292. }
  293. }
  294. };
  295. onOrgRoleChange = (newRole: OrgRole) => {
  296. this.setState({
  297. role: newRole,
  298. });
  299. };
  300. onAddUserToOrg = async () => {
  301. const { selectedOrg, role } = this.state;
  302. this.props.onOrgAdd(selectedOrg!.id, role);
  303. // add the stored userRoles also
  304. if (contextSrv.licensedAccessControlEnabled()) {
  305. if (contextSrv.hasPermission(AccessControlAction.OrgUsersWrite)) {
  306. if (this.state.pendingUserId) {
  307. await updateUserRoles(this.state.pendingRoles, this.state.pendingUserId!, this.state.pendingOrgId!);
  308. // clear pending state
  309. this.state.pendingOrgId = null;
  310. this.state.pendingRoles = [];
  311. this.state.pendingUserId = null;
  312. }
  313. }
  314. }
  315. };
  316. onCancel = () => {
  317. // clear selectedOrg when modal is canceled
  318. this.setState({
  319. selectedOrg: null,
  320. pendingRoles: [],
  321. pendingOrgId: null,
  322. pendingUserId: null,
  323. });
  324. if (this.props.onDismiss) {
  325. this.props.onDismiss();
  326. }
  327. };
  328. onRoleUpdate = async (roles: Role[], userId: number, orgId: number | undefined) => {
  329. // keep the new role assignments for user
  330. this.setState({
  331. pendingRoles: roles,
  332. pendingOrgId: orgId!,
  333. pendingUserId: userId,
  334. });
  335. };
  336. render() {
  337. const { isOpen, user, userOrgs } = this.props;
  338. const { role, roleOptions, selectedOrg } = this.state;
  339. const styles = getAddToOrgModalStyles();
  340. return (
  341. <Modal
  342. className={styles.modal}
  343. contentClassName={styles.modalContent}
  344. title="Add to an organization"
  345. isOpen={isOpen}
  346. onDismiss={this.onCancel}
  347. >
  348. <Field label="Organization">
  349. <OrgPicker inputId="new-org-input" onSelected={this.onOrgSelect} excludeOrgs={userOrgs} autoFocus />
  350. </Field>
  351. <Field label="Role" disabled={selectedOrg === null}>
  352. {contextSrv.accessControlEnabled() ? (
  353. <UserRolePicker
  354. userId={user?.id || 0}
  355. orgId={selectedOrg?.id}
  356. builtInRole={role}
  357. onBuiltinRoleChange={this.onOrgRoleChange}
  358. builtinRolesDisabled={false}
  359. roleOptions={roleOptions}
  360. updateDisabled={true}
  361. onApplyRoles={this.onRoleUpdate}
  362. pendingRoles={this.state.pendingRoles}
  363. />
  364. ) : (
  365. <OrgRolePicker inputId="new-org-role-input" value={role} onChange={this.onOrgRoleChange} />
  366. )}
  367. </Field>
  368. <Modal.ButtonRow>
  369. <HorizontalGroup spacing="md" justify="center">
  370. <Button variant="secondary" fill="outline" onClick={this.onCancel}>
  371. Cancel
  372. </Button>
  373. <Button variant="primary" disabled={selectedOrg === null} onClick={this.onAddUserToOrg}>
  374. Add to organization
  375. </Button>
  376. </HorizontalGroup>
  377. </Modal.ButtonRow>
  378. </Modal>
  379. );
  380. }
  381. }
  382. interface ChangeOrgButtonProps {
  383. isExternalUser?: boolean;
  384. onChangeRoleClick: () => void;
  385. onCancelClick: () => void;
  386. onOrgRoleSave: () => void;
  387. }
  388. const getChangeOrgButtonTheme = (theme: GrafanaTheme2) => ({
  389. disabledTooltip: css`
  390. display: flex;
  391. `,
  392. tooltipItemLink: css`
  393. color: ${theme.v1.palette.blue95};
  394. `,
  395. });
  396. export function ChangeOrgButton({
  397. onChangeRoleClick,
  398. isExternalUser,
  399. onOrgRoleSave,
  400. onCancelClick,
  401. }: ChangeOrgButtonProps): ReactElement {
  402. const styles = useStyles2(getChangeOrgButtonTheme);
  403. return (
  404. <div className={styles.disabledTooltip}>
  405. <ConfirmButton
  406. confirmText="Save"
  407. onClick={onChangeRoleClick}
  408. onCancel={onCancelClick}
  409. onConfirm={onOrgRoleSave}
  410. disabled={isExternalUser}
  411. >
  412. Change role
  413. </ConfirmButton>
  414. {isExternalUser && (
  415. <Tooltip
  416. placement="right-end"
  417. content={
  418. <div>
  419. This user&apos;s role is not editable because it is synchronized from your auth provider. Refer to
  420. the&nbsp;
  421. <a
  422. className={styles.tooltipItemLink}
  423. href={'https://grafana.com/docs/grafana/latest/auth'}
  424. rel="noreferrer"
  425. target="_blank"
  426. >
  427. Grafana authentication docs
  428. </a>
  429. &nbsp;for details.
  430. </div>
  431. }
  432. >
  433. <Icon name="question-circle" />
  434. </Tooltip>
  435. )}
  436. </div>
  437. );
  438. }
  439. const ExternalUserTooltip: React.FC = () => {
  440. const theme = useTheme();
  441. const styles = getTooltipStyles(theme);
  442. return (
  443. <div className={styles.disabledTooltip}>
  444. <Tooltip
  445. placement="right-end"
  446. content={
  447. <div>
  448. This user&apos;s built-in role is not editable because it is synchronized from your auth provider. Refer to
  449. the&nbsp;
  450. <a
  451. className={styles.tooltipItemLink}
  452. href={'https://grafana.com/docs/grafana/latest/auth'}
  453. rel="noreferrer noopener"
  454. target="_blank"
  455. >
  456. Grafana authentication docs
  457. </a>
  458. &nbsp;for details.
  459. </div>
  460. }
  461. >
  462. <Icon name="question-circle" />
  463. </Tooltip>
  464. </div>
  465. );
  466. };
  467. const getTooltipStyles = stylesFactory((theme: GrafanaTheme) => ({
  468. disabledTooltip: css`
  469. display: flex;
  470. `,
  471. tooltipItemLink: css`
  472. color: ${theme.palette.blue95};
  473. `,
  474. }));