A11y/UserAdminPage: Improves tab navigation and focus management (#41321)

This commit is contained in:
kay delaney 2021-11-05 14:45:00 +00:00 committed by GitHub
parent f3f57d3d6e
commit c8b7373016
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 369 additions and 382 deletions

View File

@ -21,6 +21,8 @@ export interface Props extends Themeable {
confirmVariant?: ButtonVariant; confirmVariant?: ButtonVariant;
/** Hide confirm actions when after of them is clicked */ /** Hide confirm actions when after of them is clicked */
closeOnConfirm?: boolean; closeOnConfirm?: boolean;
/** Move focus to button when mounted */
autoFocus?: boolean;
/** Optional on click handler for the original button */ /** Optional on click handler for the original button */
onClick?(): void; onClick?(): void;
@ -33,6 +35,8 @@ interface State {
} }
class UnThemedConfirmButton extends PureComponent<Props, State> { class UnThemedConfirmButton extends PureComponent<Props, State> {
mainButtonRef = React.createRef<HTMLButtonElement>();
confirmButtonRef = React.createRef<HTMLButtonElement>();
state: State = { state: State = {
showConfirm: false, showConfirm: false,
}; };
@ -42,9 +46,16 @@ class UnThemedConfirmButton extends PureComponent<Props, State> {
event.preventDefault(); event.preventDefault();
} }
this.setState({ this.setState(
showConfirm: true, {
}); showConfirm: true,
},
() => {
if (this.props.autoFocus && this.confirmButtonRef.current) {
this.confirmButtonRef.current.focus();
}
}
);
if (this.props.onClick) { if (this.props.onClick) {
this.props.onClick(); this.props.onClick();
@ -55,9 +66,14 @@ class UnThemedConfirmButton extends PureComponent<Props, State> {
if (event) { if (event) {
event.preventDefault(); event.preventDefault();
} }
this.setState({ this.setState(
showConfirm: false, {
}); showConfirm: false,
},
() => {
this.mainButtonRef.current?.focus();
}
);
if (this.props.onCancel) { if (this.props.onCancel) {
this.props.onCancel(); this.props.onCancel();
} }
@ -101,7 +117,7 @@ class UnThemedConfirmButton extends PureComponent<Props, State> {
<span className={styles.buttonContainer}> <span className={styles.buttonContainer}>
{typeof children === 'string' ? ( {typeof children === 'string' ? (
<span className={buttonClass}> <span className={buttonClass}>
<Button size={size} fill="text" onClick={onClick}> <Button size={size} fill="text" onClick={onClick} ref={this.mainButtonRef}>
{children} {children}
</Button> </Button>
</span> </span>
@ -111,12 +127,12 @@ class UnThemedConfirmButton extends PureComponent<Props, State> {
</span> </span>
)} )}
<span className={confirmButtonClass}> <span className={confirmButtonClass}>
<Button size={size} variant={confirmButtonVariant} onClick={this.onConfirm} ref={this.confirmButtonRef}>
{confirmText}
</Button>
<Button size={size} fill="text" onClick={this.onClickCancel}> <Button size={size} fill="text" onClick={this.onClickCancel}>
Cancel Cancel
</Button> </Button>
<Button size={size} variant={confirmButtonVariant} onClick={this.onConfirm}>
{confirmText}
</Button>
</span> </span>
</span> </span>
); );
@ -128,9 +144,9 @@ export const ConfirmButton = withTheme(UnThemedConfirmButton);
const getStyles = stylesFactory((theme: GrafanaTheme) => { const getStyles = stylesFactory((theme: GrafanaTheme) => {
return { return {
buttonContainer: css` buttonContainer: css`
direction: rtl;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end;
`, `,
buttonDisabled: css` buttonDisabled: css`
text-decoration: none; text-decoration: none;
@ -146,14 +162,14 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
`, `,
buttonHide: css` buttonHide: css`
opacity: 0; opacity: 0;
transition: opacity 0.1s ease; transition: opacity 0.1s ease, visibility 0 0.1s;
visibility: hidden;
z-index: 0; z-index: 0;
`, `,
confirmButton: css` confirmButton: css`
align-items: flex-start; align-items: flex-start;
background: ${theme.colors.bg1}; background: ${theme.colors.bg1};
display: flex; display: flex;
overflow: hidden;
position: absolute; position: absolute;
pointer-events: none; pointer-events: none;
`, `,
@ -166,7 +182,8 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
`, `,
confirmButtonHide: css` confirmButtonHide: css`
opacity: 0; opacity: 0;
transition: opacity 0.12s ease-in, transform 0.14s ease-in; visibility: hidden;
transition: opacity 0.12s ease-in, transform 0.14s ease-in, visibility 0s 0.12s;
transform: translateX(100px); transform: translateX(100px);
`, `,
}; };

View File

@ -58,8 +58,10 @@ export const ConfirmModal = ({
useEffect(() => { useEffect(() => {
// for some reason autoFocus property did no work on this button, but this does // for some reason autoFocus property did no work on this button, but this does
buttonRef.current?.focus(); if (isOpen) {
}, []); buttonRef.current?.focus();
}
}, [isOpen]);
return ( return (
<Modal className={styles.modal} title={title} icon={icon} isOpen={isOpen} onDismiss={onDismiss}> <Modal className={styles.modal} title={title} icon={icon} isOpen={isOpen} onDismiss={onDismiss}>

View File

@ -18,41 +18,48 @@ export interface RadioButtonProps {
onChange: () => void; onChange: () => void;
fullWidth?: boolean; fullWidth?: boolean;
'aria-label'?: StringSelector; 'aria-label'?: StringSelector;
children?: React.ReactNode;
} }
export const RadioButton: React.FC<RadioButtonProps> = ({ export const RadioButton = React.forwardRef<HTMLInputElement, RadioButtonProps>(
children, (
active = false, {
disabled = false, children,
size = 'md', active = false,
onChange, disabled = false,
id, size = 'md',
name = undefined, onChange,
description, id,
fullWidth, name = undefined,
'aria-label': ariaLabel, description,
}) => { fullWidth,
const theme = useTheme2(); 'aria-label': ariaLabel,
const styles = getRadioButtonStyles(theme, size, fullWidth); },
ref
) => {
const theme = useTheme2();
const styles = getRadioButtonStyles(theme, size, fullWidth);
return ( return (
<> <>
<input <input
type="radio" type="radio"
className={styles.radio} className={styles.radio}
onChange={onChange} onChange={onChange}
disabled={disabled} disabled={disabled}
id={id} id={id}
checked={active} checked={active}
name={name} name={name}
aria-label={ariaLabel} aria-label={ariaLabel}
/> ref={ref}
<label className={styles.radioLabel} htmlFor={id} title={description}> />
{children} <label className={styles.radioLabel} htmlFor={id} title={description}>
</label> {children}
</> </label>
); </>
}; );
}
);
RadioButton.displayName = 'RadioButton'; RadioButton.displayName = 'RadioButton';

View File

@ -1,4 +1,4 @@
import React, { useCallback, useRef } from 'react'; import React, { useCallback, useEffect, useRef } from 'react';
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { uniqueId } from 'lodash'; import { uniqueId } from 'lodash';
import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { GrafanaTheme2, SelectableValue } from '@grafana/data';
@ -16,6 +16,7 @@ export interface RadioButtonGroupProps<T> {
size?: RadioButtonSize; size?: RadioButtonSize;
fullWidth?: boolean; fullWidth?: boolean;
className?: string; className?: string;
autoFocus?: boolean;
} }
export function RadioButtonGroup<T>({ export function RadioButtonGroup<T>({
@ -27,6 +28,7 @@ export function RadioButtonGroup<T>({
size = 'md', size = 'md',
className, className,
fullWidth = false, fullWidth = false,
autoFocus = false,
}: RadioButtonGroupProps<T>) { }: RadioButtonGroupProps<T>) {
const handleOnChange = useCallback( const handleOnChange = useCallback(
(option: SelectableValue) => { (option: SelectableValue) => {
@ -42,6 +44,13 @@ export function RadioButtonGroup<T>({
const groupName = useRef(id); const groupName = useRef(id);
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const activeButtonRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (autoFocus && activeButtonRef.current) {
activeButtonRef.current.focus();
}
}, [autoFocus]);
return ( return (
<div className={cx(styles.radioGroup, fullWidth && styles.fullWidth, className)}> <div className={cx(styles.radioGroup, fullWidth && styles.fullWidth, className)}>
{options.map((o, i) => { {options.map((o, i) => {
@ -58,6 +67,7 @@ export function RadioButtonGroup<T>({
name={groupName.current} name={groupName.current}
description={o.description} description={o.description}
fullWidth={fullWidth} fullWidth={fullWidth}
ref={value === o.value ? activeButtonRef : undefined}
> >
{o.icon && <Icon name={o.icon as IconName} className={styles.icon} />} {o.icon && <Icon name={o.icon as IconName} className={styles.icon} />}
{o.imgUrl && <img src={o.imgUrl} alt={o.label} className={styles.img} />} {o.imgUrl && <img src={o.imgUrl} alt={o.label} className={styles.img} />}

View File

@ -1,8 +1,9 @@
import React, { PureComponent } from 'react'; import React, { useEffect } from 'react';
import { AsyncSelect } from '@grafana/ui'; import { AsyncSelect } from '@grafana/ui';
import { getBackendSrv } from 'app/core/services/backend_srv'; import { getBackendSrv } from 'app/core/services/backend_srv';
import { Organization } from 'app/types'; import { Organization } from 'app/types';
import { SelectableValue } from '@grafana/data'; import { SelectableValue } from '@grafana/data';
import { useAsyncFn } from 'react-use';
export type OrgSelectItem = SelectableValue<Organization>; export type OrgSelectItem = SelectableValue<Organization>;
@ -10,56 +11,35 @@ export interface Props {
onSelected: (org: OrgSelectItem) => void; onSelected: (org: OrgSelectItem) => void;
className?: string; className?: string;
inputId?: string; inputId?: string;
autoFocus?: boolean;
} }
export interface State { export function OrgPicker({ onSelected, className, inputId, autoFocus }: Props) {
isLoading: boolean; // For whatever reason the autoFocus prop doesn't seem to work
} // with AsyncSelect, hence this workaround. Maybe fixed in a later version?
useEffect(() => {
export class OrgPicker extends PureComponent<Props, State> { if (autoFocus && inputId) {
orgs: Organization[] = []; document.getElementById(inputId)?.focus();
state: State = {
isLoading: false,
};
async loadOrgs() {
this.setState({ isLoading: true });
const orgs = await getBackendSrv().get('/api/orgs');
this.orgs = orgs;
this.setState({ isLoading: false });
return orgs;
}
getOrgOptions = async (query: string): Promise<OrgSelectItem[]> => {
if (!this.orgs?.length) {
await this.loadOrgs();
} }
return this.orgs.map( }, [autoFocus, inputId]);
(org: Organization): OrgSelectItem => ({
value: { id: org.id, name: org.name },
label: org.name,
})
);
};
render() { const [orgOptionsState, getOrgOptions] = useAsyncFn(async () => {
const { className, onSelected, inputId } = this.props; const orgs: Organization[] = await getBackendSrv().get('/api/orgs');
const { isLoading } = this.state; return orgs.map((org) => ({ value: { id: org.id, name: org.name }, label: org.name }));
});
return ( return (
<AsyncSelect <AsyncSelect
menuShouldPortal menuShouldPortal
inputId={inputId} inputId={inputId}
className={className} className={className}
isLoading={isLoading} isLoading={orgOptionsState.loading}
defaultOptions={true} defaultOptions={true}
isSearchable={false} isSearchable={false}
loadOptions={this.getOrgOptions} loadOptions={getOrgOptions}
onChange={onSelected} onChange={onSelected}
placeholder="Select organization" placeholder="Select organization"
noOptionsMessage="No organizations found" noOptionsMessage="No organizations found"
/> />
); );
}
} }

View File

@ -8,19 +8,30 @@ interface Props {
'aria-label'?: string; 'aria-label'?: string;
inputId?: string; inputId?: string;
onChange: (role: OrgRole) => void; onChange: (role: OrgRole) => void;
autoFocus?: boolean;
} }
const options = Object.keys(OrgRole).map((key) => ({ label: key, value: key })); const options = Object.keys(OrgRole).map((key) => ({ label: key, value: key }));
export const OrgRolePicker: FC<Props> = ({ value, onChange, 'aria-label': ariaLabel, inputId, ...restProps }) => ( export const OrgRolePicker: FC<Props> = ({
<Select value,
menuShouldPortal onChange,
inputId={inputId} 'aria-label': ariaLabel,
value={value} inputId,
options={options} autoFocus,
onChange={(val) => onChange(val.value as OrgRole)} ...restProps
placeholder="Choose role..." }) => {
aria-label={ariaLabel} return (
{...restProps} <Select
/> menuShouldPortal
); inputId={inputId}
value={value}
options={options}
onChange={(val) => onChange(val.value as OrgRole)}
placeholder="Choose role..."
aria-label={ariaLabel}
autoFocus={autoFocus}
{...restProps}
/>
);
};

View File

@ -33,12 +33,19 @@ interface State {
} }
export class UserOrgs extends PureComponent<Props, State> { export class UserOrgs extends PureComponent<Props, State> {
addToOrgButtonRef = React.createRef<HTMLButtonElement>();
state = { state = {
showAddOrgModal: false, showAddOrgModal: false,
}; };
showOrgAddModal = (show: boolean) => () => { showOrgAddModal = () => {
this.setState({ showAddOrgModal: show }); this.setState({ showAddOrgModal: true });
};
dismissOrgAddModal = () => {
this.setState({ showAddOrgModal: false }, () => {
this.addToOrgButtonRef.current?.focus();
});
}; };
render() { render() {
@ -69,12 +76,12 @@ export class UserOrgs extends PureComponent<Props, State> {
</div> </div>
<div className={addToOrgContainerClass}> <div className={addToOrgContainerClass}>
{canAddToOrg && ( {canAddToOrg && (
<Button variant="secondary" onClick={this.showOrgAddModal(true)}> <Button variant="secondary" onClick={this.showOrgAddModal} ref={this.addToOrgButtonRef}>
Add user to organization Add user to organization
</Button> </Button>
)} )}
</div> </div>
<AddToOrgModal isOpen={showAddOrgModal} onOrgAdd={onOrgAdd} onDismiss={this.showOrgAddModal(false)} /> <AddToOrgModal isOpen={showAddOrgModal} onOrgAdd={onOrgAdd} onDismiss={this.dismissOrgAddModal} />
</div> </div>
</> </>
); );
@ -159,7 +166,7 @@ class UnThemedOrgRow extends PureComponent<OrgRowProps, OrgRowState> {
</td> </td>
{isChangingRole ? ( {isChangingRole ? (
<td> <td>
<OrgRolePicker inputId={inputId} value={currentRole} onChange={this.onOrgRoleChange} /> <OrgRolePicker inputId={inputId} value={currentRole} onChange={this.onOrgRoleChange} autoFocus />
</td> </td>
) : ( ) : (
<td className="width-25">{org.role}</td> <td className="width-25">{org.role}</td>
@ -184,6 +191,7 @@ class UnThemedOrgRow extends PureComponent<OrgRowProps, OrgRowState> {
confirmVariant="destructive" confirmVariant="destructive"
onCancel={this.onCancelClick} onCancel={this.onCancelClick}
onConfirm={this.onOrgRemove} onConfirm={this.onOrgRemove}
autoFocus
> >
Remove from organization Remove from organization
</ConfirmButton> </ConfirmButton>
@ -260,7 +268,7 @@ export class AddToOrgModal extends PureComponent<AddToOrgModalProps, AddToOrgMod
onDismiss={this.onCancel} onDismiss={this.onCancel}
> >
<Field label="Organization"> <Field label="Organization">
<OrgPicker inputId="new-org-input" onSelected={this.onOrgSelect} /> <OrgPicker inputId="new-org-input" onSelected={this.onOrgSelect} autoFocus />
</Field> </Field>
<Field label="Role"> <Field label="Role">
<OrgRolePicker inputId="new-org-role-input" value={role} onChange={this.onOrgRoleChange} /> <OrgRolePicker inputId="new-org-role-input" value={role} onChange={this.onOrgRoleChange} />

View File

@ -1,6 +1,5 @@
import React, { PureComponent } from 'react'; import React, { useState } from 'react';
import { ConfirmButton, RadioButtonGroup, Icon } from '@grafana/ui'; import { ConfirmButton, RadioButtonGroup, Icon } from '@grafana/ui';
import { cx } from '@emotion/css';
import { AccessControlAction } from 'app/types'; import { AccessControlAction } from 'app/types';
import { contextSrv } from 'app/core/core'; import { contextSrv } from 'app/core/core';
@ -10,98 +9,72 @@ interface Props {
onGrafanaAdminChange: (isGrafanaAdmin: boolean) => void; onGrafanaAdminChange: (isGrafanaAdmin: boolean) => void;
} }
interface State {
isEditing: boolean;
currentAdminOption: string;
}
const adminOptions = [ const adminOptions = [
{ label: 'Yes', value: 'YES' }, { label: 'Yes', value: true },
{ label: 'No', value: 'NO' }, { label: 'No', value: false },
]; ];
export class UserPermissions extends PureComponent<Props, State> { export function UserPermissions({ isGrafanaAdmin, onGrafanaAdminChange }: Props) {
state = { const [isEditing, setIsEditing] = useState(false);
isEditing: false, const [currentAdminOption, setCurrentAdminOption] = useState(isGrafanaAdmin);
currentAdminOption: this.props.isGrafanaAdmin ? 'YES' : 'NO',
const onChangeClick = () => setIsEditing(true);
const onCancelClick = () => {
setIsEditing(false);
setCurrentAdminOption(isGrafanaAdmin);
}; };
onChangeClick = () => { const handleGrafanaAdminChange = () => onGrafanaAdminChange(currentAdminOption);
this.setState({ isEditing: true });
};
onCancelClick = () => { const canChangePermissions = contextSrv.hasPermission(AccessControlAction.UsersPermissionsUpdate);
this.setState({
isEditing: false,
currentAdminOption: this.props.isGrafanaAdmin ? 'YES' : 'NO',
});
};
onGrafanaAdminChange = () => { return (
const { currentAdminOption } = this.state; <>
const newIsGrafanaAdmin = currentAdminOption === 'YES' ? true : false; <h3 className="page-heading">Permissions</h3>
this.props.onGrafanaAdminChange(newIsGrafanaAdmin); <div className="gf-form-group">
}; <div className="gf-form">
<table className="filter-table form-inline">
onAdminOptionSelect = (value: string) => { <tbody>
this.setState({ currentAdminOption: value }); <tr>
}; <td className="width-16">Grafana Admin</td>
{isEditing ? (
render() { <td colSpan={2}>
const { isGrafanaAdmin } = this.props; <RadioButtonGroup
const { isEditing, currentAdminOption } = this.state; options={adminOptions}
const changeButtonContainerClass = cx('pull-right'); value={currentAdminOption}
const canChangePermissions = contextSrv.hasPermission(AccessControlAction.UsersPermissionsUpdate); onChange={setCurrentAdminOption}
autoFocus
return ( />
<>
<h3 className="page-heading">Permissions</h3>
<div className="gf-form-group">
<div className="gf-form">
<table className="filter-table form-inline">
<tbody>
<tr>
<td className="width-16">Grafana Admin</td>
{isEditing ? (
<td colSpan={2}>
<RadioButtonGroup
options={adminOptions}
value={currentAdminOption}
onChange={this.onAdminOptionSelect}
/>
</td>
) : (
<td colSpan={2}>
{isGrafanaAdmin ? (
<>
<Icon name="shield" /> Yes
</>
) : (
<>No</>
)}
</td>
)}
<td>
<div className={changeButtonContainerClass}>
{canChangePermissions && (
<ConfirmButton
className="pull-right"
onClick={this.onChangeClick}
onConfirm={this.onGrafanaAdminChange}
onCancel={this.onCancelClick}
confirmText="Change"
>
Change
</ConfirmButton>
)}
</div>
</td> </td>
</tr> ) : (
</tbody> <td colSpan={2}>
</table> {isGrafanaAdmin ? (
</div> <>
<Icon name="shield" /> Yes
</>
) : (
<>No</>
)}
</td>
)}
<td>
{canChangePermissions && (
<ConfirmButton
onClick={onChangeClick}
onConfirm={handleGrafanaAdminChange}
onCancel={onCancelClick}
confirmText="Change"
>
Change
</ConfirmButton>
)}
</td>
</tr>
</tbody>
</table>
</div> </div>
</> </div>
); </>
} );
} }

View File

@ -1,4 +1,4 @@
import React, { FC, PureComponent } from 'react'; import React, { FC, PureComponent, useRef, useState } from 'react';
import { AccessControlAction, UserDTO } from 'app/types'; import { AccessControlAction, UserDTO } from 'app/types';
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { config } from 'app/core/config'; import { config } from 'app/core/config';
@ -16,163 +16,149 @@ interface Props {
onPasswordChange(password: string): void; onPasswordChange(password: string): void;
} }
interface State { export function UserProfile({
isLoading: boolean; user,
showDeleteModal: boolean; onUserUpdate,
showDisableModal: boolean; onUserDelete,
} onUserDisable,
onUserEnable,
onPasswordChange,
}: Props) {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showDisableModal, setShowDisableModal] = useState(false);
export class UserProfile extends PureComponent<Props, State> { const deleteUserRef = useRef<HTMLButtonElement | null>(null);
state = { const showDeleteUserModal = (show: boolean) => () => {
isLoading: false, setShowDeleteModal(show);
showDeleteModal: false, if (!show && deleteUserRef.current) {
showDisableModal: false, deleteUserRef.current.focus();
}
}; };
showDeleteUserModal = (show: boolean) => () => { const disableUserRef = useRef<HTMLButtonElement | null>(null);
this.setState({ showDeleteModal: show }); const showDisableUserModal = (show: boolean) => () => {
setShowDisableModal(show);
if (!show && disableUserRef.current) {
disableUserRef.current.focus();
}
}; };
showDisableUserModal = (show: boolean) => () => { const handleUserDelete = () => onUserDelete(user.id);
this.setState({ showDisableModal: show });
};
onUserDelete = () => { const handleUserDisable = () => onUserDisable(user.id);
const { user, onUserDelete } = this.props;
onUserDelete(user.id);
};
onUserDisable = () => { const handleUserEnable = () => onUserEnable(user.id);
const { user, onUserDisable } = this.props;
onUserDisable(user.id);
};
onUserEnable = () => { const onUserNameChange = (newValue: string) => {
const { user, onUserEnable } = this.props;
onUserEnable(user.id);
};
onUserNameChange = (newValue: string) => {
const { user, onUserUpdate } = this.props;
onUserUpdate({ onUserUpdate({
...user, ...user,
name: newValue, name: newValue,
}); });
}; };
onUserEmailChange = (newValue: string) => { const onUserEmailChange = (newValue: string) => {
const { user, onUserUpdate } = this.props;
onUserUpdate({ onUserUpdate({
...user, ...user,
email: newValue, email: newValue,
}); });
}; };
onUserLoginChange = (newValue: string) => { const onUserLoginChange = (newValue: string) => {
const { user, onUserUpdate } = this.props;
onUserUpdate({ onUserUpdate({
...user, ...user,
login: newValue, login: newValue,
}); });
}; };
onPasswordChange = (newValue: string) => { const authSource = user.authLabels?.length && user.authLabels[0];
this.props.onPasswordChange(newValue); const lockMessage = authSource ? `Synced via ${authSource}` : '';
}; const styles = getStyles(config.theme);
render() { const editLocked = user.isExternal || !contextSrv.hasPermission(AccessControlAction.UsersWrite);
const { user } = this.props; const passwordChangeLocked = user.isExternal || !contextSrv.hasPermission(AccessControlAction.UsersPasswordUpdate);
const { showDeleteModal, showDisableModal } = this.state; const canDelete = contextSrv.hasPermission(AccessControlAction.UsersDelete);
const authSource = user.authLabels?.length && user.authLabels[0]; const canDisable = contextSrv.hasPermission(AccessControlAction.UsersDisable);
const lockMessage = authSource ? `Synced via ${authSource}` : ''; const canEnable = contextSrv.hasPermission(AccessControlAction.UsersEnable);
const styles = getStyles(config.theme);
const editLocked = user.isExternal || !contextSrv.hasPermission(AccessControlAction.UsersWrite); return (
const passwordChangeLocked = user.isExternal || !contextSrv.hasPermission(AccessControlAction.UsersPasswordUpdate); <>
const canDelete = contextSrv.hasPermission(AccessControlAction.UsersDelete); <h3 className="page-heading">User information</h3>
const canDisable = contextSrv.hasPermission(AccessControlAction.UsersDisable); <div className="gf-form-group">
const canEnable = contextSrv.hasPermission(AccessControlAction.UsersEnable); <div className="gf-form">
<table className="filter-table form-inline">
return ( <tbody>
<> <UserProfileRow
<h3 className="page-heading">User information</h3> label="Name"
<div className="gf-form-group"> value={user.name}
<div className="gf-form"> locked={editLocked}
<table className="filter-table form-inline"> lockMessage={lockMessage}
<tbody> onChange={onUserNameChange}
<UserProfileRow />
label="Name" <UserProfileRow
value={user.name} label="Email"
locked={editLocked} value={user.email}
lockMessage={lockMessage} locked={editLocked}
onChange={this.onUserNameChange} lockMessage={lockMessage}
/> onChange={onUserEmailChange}
<UserProfileRow />
label="Email" <UserProfileRow
value={user.email} label="Username"
locked={editLocked} value={user.login}
lockMessage={lockMessage} locked={editLocked}
onChange={this.onUserEmailChange} lockMessage={lockMessage}
/> onChange={onUserLoginChange}
<UserProfileRow />
label="Username" <UserProfileRow
value={user.login} label="Password"
locked={editLocked} value="********"
lockMessage={lockMessage} inputType="password"
onChange={this.onUserLoginChange} locked={passwordChangeLocked}
/> lockMessage={lockMessage}
<UserProfileRow onChange={onPasswordChange}
label="Password" />
value="********" </tbody>
inputType="password" </table>
locked={passwordChangeLocked}
lockMessage={lockMessage}
onChange={this.onPasswordChange}
/>
</tbody>
</table>
</div>
<div className={styles.buttonRow}>
{canDelete && (
<>
<Button variant="destructive" onClick={this.showDeleteUserModal(true)}>
Delete user
</Button>
<ConfirmModal
isOpen={showDeleteModal}
title="Delete user"
body="Are you sure you want to delete this user?"
confirmText="Delete user"
onConfirm={this.onUserDelete}
onDismiss={this.showDeleteUserModal(false)}
/>
</>
)}
{user.isDisabled && canEnable && (
<Button variant="secondary" onClick={this.onUserEnable}>
Enable user
</Button>
)}
{!user.isDisabled && canDisable && (
<>
<Button variant="secondary" onClick={this.showDisableUserModal(true)}>
Disable user
</Button>
<ConfirmModal
isOpen={showDisableModal}
title="Disable user"
body="Are you sure you want to disable this user?"
confirmText="Disable user"
onConfirm={this.onUserDisable}
onDismiss={this.showDisableUserModal(false)}
/>
</>
)}
</div>
</div> </div>
</> <div className={styles.buttonRow}>
); {canDelete && (
} <>
<Button variant="destructive" onClick={showDeleteUserModal(true)} ref={deleteUserRef}>
Delete user
</Button>
<ConfirmModal
isOpen={showDeleteModal}
title="Delete user"
body="Are you sure you want to delete this user?"
confirmText="Delete user"
onConfirm={handleUserDelete}
onDismiss={showDeleteUserModal(false)}
/>
</>
)}
{user.isDisabled && canEnable && (
<Button variant="secondary" onClick={handleUserEnable}>
Enable user
</Button>
)}
{!user.isDisabled && canDisable && (
<>
<Button variant="secondary" onClick={showDisableUserModal(true)} ref={disableUserRef}>
Disable user
</Button>
<ConfirmModal
isOpen={showDisableModal}
title="Disable user"
body="Are you sure you want to disable this user?"
confirmText="Disable user"
onConfirm={handleUserDisable}
onDismiss={showDisableUserModal(false)}
/>
</>
)}
</div>
</div>
</>
);
} }
const getStyles = stylesFactory((theme: GrafanaTheme) => { const getStyles = stylesFactory((theme: GrafanaTheme) => {
@ -269,7 +255,6 @@ export class UserProfileRow extends PureComponent<UserProfileRowProps, UserProfi
font-weight: 500; font-weight: 500;
` `
); );
const editButtonContainerClass = cx('pull-right');
if (locked) { if (locked) {
return <LockedRow label={label} value={value} lockMessage={lockMessage} />; return <LockedRow label={label} value={value} lockMessage={lockMessage} />;
@ -297,16 +282,14 @@ export class UserProfileRow extends PureComponent<UserProfileRowProps, UserProfi
)} )}
</td> </td>
<td> <td>
<div className={editButtonContainerClass}> <ConfirmButton
<ConfirmButton confirmText="Save"
confirmText="Save" onClick={this.onEditClick}
onClick={this.onEditClick} onConfirm={this.onSave}
onConfirm={this.onSave} onCancel={this.onCancelClick}
onCancel={this.onCancelClick} >
> Edit
Edit </ConfirmButton>
</ConfirmButton>
</div>
</td> </td>
</tr> </tr>
); );
@ -320,13 +303,10 @@ interface LockedRowProps {
} }
export const LockedRow: FC<LockedRowProps> = ({ label, value, lockMessage }) => { export const LockedRow: FC<LockedRowProps> = ({ label, value, lockMessage }) => {
const lockMessageClass = cx( const lockMessageClass = css`
'pull-right', font-style: italic;
css` margin-right: 0.6rem;
font-style: italic; `;
margin-right: 0.6rem;
`
);
const labelClass = cx( const labelClass = cx(
'width-16', 'width-16',
css` css`

View File

@ -16,12 +16,19 @@ interface State {
} }
export class UserSessions extends PureComponent<Props, State> { export class UserSessions extends PureComponent<Props, State> {
forceAllLogoutButton = React.createRef<HTMLButtonElement>();
state: State = { state: State = {
showLogoutModal: false, showLogoutModal: false,
}; };
showLogoutConfirmationModal = (show: boolean) => () => { showLogoutConfirmationModal = () => {
this.setState({ showLogoutModal: show }); this.setState({ showLogoutModal: true });
};
dismissLogoutConfirmationModal = () => {
this.setState({ showLogoutModal: false }, () => {
this.forceAllLogoutButton.current?.focus();
});
}; };
onSessionRevoke = (id: number) => { onSessionRevoke = (id: number) => {
@ -74,6 +81,7 @@ export class UserSessions extends PureComponent<Props, State> {
confirmText="Confirm logout" confirmText="Confirm logout"
confirmVariant="destructive" confirmVariant="destructive"
onConfirm={this.onSessionRevoke(session.id)} onConfirm={this.onSessionRevoke(session.id)}
autoFocus
> >
Force logout Force logout
</ConfirmButton> </ConfirmButton>
@ -87,7 +95,7 @@ export class UserSessions extends PureComponent<Props, State> {
</div> </div>
<div className={logoutFromAllDevicesClass}> <div className={logoutFromAllDevicesClass}>
{canLogout && sessions.length > 0 && ( {canLogout && sessions.length > 0 && (
<Button variant="secondary" onClick={this.showLogoutConfirmationModal(true)}> <Button variant="secondary" onClick={this.showLogoutConfirmationModal} ref={this.forceAllLogoutButton}>
Force logout from all devices Force logout from all devices
</Button> </Button>
)} )}
@ -97,7 +105,7 @@ export class UserSessions extends PureComponent<Props, State> {
body="Are you sure you want to force logout from all devices?" body="Are you sure you want to force logout from all devices?"
confirmText="Force logout" confirmText="Force logout"
onConfirm={this.onAllSessionsRevoke} onConfirm={this.onAllSessionsRevoke}
onDismiss={this.showLogoutConfirmationModal(false)} onDismiss={this.dismissLogoutConfirmationModal}
/> />
</div> </div>
</div> </div>

View File

@ -75,9 +75,9 @@ describe('ApiKeysPage', () => {
setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true }); setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true });
expect(screen.getByRole('table')).toBeInTheDocument(); expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getAllByRole('row').length).toBe(4); expect(screen.getAllByRole('row').length).toBe(4);
expect(screen.getByRole('row', { name: /first admin 2021-01-01 00:00:00 cancel delete/i })).toBeInTheDocument(); expect(screen.getByRole('row', { name: /first admin 2021-01-01 00:00:00/i })).toBeInTheDocument();
expect(screen.getByRole('row', { name: /second editor 2021-01-02 00:00:00 cancel delete/i })).toBeInTheDocument(); expect(screen.getByRole('row', { name: /second editor 2021-01-02 00:00:00/i })).toBeInTheDocument();
expect(screen.getByRole('row', { name: /third viewer no expiration date cancel delete/i })).toBeInTheDocument(); expect(screen.getByRole('row', { name: /third viewer no expiration date/i })).toBeInTheDocument();
}); });
}); });
@ -118,27 +118,24 @@ describe('ApiKeysPage', () => {
{ id: 3, name: 'Third', role: OrgRole.Viewer, secondsToLive: 0, expiration: undefined }, { id: 3, name: 'Third', role: OrgRole.Viewer, secondsToLive: 0, expiration: undefined },
]; ];
const { deleteApiKeyMock } = setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true }); const { deleteApiKeyMock } = setup({ apiKeys, apiKeysCount: apiKeys.length, hasFetched: true });
const firstRow = screen.getByRole('row', { name: /first admin 2021-01-01 00:00:00 cancel delete/i }); const firstRow = screen.getByRole('row', { name: /first admin 2021-01-01 00:00:00/i });
const secondRow = screen.getByRole('row', { name: /second editor 2021-01-02 00:00:00 cancel delete/i }); const secondRow = screen.getByRole('row', { name: /second editor 2021-01-02 00:00:00/i });
deleteApiKeyMock.mockClear(); deleteApiKeyMock.mockClear();
expect(within(firstRow).getByRole('cell', { name: /cancel delete/i })).toBeInTheDocument(); expect(within(firstRow).getByLabelText('Delete API key')).toBeInTheDocument();
userEvent.click(within(firstRow).getByRole('cell', { name: /cancel delete/i })); userEvent.click(within(firstRow).getByLabelText('Delete API key'));
expect(within(firstRow).getByRole('button', { name: /delete$/i })).toBeInTheDocument(); expect(within(firstRow).getByRole('button', { name: /delete$/i })).toBeInTheDocument();
// TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed userEvent.click(within(firstRow).getByRole('button', { name: /delete$/i }));
userEvent.click(within(firstRow).getByRole('button', { name: /delete$/i }), undefined, {
skipPointerEventsCheck: true,
});
expect(deleteApiKeyMock).toHaveBeenCalledTimes(1); expect(deleteApiKeyMock).toHaveBeenCalledTimes(1);
expect(deleteApiKeyMock).toHaveBeenCalledWith(1, false); expect(deleteApiKeyMock).toHaveBeenCalledWith(1, false);
toggleShowExpired(); toggleShowExpired();
deleteApiKeyMock.mockClear(); deleteApiKeyMock.mockClear();
expect(within(secondRow).getByRole('cell', { name: /cancel delete/i })).toBeInTheDocument(); expect(within(secondRow).getByLabelText('Delete API key')).toBeInTheDocument();
userEvent.click(within(secondRow).getByRole('cell', { name: /cancel delete/i })); userEvent.click(within(secondRow).getByLabelText('Delete API key'));
expect(within(secondRow).getByRole('button', { name: /delete$/i })).toBeInTheDocument(); expect(within(secondRow).getByRole('button', { name: /delete$/i })).toBeInTheDocument();
// TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed
userEvent.click(within(secondRow).getByRole('button', { name: /delete$/i }), undefined, { userEvent.click(within(secondRow).getByRole('button', { name: /delete$/i }), undefined, {
skipPointerEventsCheck: true, skipPointerEventsCheck: true,
}); });

View File

@ -116,9 +116,7 @@ describe('AnnotationsSettings', () => {
expect(screen.getByRole('heading', { name: /annotations/i })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: /annotations/i })).toBeInTheDocument();
expect(screen.queryByRole('table')).toBeInTheDocument(); expect(screen.queryByRole('table')).toBeInTheDocument();
expect( expect(screen.getByRole('row', { name: /annotations & alerts \(built\-in\) grafana/i })).toBeInTheDocument();
screen.getByRole('row', { name: /annotations & alerts \(built\-in\) grafana cancel delete/i })
).toBeInTheDocument();
expect( expect(
screen.queryByLabelText(selectors.components.CallToActionCard.button('Add annotation query')) screen.queryByLabelText(selectors.components.CallToActionCard.button('Add annotation query'))
).toBeInTheDocument(); ).toBeInTheDocument();
@ -142,14 +140,14 @@ describe('AnnotationsSettings', () => {
userEvent.click(within(heading).getByText(/annotations/i)); userEvent.click(within(heading).getByText(/annotations/i));
expect(screen.getByRole('table')).toBeInTheDocument(); expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getByRole('row', { name: /my annotation \(built\-in\) grafana cancel delete/i })).toBeInTheDocument(); expect(screen.getByRole('row', { name: /my annotation \(built\-in\) grafana/i })).toBeInTheDocument();
expect( expect(
screen.queryByLabelText(selectors.components.CallToActionCard.button('Add annotation query')) screen.queryByLabelText(selectors.components.CallToActionCard.button('Add annotation query'))
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /new query/i })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /new query/i })).not.toBeInTheDocument();
// TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed userEvent.click(screen.getAllByLabelText(/Delete query with title/)[0]);
userEvent.click(screen.getByRole('button', { name: /^delete$/i }), undefined, { skipPointerEventsCheck: true }); userEvent.click(screen.getByRole('button', { name: 'Delete' }));
expect(screen.queryAllByRole('row').length).toBe(0); expect(screen.queryAllByRole('row').length).toBe(0);
expect( expect(
@ -238,9 +236,7 @@ describe('AnnotationsSettings', () => {
userEvent.click(within(heading).getByText(/annotations/i)); userEvent.click(within(heading).getByText(/annotations/i));
expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(2); expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(2);
expect( expect(screen.queryByRole('row', { name: /my prometheus annotation prometheus/i })).toBeInTheDocument();
screen.queryByRole('row', { name: /my prometheus annotation prometheus cancel delete/i })
).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /new query/i })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /new query/i })).toBeInTheDocument();
expect( expect(
screen.queryByLabelText(selectors.components.CallToActionCard.button('Add annotation query')) screen.queryByLabelText(selectors.components.CallToActionCard.button('Add annotation query'))
@ -252,8 +248,8 @@ describe('AnnotationsSettings', () => {
expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(3); expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(3);
// TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed userEvent.click(screen.getAllByLabelText(/Delete query with title/)[0]);
userEvent.click(screen.getAllByRole('button', { name: /delete/i })[1], undefined, { skipPointerEventsCheck: true }); userEvent.click(screen.getByRole('button', { name: 'Delete' }));
expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(2); expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(2);
}); });

View File

@ -131,10 +131,8 @@ describe('LinksSettings', () => {
expect(getTableBodyRows().length).toBe(links.length); expect(getTableBodyRows().length).toBe(links.length);
// TODO remove skipPointerEventsCheck once https://github.com/jsdom/jsdom/issues/3232 is fixed userEvent.click(within(getTableBody()).getAllByLabelText(/Delete link with title/)[0]);
userEvent.click(within(getTableBody()).getAllByRole('button', { name: 'Delete' })[0], undefined, { userEvent.click(within(getTableBody()).getByRole('button', { name: 'Delete' }));
skipPointerEventsCheck: true,
});
expect(getTableBodyRows().length).toBe(links.length - 1); expect(getTableBodyRows().length).toBe(links.length - 1);
expect(within(getTableBody()).queryByText(links[0].title)).not.toBeInTheDocument(); expect(within(getTableBody()).queryByText(links[0].title)).not.toBeInTheDocument();