Commit 8505d907 by Alexander Zobnin Committed by Torkel Ödegaard

Admin: New Admin User page (#20498)

* admin: user page to react WIP

* admin user page: basic view

* admin user page: refactor, extract orgs and permissions components

* admin user: change sessions actions styles

* admin user: add disable button

* user admin: add change grafana admin action

* user admin: able to change org role and remove org

* user admin: confirm force logout

* user admin: change org button style

* user admin: add confirm modals for critical actions

* user admin: lock down ldap user info

* user admin: align with latest design changes

* user admin: add LDAP sync

* admin user: confirm button

* user admin: add to org modal

* user admin: fix ConfirmButton story

* admin user: handle grafana admin change

* ConfirmButton: make styled component

* ConfirmButton: completely styled component

* User Admin: permissions section refactor

* admin user: refactor (orgs and sessions)

* ConfirmButton: able to set confirm variant

* admin user: inline org removal

* admin user: show ldap sync info only for ldap users

* admin user: edit profile

* ConfirmButton: some fixes after review

* Chore: fix storybook build

* admin user: rename handlers

* admin user: remove LdapUserPage import from routes

* Chore: fix ConfirmButton tests

* Chore: fix user api endpoint tests

* Chore: update failed test snapshots

* admin user: redux actions WIP

* admin user: use new ConfirmModal component for user profile

* admin user: use new ConfirmModal component for sessions

* admin user: use lockMessage

* ConfirmButton: use primary button as default

* admin user: fix ActionButton color

* UI: use Icon component for Modal

* UI: refactor ConfirmModal after Modal changes

* UI: add link button variant

* UI: able to use custom ConfirmButton

* Chore: fix type errors after ConfirmButton refactor

* Chore: revert Graph component changes (works with TS 3.7)

* Chore: use Forms.Button instead of ActionButton

* admin user: align items

* admin user: align add to org modal

* UI: organization picker component

* admin user: use org picker for AddToOrgModal

* admin user: org actions

* admin user: connect sessions actions

* admin user: updateUserPermissions action

* admin user: enable delete user action

* admin user: sync ldap user

* Chore: refactor, remove unused code

* Chore: refactor, move api calls to actions

* admin user: set user password action

* Chore: refactor, remove unused components

* admin user: set input focus on edit

* admin user: pass user into debug LDAP mapping

* UserAdminPage: Ux changes

* UserAdminPage: align buttons to the left

* UserAdminPage: align delete user button

* UserAdminPage: swap add to org modal buttons

* UserAdminPage: set password field to empty when editing

* UserAdminPage: fix tests

* Updated button border

* Chore: fix ConfirmButton after changes introduced in #21092

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
parent 108039af
......@@ -16,7 +16,7 @@ const defaultProps = {
const variants = {
size: ['xs', 'sm', 'md', 'lg'],
variant: ['primary', 'secondary', 'danger', 'inverse', 'transparent'],
variant: ['primary', 'secondary', 'danger', 'inverse', 'transparent', 'link'],
};
const combinationOptions = {
CombinationRenderer: ThemeableCombinationsRowRenderer,
......
......@@ -67,6 +67,13 @@ export const getButtonStyles = stylesFactory(({ theme, size, variant, textAndIco
background: transparent;
`;
break;
case 'link':
background = css`
${buttonVariantStyles('', '', theme.colors.linkExternal, 'rgba(0, 0, 0, 0.1)', true)};
background: transparent;
`;
break;
}
return {
......
......@@ -133,9 +133,11 @@ class UnThemedConfirmButton extends PureComponent<Props, State> {
return (
<span className={styles.buttonContainer}>
{typeof children === 'string' ? (
<Forms.Button className={buttonClass} size={size} variant="link" onClick={onClick}>
{children}
</Forms.Button>
<span className={buttonClass}>
<Forms.Button size={size} variant="link" onClick={onClick}>
{children}
</Forms.Button>
</span>
) : (
<span className={buttonClass} onClick={onClick}>
{children}
......
......@@ -38,7 +38,7 @@ const getPropertiesForVariant = (theme: GrafanaTheme, variant: ButtonVariant) =>
) as string;
return {
borderColor: selectThemeVariant({ light: theme.colors.gray70, dark: theme.colors.gray33 }, theme.type),
borderColor: selectThemeVariant({ light: theme.colors.gray85, dark: theme.colors.gray25 }, theme.type),
background: buttonVariantStyles(
from,
to,
......@@ -57,7 +57,6 @@ const getPropertiesForVariant = (theme: GrafanaTheme, variant: ButtonVariant) =>
borderColor: 'transparent',
background: buttonVariantStyles('transparent', 'transparent', theme.colors.linkExternal),
variantStyles: css`
text-decoration: underline;
&:focus {
outline: none;
box-shadow: none;
......
......@@ -11,6 +11,7 @@ export enum InputStatus {
interface Props extends React.HTMLProps<HTMLInputElement> {
validationEvents?: ValidationEvents;
hideErrorMessage?: boolean;
inputRef?: React.LegacyRef<HTMLInputElement>;
// Override event props and append status as argument
onBlur?: (event: React.FocusEvent<HTMLInputElement>, status?: InputStatus) => void;
......@@ -70,14 +71,14 @@ export class Input extends PureComponent<Props, State> {
};
render() {
const { validationEvents, className, hideErrorMessage, ...restProps } = this.props;
const { validationEvents, className, hideErrorMessage, inputRef, ...restProps } = this.props;
const { error } = this.state;
const inputClassName = classNames('gf-form-input', { invalid: this.isInvalid }, className);
const inputElementProps = this.populateEventPropsWithStatus(restProps, validationEvents);
return (
<div style={{ flexGrow: 1 }}>
<input {...inputElementProps} className={inputClassName} />
<input {...inputElementProps} ref={inputRef} className={inputClassName} />
{error && !hideErrorMessage && <span>{error}</span>}
</div>
);
......
......@@ -36,6 +36,8 @@ func getUserUserProfile(userID int64) Response {
query.Result.IsExternal = true
}
query.Result.AvatarUrl = dtos.GetGravatarUrl(query.Result.Email)
return JSON(200, query.Result)
}
......
package api
import (
"fmt"
"net/http"
"testing"
"time"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/bus"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/models"
......@@ -48,9 +50,10 @@ func TestUserApiEndpoint(t *testing.T) {
})
sc.handlerFunc = GetUserByID
avatarUrl := dtos.GetGravatarUrl("daniel@grafana.com")
sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
expected := `
expected := fmt.Sprintf(`
{
"id": 1,
"email": "daniel@grafana.com",
......@@ -64,10 +67,11 @@ func TestUserApiEndpoint(t *testing.T) {
"authLabels": [
"LDAP"
],
"avatarUrl": "%s",
"updatedAt": "2019-02-11T17:30:40Z",
"createdAt": "2019-02-11T17:30:40Z"
}
`
`, avatarUrl)
require.Equal(t, http.StatusOK, sc.resp.Code)
require.JSONEq(t, expected, sc.resp.Body.String())
......@@ -109,6 +113,7 @@ func TestUserApiEndpoint(t *testing.T) {
"isDisabled": false,
"authLabels": null,
"isExternal": false,
"avatarUrl": "",
"updatedAt": "2019-02-11T17:30:40Z",
"createdAt": "2019-02-11T17:30:40Z"
}
......
......@@ -227,6 +227,7 @@ type UserProfileDTO struct {
AuthLabels []string `json:"authLabels"`
UpdatedAt time.Time `json:"updatedAt"`
CreatedAt time.Time `json:"createdAt"`
AvatarUrl string `json:"avatarUrl"`
}
type UserSearchHitDTO struct {
......
import React, { PureComponent } from 'react';
import { AsyncSelect } from '@grafana/ui';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { Organization } from 'app/types';
import { SelectableValue } from '@grafana/data';
export interface OrgSelectItem {
id: number;
value: number;
label: string;
name: string;
}
export interface Props {
onSelected: (org: OrgSelectItem) => void;
className?: string;
}
export interface State {
isLoading: boolean;
}
export class OrgPicker extends PureComponent<Props, State> {
orgs: Organization[];
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<Array<SelectableValue<number>>> => {
if (!this.orgs) {
await this.loadOrgs();
}
return this.orgs.map(
(org: Organization): SelectableValue<number> => ({
id: org.id,
value: org.id,
label: org.name,
name: org.name,
})
);
};
render() {
const { className, onSelected } = this.props;
const { isLoading } = this.state;
return (
<div className="org-picker">
<AsyncSelect
className={className}
isLoading={isLoading}
defaultOptions={true}
isSearchable={false}
loadOptions={this.getOrgOptions}
onChange={onSelected}
placeholder="Select organization"
noOptionsMessage={() => 'No organizations found'}
/>
</div>
);
}
}
import React, { FC } from 'react';
import { UserInfo } from './UserInfo';
import { LdapUserPermissions } from './ldap/LdapUserPermissions';
import { User } from 'app/types';
interface Props {
user: User;
}
export const DisabledUserInfo: FC<Props> = ({ user }) => {
return (
<>
<LdapUserPermissions
permissions={{
isGrafanaAdmin: (user as any).isGrafanaAdmin,
isDisabled: (user as any).isDisabled,
}}
/>
<UserInfo user={user} />
</>
);
};
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import { NavModel } from '@grafana/data';
import { getNavModel } from 'app/core/selectors/navModel';
import { getRouteParamsId } from 'app/core/selectors/location';
import config from 'app/core/config';
import Page from 'app/core/components/Page/Page';
import { UserProfile } from './UserProfile';
import { UserPermissions } from './UserPermissions';
import { UserSessions } from './UserSessions';
import { UserLdapSyncInfo } from './UserLdapSyncInfo';
import { StoreState, UserDTO, UserOrg, UserSession, SyncInfo, UserAdminError } from 'app/types';
import {
loadAdminUserPage,
revokeSession,
revokeAllSessions,
updateUser,
setUserPassword,
disableUser,
enableUser,
deleteUser,
updateUserPermissions,
addOrgUser,
updateOrgUserRole,
deleteOrgUser,
syncLdapUser,
} from './state/actions';
import { UserOrgs } from './UserOrgs';
interface Props {
navModel: NavModel;
userId: number;
user: UserDTO;
orgs: UserOrg[];
sessions: UserSession[];
ldapSyncInfo: SyncInfo;
isLoading: boolean;
error: UserAdminError;
loadAdminUserPage: typeof loadAdminUserPage;
revokeSession: typeof revokeSession;
revokeAllSessions: typeof revokeAllSessions;
updateUser: typeof updateUser;
setUserPassword: typeof setUserPassword;
disableUser: typeof disableUser;
enableUser: typeof enableUser;
deleteUser: typeof deleteUser;
updateUserPermissions: typeof updateUserPermissions;
addOrgUser: typeof addOrgUser;
updateOrgUserRole: typeof updateOrgUserRole;
deleteOrgUser: typeof deleteOrgUser;
syncLdapUser: typeof syncLdapUser;
}
interface State {
// isLoading: boolean;
}
export class UserAdminPage extends PureComponent<Props, State> {
state = {
// isLoading: true,
};
async componentDidMount() {
const { userId, loadAdminUserPage } = this.props;
loadAdminUserPage(userId);
}
onUserUpdate = (user: UserDTO) => {
this.props.updateUser(user);
};
onPasswordChange = (password: string) => {
const { userId, setUserPassword } = this.props;
setUserPassword(userId, password);
};
onUserDelete = (userId: number) => {
this.props.deleteUser(userId);
};
onUserDisable = (userId: number) => {
this.props.disableUser(userId);
};
onUserEnable = (userId: number) => {
this.props.enableUser(userId);
};
onGrafanaAdminChange = (isGrafanaAdmin: boolean) => {
const { userId, updateUserPermissions } = this.props;
updateUserPermissions(userId, isGrafanaAdmin);
};
onOrgRemove = (orgId: number) => {
const { userId, deleteOrgUser } = this.props;
deleteOrgUser(userId, orgId);
};
onOrgRoleChange = (orgId: number, newRole: string) => {
const { userId, updateOrgUserRole } = this.props;
updateOrgUserRole(userId, orgId, newRole);
};
onOrgAdd = (orgId: number, role: string) => {
const { user, addOrgUser } = this.props;
addOrgUser(user, orgId, role);
};
onSessionRevoke = (tokenId: number) => {
const { userId, revokeSession } = this.props;
revokeSession(tokenId, userId);
};
onAllSessionsRevoke = () => {
const { userId, revokeAllSessions } = this.props;
revokeAllSessions(userId);
};
onUserSync = () => {
const { userId, syncLdapUser } = this.props;
syncLdapUser(userId);
};
render() {
const { navModel, user, orgs, sessions, ldapSyncInfo, isLoading } = this.props;
// const { isLoading } = this.state;
const isLDAPUser = user && user.isExternal && user.authLabels && user.authLabels.includes('LDAP');
return (
<Page navModel={navModel}>
<Page.Contents isLoading={isLoading}>
{user && (
<>
<UserProfile
user={user}
onUserUpdate={this.onUserUpdate}
onUserDelete={this.onUserDelete}
onUserDisable={this.onUserDisable}
onUserEnable={this.onUserEnable}
onPasswordChange={this.onPasswordChange}
/>
{isLDAPUser && config.buildInfo.isEnterprise && ldapSyncInfo && (
<UserLdapSyncInfo ldapSyncInfo={ldapSyncInfo} user={user} onUserSync={this.onUserSync} />
)}
<UserPermissions isGrafanaAdmin={user.isGrafanaAdmin} onGrafanaAdminChange={this.onGrafanaAdminChange} />
</>
)}
{orgs && (
<UserOrgs
orgs={orgs}
onOrgRemove={this.onOrgRemove}
onOrgRoleChange={this.onOrgRoleChange}
onOrgAdd={this.onOrgAdd}
/>
)}
{sessions && (
<UserSessions
sessions={sessions}
onSessionRevoke={this.onSessionRevoke}
onAllSessionsRevoke={this.onAllSessionsRevoke}
/>
)}
</Page.Contents>
</Page>
);
}
}
const mapStateToProps = (state: StoreState) => ({
userId: getRouteParamsId(state.location),
navModel: getNavModel(state.navIndex, 'global-users'),
user: state.userAdmin.user,
sessions: state.userAdmin.sessions,
orgs: state.userAdmin.orgs,
ldapSyncInfo: state.ldap.syncInfo,
isLoading: state.userAdmin.isLoading,
error: state.userAdmin.error,
});
const mapDispatchToProps = {
loadAdminUserPage,
updateUser,
setUserPassword,
disableUser,
enableUser,
deleteUser,
updateUserPermissions,
addOrgUser,
updateOrgUserRole,
deleteOrgUser,
revokeSession,
revokeAllSessions,
syncLdapUser,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(UserAdminPage));
import React, { FC } from 'react';
import { User } from 'app/types';
interface Props {
user: User;
}
export const UserInfo: FC<Props> = ({ user }) => {
return (
<div className="gf-form-group">
<div className="gf-form">
<table className="filter-table form-inline">
<thead>
<tr>
<th colSpan={2}>User information</th>
</tr>
</thead>
<tbody>
<tr>
<td className="width-16">Name</td>
<td>{user.name}</td>
</tr>
<tr>
<td className="width-16">Username</td>
<td>{user.login}</td>
</tr>
<tr>
<td className="width-16">Email</td>
<td>{user.email}</td>
</tr>
</tbody>
</table>
</div>
</div>
);
};
import React, { PureComponent } from 'react';
import { dateTime } from '@grafana/data';
import { SyncInfo, UserDTO } from 'app/types';
import { Button, LinkButton } from '@grafana/ui';
interface Props {
ldapSyncInfo: SyncInfo;
user: UserDTO;
onUserSync: () => void;
}
interface State {}
const syncTimeFormat = 'dddd YYYY-MM-DD HH:mm zz';
const debugLDAPMappingBaseURL = '/admin/ldap';
export class UserLdapSyncInfo extends PureComponent<Props, State> {
onUserSync = () => {
this.props.onUserSync();
};
render() {
const { ldapSyncInfo, user } = this.props;
const nextSyncTime = dateTime(ldapSyncInfo.nextSync).format(syncTimeFormat);
const prevSyncSuccessful = ldapSyncInfo && ldapSyncInfo.prevSync;
const prevSyncTime = prevSyncSuccessful ? dateTime(ldapSyncInfo.prevSync.started).format(syncTimeFormat) : '';
const debugLDAPMappingURL = `${debugLDAPMappingBaseURL}?user=${user && user.login}`;
return (
<>
<h3 className="page-heading">LDAP Synchronisation</h3>
<div className="gf-form-group">
<div className="gf-form">
<table className="filter-table form-inline">
<tbody>
<tr>
<td>External sync</td>
<td>User synced via LDAP – some changes must be done in LDAP or mappings.</td>
<td>
<span className="label label-tag">LDAP</span>
</td>
</tr>
<tr>
{ldapSyncInfo.enabled ? (
<>
<td>Next scheduled synchronisation</td>
<td colSpan={2}>{nextSyncTime}</td>
</>
) : (
<>
<td>Next scheduled synchronisation</td>
<td colSpan={2}>Not enabled</td>
</>
)}
</tr>
<tr>
{prevSyncSuccessful ? (
<>
<td>Last synchronisation</td>
<td>{prevSyncTime}</td>
<td>Successful</td>
</>
) : (
<td colSpan={3}>Last synchronisation</td>
)}
</tr>
</tbody>
</table>
</div>
<div className="gf-form-button-row">
<Button variant="secondary" onClick={this.onUserSync}>
Sync user
</Button>
<LinkButton variant="inverse" href={debugLDAPMappingURL}>
Debug LDAP Mapping
</LinkButton>
</div>
</div>
</>
);
}
}
import React, { PureComponent } from 'react';
import { css, cx } from 'emotion';
import { Modal, Themeable, stylesFactory, withTheme, ConfirmButton, Forms } from '@grafana/ui';
import { GrafanaTheme } from '@grafana/data';
import { UserOrg, Organization } from 'app/types';
import { OrgPicker, OrgSelectItem } from 'app/core/components/Select/OrgPicker';
interface Props {
orgs: UserOrg[];
onOrgRemove: (orgId: number) => void;
onOrgRoleChange: (orgId: number, newRole: string) => void;
onOrgAdd: (orgId: number, role: string) => void;
}
interface State {
showAddOrgModal: boolean;
}
export class UserOrgs extends PureComponent<Props, State> {
state = {
showAddOrgModal: false,
};
showOrgAddModal = (show: boolean) => () => {
this.setState({ showAddOrgModal: show });
};
render() {
const { orgs, onOrgRoleChange, onOrgRemove, onOrgAdd } = this.props;
const { showAddOrgModal } = this.state;
const addToOrgContainerClass = css`
margin-top: 0.8rem;
`;
return (
<>
<h3 className="page-heading">Organisations</h3>
<div className="gf-form-group">
<div className="gf-form">
<table className="filter-table form-inline">
<tbody>
{orgs.map((org, index) => (
<OrgRow
key={`${org.orgId}-${index}`}
org={org}
onOrgRoleChange={onOrgRoleChange}
onOrgRemove={onOrgRemove}
/>
))}
</tbody>
</table>
</div>
<div className={addToOrgContainerClass}>
<Forms.Button variant="secondary" onClick={this.showOrgAddModal(true)}>
Add user to organization
</Forms.Button>
</div>
<AddToOrgModal isOpen={showAddOrgModal} onOrgAdd={onOrgAdd} onDismiss={this.showOrgAddModal(false)} />
</div>
</>
);
}
}
const ORG_ROLES = ['Viewer', 'Editor', 'Admin'];
const getOrgRowStyles = stylesFactory((theme: GrafanaTheme) => {
return {
removeButton: css`
margin-right: 0.6rem;
text-decoration: underline;
color: ${theme.colors.blue95};
`,
label: css`
font-weight: 500;
`,
};
});
interface OrgRowProps extends Themeable {
org: UserOrg;
onOrgRemove: (orgId: number) => void;
onOrgRoleChange: (orgId: number, newRole: string) => void;
}
interface OrgRowState {
currentRole: string;
isChangingRole: boolean;
isRemovingFromOrg: boolean;
}
class UnThemedOrgRow extends PureComponent<OrgRowProps, OrgRowState> {
state = {
currentRole: this.props.org.role,
isChangingRole: false,
isRemovingFromOrg: false,
};
onOrgRemove = () => {
const { org } = this.props;
this.props.onOrgRemove(org.orgId);
};
onChangeRoleClick = () => {
const { org } = this.props;
this.setState({ isChangingRole: true, currentRole: org.role });
};
onOrgRemoveClick = () => {
this.setState({ isRemovingFromOrg: true });
};
onOrgRoleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const newRole = event.target.value;
this.setState({ currentRole: newRole });
};
onOrgRoleSave = () => {
this.props.onOrgRoleChange(this.props.org.orgId, this.state.currentRole);
};
onCancelClick = () => {
this.setState({ isChangingRole: false, isRemovingFromOrg: false });
};
render() {
const { org, theme } = this.props;
const { currentRole, isChangingRole, isRemovingFromOrg } = this.state;
const styles = getOrgRowStyles(theme);
const labelClass = cx('width-16', styles.label);
return (
<tr>
<td className={labelClass}>{org.name}</td>
{isChangingRole ? (
<td>
<div className="gf-form-select-wrapper width-8">
<select value={currentRole} className="gf-form-input" onChange={this.onOrgRoleChange}>
{ORG_ROLES.map((option, index) => {
return (
<option value={option} key={`${option}-${index}`}>
{option}
</option>
);
})}
</select>
</div>
</td>
) : (
<td className="width-25">{org.role}</td>
)}
{!isRemovingFromOrg && (
<td colSpan={isChangingRole ? 2 : 1}>
<div className="pull-right">
<ConfirmButton
confirmText="Save"
onClick={this.onChangeRoleClick}
onCancel={this.onCancelClick}
onConfirm={this.onOrgRoleSave}
>
Change role
</ConfirmButton>
</div>
</td>
)}
{!isChangingRole && (
<td colSpan={isRemovingFromOrg ? 2 : 1}>
<div className="pull-right">
<ConfirmButton
confirmText="Confirm removal"
confirmVariant="danger"
onClick={this.onOrgRemoveClick}
onCancel={this.onCancelClick}
onConfirm={this.onOrgRemove}
>
Remove from organisation
</ConfirmButton>
</div>
</td>
)}
</tr>
);
}
}
const OrgRow = withTheme(UnThemedOrgRow);
const getAddToOrgModalStyles = stylesFactory(() => ({
modal: css`
width: 500px;
`,
buttonRow: css`
text-align: center;
`,
}));
interface AddToOrgModalProps {
isOpen: boolean;
onOrgAdd(orgId: number, role: string): void;
onDismiss?(): void;
}
interface AddToOrgModalState {
selectedOrg: Organization;
role: string;
}
export class AddToOrgModal extends PureComponent<AddToOrgModalProps, AddToOrgModalState> {
state: AddToOrgModalState = {
selectedOrg: null,
role: 'Admin',
};
onOrgSelect = (org: OrgSelectItem) => {
this.setState({ selectedOrg: { ...org } });
};
onOrgRoleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
this.setState({
role: event.target.value,
});
};
onAddUserToOrg = () => {
const { selectedOrg, role } = this.state;
this.props.onOrgAdd(selectedOrg.id, role);
};
onCancel = () => {
this.props.onDismiss();
};
render() {
const { isOpen } = this.props;
const { role } = this.state;
const styles = getAddToOrgModalStyles();
const buttonRowClass = cx('gf-form-button-row', styles.buttonRow);
return (
<Modal className={styles.modal} title="Add to an organization" isOpen={isOpen} onDismiss={this.onCancel}>
<div className="gf-form-group">
<h6 className="">Organisation</h6>
<OrgPicker className="width-25" onSelected={this.onOrgSelect} />
</div>
<div className="gf-form-group">
<h6 className="">Role</h6>
<div className="gf-form-select-wrapper width-16">
<select value={role} className="gf-form-input" onChange={this.onOrgRoleChange}>
{ORG_ROLES.map((option, index) => {
return (
<option value={option} key={`${option}-${index}`}>
{option}
</option>
);
})}
</select>
</div>
</div>
<div className={buttonRowClass}>
<Forms.Button variant="primary" onClick={this.onAddUserToOrg}>
Add to organization
</Forms.Button>
<Forms.Button variant="secondary" onClick={this.onCancel}>
Cancel
</Forms.Button>
</div>
</Modal>
);
}
}
import React, { PureComponent } from 'react';
import { ConfirmButton } from '@grafana/ui';
import { cx } from 'emotion';
interface Props {
isGrafanaAdmin: boolean;
onGrafanaAdminChange: (isGrafanaAdmin: boolean) => void;
}
interface State {
isEditing: boolean;
currentAdminOption: string;
}
export class UserPermissions extends PureComponent<Props, State> {
state = {
isEditing: false,
currentAdminOption: this.props.isGrafanaAdmin ? 'YES' : 'NO',
};
onChangeClick = () => {
this.setState({ isEditing: true });
};
onCancelClick = () => {
this.setState({
isEditing: false,
currentAdminOption: this.props.isGrafanaAdmin ? 'YES' : 'NO',
});
};
onGrafanaAdminChange = () => {
const { currentAdminOption } = this.state;
const newIsGrafanaAdmin = currentAdminOption === 'YES' ? true : false;
this.props.onGrafanaAdminChange(newIsGrafanaAdmin);
};
onAdminOptionSelect = (event: React.ChangeEvent<HTMLSelectElement>) => {
this.setState({ currentAdminOption: event.target.value });
};
render() {
const { isGrafanaAdmin } = this.props;
const { isEditing, currentAdminOption } = this.state;
const changeButtonContainerClass = cx('pull-right');
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}>
<div className="gf-form-select-wrapper width-8">
<select
value={currentAdminOption}
className="gf-form-input"
onChange={this.onAdminOptionSelect}
>
{['YES', 'NO'].map((option, index) => {
return (
<option value={option} key={`${option}-${index}`}>
{option}
</option>
);
})}
</select>
</div>
</td>
) : (
<td colSpan={2}>
{isGrafanaAdmin ? (
<>
<i className="gicon gicon-shield" /> Yes
</>
) : (
<>No</>
)}
</td>
)}
<td>
<div className={changeButtonContainerClass}>
<ConfirmButton
className="pull-right"
onClick={this.onChangeClick}
onConfirm={this.onGrafanaAdminChange}
onCancel={this.onCancelClick}
confirmText="Change"
>
Change
</ConfirmButton>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</>
);
}
}
import React, { PureComponent, FC } from 'react';
import { UserDTO } from 'app/types';
import { cx, css } from 'emotion';
import { config } from 'app/core/config';
import { GrafanaTheme } from '@grafana/data';
import { ConfirmButton, Input, ConfirmModal, InputStatus, Forms, stylesFactory } from '@grafana/ui';
interface Props {
user: UserDTO;
onUserUpdate: (user: UserDTO) => void;
onUserDelete: (userId: number) => void;
onUserDisable: (userId: number) => void;
onUserEnable: (userId: number) => void;
onPasswordChange(password: string): void;
}
interface State {
isLoading: boolean;
showDeleteModal: boolean;
showDisableModal: boolean;
}
export class UserProfile extends PureComponent<Props, State> {
state = {
isLoading: false,
showDeleteModal: false,
showDisableModal: false,
};
showDeleteUserModal = (show: boolean) => () => {
this.setState({ showDeleteModal: show });
};
showDisableUserModal = (show: boolean) => () => {
this.setState({ showDisableModal: show });
};
onUserDelete = () => {
const { user, onUserDelete } = this.props;
onUserDelete(user.id);
};
onUserDisable = () => {
const { user, onUserDisable } = this.props;
onUserDisable(user.id);
};
onUserEnable = () => {
const { user, onUserEnable } = this.props;
onUserEnable(user.id);
};
onUserNameChange = (newValue: string) => {
const { user, onUserUpdate } = this.props;
onUserUpdate({
...user,
name: newValue,
});
};
onUserEmailChange = (newValue: string) => {
const { user, onUserUpdate } = this.props;
onUserUpdate({
...user,
email: newValue,
});
};
onUserLoginChange = (newValue: string) => {
const { user, onUserUpdate } = this.props;
onUserUpdate({
...user,
login: newValue,
});
};
onPasswordChange = (newValue: string) => {
this.props.onPasswordChange(newValue);
};
render() {
const { user } = this.props;
const { showDeleteModal, showDisableModal } = this.state;
const lockMessage = 'Synced via LDAP';
const styles = getStyles(config.theme);
return (
<>
<h3 className="page-heading">User information</h3>
<div className="gf-form-group">
<div className="gf-form">
<table className="filter-table form-inline">
<tbody>
<UserProfileRow
label="Name"
value={user.name}
locked={user.isExternal}
lockMessage={lockMessage}
onChange={this.onUserNameChange}
/>
<UserProfileRow
label="Email"
value={user.email}
locked={user.isExternal}
lockMessage={lockMessage}
onChange={this.onUserEmailChange}
/>
<UserProfileRow
label="Username"
value={user.login}
locked={user.isExternal}
lockMessage={lockMessage}
onChange={this.onUserLoginChange}
/>
<UserProfileRow
label="Password"
value="********"
inputType="password"
locked={user.isExternal}
lockMessage={lockMessage}
onChange={this.onPasswordChange}
/>
</tbody>
</table>
</div>
<div className={styles.buttonRow}>
<Forms.Button variant="destructive" onClick={this.showDeleteUserModal(true)}>
Delete User
</Forms.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 ? (
<Forms.Button variant="secondary" onClick={this.onUserEnable}>
Enable User
</Forms.Button>
) : (
<Forms.Button variant="secondary" onClick={this.showDisableUserModal(true)}>
Disable User
</Forms.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>
</>
);
}
}
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
buttonRow: css`
margin-top: 0.8rem;
> * {
margin-right: 16px;
}
`,
};
});
interface UserProfileRowProps {
label: string;
value?: string;
locked?: boolean;
lockMessage?: string;
inputType?: string;
onChange?: (value: string) => void;
}
interface UserProfileRowState {
value: string;
editing: boolean;
}
export class UserProfileRow extends PureComponent<UserProfileRowProps, UserProfileRowState> {
inputElem: HTMLInputElement;
static defaultProps: Partial<UserProfileRowProps> = {
value: '',
locked: false,
lockMessage: '',
inputType: 'text',
};
state = {
editing: false,
value: this.props.value || '',
};
setInputElem = (elem: any) => {
this.inputElem = elem;
};
onEditClick = () => {
if (this.props.inputType === 'password') {
// Reset value for password field
this.setState({ editing: true, value: '' }, this.focusInput);
} else {
this.setState({ editing: true }, this.focusInput);
}
};
onCancelClick = () => {
this.setState({ editing: false, value: this.props.value || '' });
};
onInputChange = (event: React.ChangeEvent<HTMLInputElement>, status?: InputStatus) => {
if (status === InputStatus.Invalid) {
return;
}
this.setState({ value: event.target.value });
};
onInputBlur = (event: React.FocusEvent<HTMLInputElement>, status?: InputStatus) => {
if (status === InputStatus.Invalid) {
return;
}
this.setState({ value: event.target.value });
};
focusInput = () => {
if (this.inputElem && this.inputElem.focus) {
this.inputElem.focus();
}
};
onSave = () => {
if (this.props.onChange) {
this.props.onChange(this.state.value);
}
};
render() {
const { label, locked, lockMessage, inputType } = this.props;
const { value } = this.state;
const labelClass = cx(
'width-16',
css`
font-weight: 500;
`
);
const editButtonContainerClass = cx('pull-right');
if (locked) {
return <LockedRow label={label} value={value} lockMessage={lockMessage} />;
}
return (
<tr>
<td className={labelClass}>{label}</td>
<td className="width-25" colSpan={2}>
{this.state.editing ? (
<Input
className="width-20"
type={inputType}
defaultValue={value}
onBlur={this.onInputBlur}
onChange={this.onInputChange}
inputRef={this.setInputElem}
/>
) : (
<span>{this.props.value}</span>
)}
</td>
<td>
<div className={editButtonContainerClass}>
<ConfirmButton
confirmText="Save"
onClick={this.onEditClick}
onConfirm={this.onSave}
onCancel={this.onCancelClick}
>
Edit
</ConfirmButton>
</div>
</td>
</tr>
);
}
}
interface LockedRowProps {
label: string;
value?: any;
lockMessage?: string;
}
export const LockedRow: FC<LockedRowProps> = ({ label, value, lockMessage }) => {
const lockMessageClass = cx(
'pull-right',
css`
font-style: italic;
margin-right: 0.6rem;
`
);
const labelClass = cx(
'width-16',
css`
font-weight: 500;
`
);
return (
<tr>
<td className={labelClass}>{label}</td>
<td className="width-25" colSpan={2}>
{value}
</td>
<td>
<span className={lockMessageClass}>{lockMessage}</span>
</td>
</tr>
);
};
import React, { PureComponent } from 'react';
import { css } from 'emotion';
import { ConfirmButton, ConfirmModal, Forms } from '@grafana/ui';
import { UserSession } from 'app/types';
interface Props {
......@@ -8,19 +10,37 @@ interface Props {
onAllSessionsRevoke: () => void;
}
export class UserSessions extends PureComponent<Props> {
handleSessionRevoke = (id: number) => {
interface State {
showLogoutModal: boolean;
}
export class UserSessions extends PureComponent<Props, State> {
state: State = {
showLogoutModal: false,
};
showLogoutConfirmationModal = (show: boolean) => () => {
this.setState({ showLogoutModal: show });
};
onSessionRevoke = (id: number) => {
return () => {
this.props.onSessionRevoke(id);
};
};
handleAllSessionsRevoke = () => {
onAllSessionsRevoke = () => {
this.setState({ showLogoutModal: false });
this.props.onAllSessionsRevoke();
};
render() {
const { sessions } = this.props;
const { showLogoutModal } = this.state;
const logoutFromAllDevicesClass = css`
margin-top: 0.8rem;
`;
return (
<>
......@@ -45,21 +65,35 @@ export class UserSessions extends PureComponent<Props> {
<td>{session.clientIp}</td>
<td>{`${session.browser} on ${session.os} ${session.osVersion}`}</td>
<td>
<button className="btn btn-danger btn-small" onClick={this.handleSessionRevoke(session.id)}>
<i className="fa fa-power-off" />
</button>
<div className="pull-right">
<ConfirmButton
confirmText="Confirm logout"
confirmVariant="danger"
onConfirm={this.onSessionRevoke(session.id)}
>
Force logout
</ConfirmButton>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="gf-form-button-row">
<div className={logoutFromAllDevicesClass}>
{sessions.length > 0 && (
<button className="btn btn-danger" onClick={this.handleAllSessionsRevoke}>
Logout user from all devices
</button>
<Forms.Button variant="secondary" onClick={this.showLogoutConfirmationModal(true)}>
Force logout from all devices
</Forms.Button>
)}
<ConfirmModal
isOpen={showLogoutModal}
title="Force logout from all devices"
body="Are you sure you want to force logout from all devices?"
confirmText="Force logout"
onConfirm={this.onAllSessionsRevoke}
onDismiss={this.showLogoutConfirmationModal(false)}
/>
</div>
</div>
</>
......
......@@ -19,7 +19,7 @@ export class UserSyncInfo extends PureComponent<Props, State> {
isSyncing: false,
};
handleSyncClick = async () => {
onSyncClick = async () => {
const { onSync } = this.props;
this.setState({ isSyncing: true });
try {
......@@ -41,7 +41,7 @@ export class UserSyncInfo extends PureComponent<Props, State> {
return (
<>
<button className={`btn btn-secondary pull-right`} onClick={this.handleSyncClick} disabled={isDisabled}>
<button className={`btn btn-secondary pull-right`} onClick={this.onSyncClick} disabled={isDisabled}>
<span className="btn-title">Sync user</span>
{isSyncing && <i className="fa fa-spinner fa-fw fa-spin run-icon" />}
</button>
......
......@@ -25,6 +25,7 @@ interface Props {
ldapSyncInfo: SyncInfo;
ldapError: LdapError;
userError?: LdapError;
username?: string;
loadLdapState: typeof loadLdapState;
loadLdapSyncStatus: typeof loadLdapSyncStatus;
......@@ -43,8 +44,12 @@ export class LdapPage extends PureComponent<Props, State> {
};
async componentDidMount() {
await this.props.clearUserMappingInfo();
const { username, clearUserMappingInfo, loadUserMapping } = this.props;
await clearUserMappingInfo();
await this.fetchLDAPStatus();
if (username) {
await loadUserMapping(username);
}
this.setState({ isLoading: false });
}
......@@ -71,7 +76,7 @@ export class LdapPage extends PureComponent<Props, State> {
};
render() {
const { ldapUser, userError, ldapError, ldapSyncInfo, ldapConnectionInfo, navModel } = this.props;
const { ldapUser, userError, ldapError, ldapSyncInfo, ldapConnectionInfo, navModel, username } = this.props;
const { isLoading } = this.state;
return (
......@@ -91,7 +96,15 @@ export class LdapPage extends PureComponent<Props, State> {
<h3 className="page-heading">Test user mapping</h3>
<div className="gf-form-group">
<form onSubmit={this.search} className="gf-form-inline">
<FormField label="Username" labelWidth={8} inputWidth={30} type="text" id="username" name="username" />
<FormField
label="Username"
labelWidth={8}
inputWidth={30}
type="text"
id="username"
name="username"
defaultValue={username}
/>
<button type="submit" className="btn btn-primary">
Run
</button>
......@@ -117,6 +130,7 @@ export class LdapPage extends PureComponent<Props, State> {
const mapStateToProps = (state: StoreState) => ({
navModel: getNavModel(state.navIndex, 'ldap'),
username: state.location.routeParams.user,
ldapConnectionInfo: state.ldap.connectionInfo,
ldapUser: state.ldap.user,
ldapSyncInfo: state.ldap.syncInfo,
......
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import { NavModel } from '@grafana/data';
import { Alert } from '@grafana/ui';
import Page from 'app/core/components/Page/Page';
import { getNavModel } from 'app/core/selectors/navModel';
import {
AppNotificationSeverity,
LdapError,
LdapUser,
StoreState,
User,
UserSession,
SyncInfo,
LdapUserSyncInfo,
} from 'app/types';
import {
clearUserError,
loadLdapUserInfo,
revokeSession,
revokeAllSessions,
loadLdapSyncStatus,
syncUser,
} from '../state/actions';
import { LdapUserInfo } from './LdapUserInfo';
import { getRouteParamsId } from 'app/core/selectors/location';
import { UserSessions } from '../UserSessions';
import { UserInfo } from '../UserInfo';
import { UserSyncInfo } from '../UserSyncInfo';
interface Props {
navModel: NavModel;
userId: number;
user: User;
sessions: UserSession[];
ldapUser: LdapUser;
userError?: LdapError;
ldapSyncInfo?: SyncInfo;
loadLdapUserInfo: typeof loadLdapUserInfo;
clearUserError: typeof clearUserError;
loadLdapSyncStatus: typeof loadLdapSyncStatus;
syncUser: typeof syncUser;
revokeSession: typeof revokeSession;
revokeAllSessions: typeof revokeAllSessions;
}
interface State {
isLoading: boolean;
}
export class LdapUserPage extends PureComponent<Props, State> {
state = {
isLoading: true,
};
async componentDidMount() {
const { userId, loadLdapUserInfo, loadLdapSyncStatus } = this.props;
try {
await loadLdapUserInfo(userId);
await loadLdapSyncStatus();
} finally {
this.setState({ isLoading: false });
}
}
onClearUserError = () => {
this.props.clearUserError();
};
onSyncUser = () => {
const { syncUser, user } = this.props;
if (syncUser && user) {
syncUser(user.id);
}
};
onSessionRevoke = (tokenId: number) => {
const { userId, revokeSession } = this.props;
revokeSession(tokenId, userId);
};
onAllSessionsRevoke = () => {
const { userId, revokeAllSessions } = this.props;
revokeAllSessions(userId);
};
isUserError = (): boolean => {
return !!(this.props.userError && this.props.userError.title);
};
render() {
const { user, ldapUser, userError, navModel, sessions, ldapSyncInfo } = this.props;
const { isLoading } = this.state;
const userSyncInfo: LdapUserSyncInfo = {};
if (ldapSyncInfo) {
userSyncInfo.nextSync = ldapSyncInfo.nextSync;
}
if (user) {
userSyncInfo.prevSync = (user as any).updatedAt;
}
return (
<Page navModel={navModel}>
<Page.Contents isLoading={isLoading}>
<div className="grafana-info-box">
This user is synced via LDAP – All changes must be done in LDAP or mappings.
</div>
{userError && userError.title && (
<div className="gf-form-group">
<Alert
title={userError.title}
severity={AppNotificationSeverity.Error}
children={userError.body}
onRemove={this.onClearUserError}
/>
</div>
)}
{userSyncInfo && (
<UserSyncInfo syncInfo={userSyncInfo} onSync={this.onSyncUser} disableSync={this.isUserError()} />
)}
{ldapUser && <LdapUserInfo ldapUser={ldapUser} />}
{!ldapUser && user && <UserInfo user={user} />}
{sessions && (
<UserSessions
sessions={sessions}
onSessionRevoke={this.onSessionRevoke}
onAllSessionsRevoke={this.onAllSessionsRevoke}
/>
)}
</Page.Contents>
</Page>
);
}
}
const mapStateToProps = (state: StoreState) => ({
userId: getRouteParamsId(state.location),
navModel: getNavModel(state.navIndex, 'global-users'),
user: state.ldapUser.user,
ldapUser: state.ldapUser.ldapUser,
userError: state.ldapUser.userError,
ldapSyncInfo: state.ldapUser.ldapSyncInfo,
sessions: state.ldapUser.sessions,
});
const mapDispatchToProps = {
loadLdapUserInfo,
loadLdapSyncStatus,
syncUser,
revokeSession,
revokeAllSessions,
clearUserError,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(LdapUserPage));
import { updateLocation } from 'app/core/actions';
import config from 'app/core/config';
import { ThunkResult } from 'app/types';
import {
getLdapState,
getLdapSyncStatus,
getUser,
getUserInfo,
getUserSessions,
revokeAllUserSessions,
revokeUserSession,
syncLdapUser,
} from './apis';
import { dateTime } from '@grafana/data';
import { getBackendSrv } from '@grafana/runtime';
import { ThunkResult, LdapUser, UserSession, UserDTO } from 'app/types';
import {
clearUserErrorAction,
clearUserMappingInfoAction,
userAdminPageLoadedAction,
userProfileLoadedAction,
userOrgsLoadedAction,
userSessionsLoadedAction,
userAdminPageFailedAction,
ldapConnectionInfoLoadedAction,
ldapFailedAction,
ldapSyncStatusLoadedAction,
userLoadedAction,
userMappingInfoFailedAction,
userMappingInfoLoadedAction,
userSessionsLoadedAction,
userSyncFailedAction,
userMappingInfoFailedAction,
clearUserMappingInfoAction,
clearUserErrorAction,
ldapFailedAction,
} from './reducers';
// Actions
// UserAdminPage
export function loadLdapState(): ThunkResult<void> {
export function loadAdminUserPage(userId: number): ThunkResult<void> {
return async dispatch => {
try {
const connectionInfo = await getLdapState();
dispatch(ldapConnectionInfoLoadedAction(connectionInfo));
dispatch(userAdminPageLoadedAction(false));
await dispatch(loadUserProfile(userId));
await dispatch(loadUserOrgs(userId));
await dispatch(loadUserSessions(userId));
if (config.ldapEnabled && config.buildInfo.isEnterprise) {
await dispatch(loadLdapSyncStatus());
}
dispatch(userAdminPageLoadedAction(true));
} catch (error) {
console.log(error);
error.isHandled = true;
const ldapError = {
const userError = {
title: error.data.message,
body: error.data.error,
};
dispatch(ldapFailedAction(ldapError));
dispatch(userAdminPageFailedAction(userError));
}
};
}
export function loadLdapSyncStatus(): ThunkResult<void> {
export function loadUserProfile(userId: number): ThunkResult<void> {
return async dispatch => {
if (config.buildInfo.isEnterprise) {
// Available only in enterprise
const syncStatus = await getLdapSyncStatus();
dispatch(ldapSyncStatusLoadedAction(syncStatus));
}
const user = await getBackendSrv().get(`/api/users/${userId}`);
dispatch(userProfileLoadedAction(user));
};
}
export function loadUserMapping(username: string): ThunkResult<void> {
export function updateUser(user: UserDTO): ThunkResult<void> {
return async dispatch => {
try {
const userInfo = await getUserInfo(username);
dispatch(userMappingInfoLoadedAction(userInfo));
} catch (error) {
error.isHandled = true;
const userError = {
title: error.data.message,
body: error.data.error,
await getBackendSrv().put(`/api/users/${user.id}`, user);
dispatch(loadAdminUserPage(user.id));
};
}
export function setUserPassword(userId: number, password: string): ThunkResult<void> {
return async dispatch => {
const payload = { password };
await getBackendSrv().put(`/api/admin/users/${userId}/password`, payload);
dispatch(loadAdminUserPage(userId));
};
}
export function disableUser(userId: number): ThunkResult<void> {
return async dispatch => {
await getBackendSrv().post(`/api/admin/users/${userId}/disable`);
// dispatch(loadAdminUserPage(userId));
dispatch(updateLocation({ path: '/admin/users' }));
};
}
export function enableUser(userId: number): ThunkResult<void> {
return async dispatch => {
await getBackendSrv().post(`/api/admin/users/${userId}/enable`);
dispatch(loadAdminUserPage(userId));
};
}
export function deleteUser(userId: number): ThunkResult<void> {
return async dispatch => {
await getBackendSrv().delete(`/api/admin/users/${userId}`);
dispatch(updateLocation({ path: '/admin/users' }));
};
}
export function updateUserPermissions(userId: number, isGrafanaAdmin: boolean): ThunkResult<void> {
return async dispatch => {
const payload = { isGrafanaAdmin };
await getBackendSrv().put(`/api/admin/users/${userId}/permissions`, payload);
dispatch(loadAdminUserPage(userId));
};
}
export function loadUserOrgs(userId: number): ThunkResult<void> {
return async dispatch => {
const orgs = await getBackendSrv().get(`/api/users/${userId}/orgs`);
dispatch(userOrgsLoadedAction(orgs));
};
}
export function addOrgUser(user: UserDTO, orgId: number, role: string): ThunkResult<void> {
return async dispatch => {
const payload = {
loginOrEmail: user.login,
role: role,
};
await getBackendSrv().post(`/api/orgs/${orgId}/users/`, payload);
dispatch(loadAdminUserPage(user.id));
};
}
export function updateOrgUserRole(userId: number, orgId: number, role: string): ThunkResult<void> {
return async dispatch => {
const payload = { role };
await getBackendSrv().patch(`/api/orgs/${orgId}/users/${userId}`, payload);
dispatch(loadAdminUserPage(userId));
};
}
export function deleteOrgUser(userId: number, orgId: number): ThunkResult<void> {
return async dispatch => {
await getBackendSrv().delete(`/api/orgs/${orgId}/users/${userId}`);
dispatch(loadAdminUserPage(userId));
};
}
export function loadUserSessions(userId: number): ThunkResult<void> {
return async dispatch => {
const tokens = await getBackendSrv().get(`/api/admin/users/${userId}/auth-tokens`);
tokens.reverse();
const sessions = tokens.map((session: UserSession) => {
return {
id: session.id,
isActive: session.isActive,
seenAt: dateTime(session.seenAt).fromNow(),
createdAt: dateTime(session.createdAt).format('MMMM DD, YYYY'),
clientIp: session.clientIp,
browser: session.browser,
browserVersion: session.browserVersion,
os: session.os,
osVersion: session.osVersion,
device: session.device,
};
dispatch(clearUserMappingInfoAction());
dispatch(userMappingInfoFailedAction(userError));
}
});
dispatch(userSessionsLoadedAction(sessions));
};
}
export function clearUserError(): ThunkResult<void> {
return dispatch => {
dispatch(clearUserErrorAction());
export function revokeSession(tokenId: number, userId: number): ThunkResult<void> {
return async dispatch => {
const payload = { authTokenId: tokenId };
await getBackendSrv().post(`/api/admin/users/${userId}/revoke-auth-token`, payload);
dispatch(loadUserSessions(userId));
};
}
export function clearUserMappingInfo(): ThunkResult<void> {
return dispatch => {
dispatch(clearUserErrorAction());
dispatch(clearUserMappingInfoAction());
export function revokeAllSessions(userId: number): ThunkResult<void> {
return async dispatch => {
await getBackendSrv().post(`/api/admin/users/${userId}/logout`);
dispatch(loadUserSessions(userId));
};
}
export function syncUser(userId: number): ThunkResult<void> {
// LDAP user actions
export function loadLdapSyncStatus(): ThunkResult<void> {
return async dispatch => {
try {
await syncLdapUser(userId);
dispatch(loadLdapUserInfo(userId));
dispatch(loadLdapSyncStatus());
} catch (error) {
dispatch(userSyncFailedAction());
// Available only in enterprise
if (config.buildInfo.isEnterprise) {
const syncStatus = await getBackendSrv().get(`/api/admin/ldap-sync-status`);
dispatch(ldapSyncStatusLoadedAction(syncStatus));
}
};
}
export function loadLdapUserInfo(userId: number): ThunkResult<void> {
export function syncLdapUser(userId: number): ThunkResult<void> {
return async dispatch => {
await getBackendSrv().post(`/api/admin/ldap/sync/${userId}`);
dispatch(loadAdminUserPage(userId));
};
}
// LDAP debug page
export function loadLdapState(): ThunkResult<void> {
return async dispatch => {
try {
const user = await getUser(userId);
dispatch(userLoadedAction(user));
dispatch(loadUserSessions(userId));
dispatch(loadUserMapping(user.login));
const connectionInfo = await getBackendSrv().get(`/api/admin/ldap/status`);
dispatch(ldapConnectionInfoLoadedAction(connectionInfo));
} catch (error) {
error.isHandled = true;
const userError = {
const ldapError = {
title: error.data.message,
body: error.data.error,
};
dispatch(userMappingInfoFailedAction(userError));
dispatch(ldapFailedAction(ldapError));
}
};
}
export function loadUserSessions(userId: number): ThunkResult<void> {
export function loadUserMapping(username: string): ThunkResult<void> {
return async dispatch => {
const sessions = await getUserSessions(userId);
dispatch(userSessionsLoadedAction(sessions));
try {
const response = await getBackendSrv().get(`/api/admin/ldap/${username}`);
const { name, surname, email, login, isGrafanaAdmin, isDisabled, roles, teams } = response;
const userInfo: LdapUser = {
info: { name, surname, email, login },
permissions: { isGrafanaAdmin, isDisabled },
roles,
teams,
};
dispatch(userMappingInfoLoadedAction(userInfo));
} catch (error) {
error.isHandled = true;
const userError = {
title: error.data.message,
body: error.data.error,
};
dispatch(clearUserMappingInfoAction());
dispatch(userMappingInfoFailedAction(userError));
}
};
}
export function revokeSession(tokenId: number, userId: number): ThunkResult<void> {
return async dispatch => {
await revokeUserSession(tokenId, userId);
dispatch(loadUserSessions(userId));
export function clearUserError(): ThunkResult<void> {
return dispatch => {
dispatch(clearUserErrorAction());
};
}
export function revokeAllSessions(userId: number): ThunkResult<void> {
return async dispatch => {
await revokeAllUserSessions(userId);
dispatch(loadUserSessions(userId));
export function clearUserMappingInfo(): ThunkResult<void> {
return dispatch => {
dispatch(clearUserErrorAction());
dispatch(clearUserMappingInfoAction());
};
}
import { getBackendSrv } from '@grafana/runtime';
import { dateTime } from '@grafana/data';
import { LdapUser, LdapConnectionInfo, UserSession, SyncInfo, User } from 'app/types';
export interface ServerStat {
name: string;
......@@ -33,60 +31,3 @@ export const getServerStats = async (): Promise<ServerStat[]> => {
throw error;
}
};
export const getLdapState = async (): Promise<LdapConnectionInfo> => {
return await getBackendSrv().get(`/api/admin/ldap/status`);
};
export const getLdapSyncStatus = async (): Promise<SyncInfo> => {
return await getBackendSrv().get(`/api/admin/ldap-sync-status`);
};
export const syncLdapUser = async (userId: number) => {
return await getBackendSrv().post(`/api/admin/ldap/sync/${userId}`);
};
export const getUserInfo = async (username: string): Promise<LdapUser> => {
const response = await getBackendSrv().get(`/api/admin/ldap/${username}`);
const { name, surname, email, login, isGrafanaAdmin, isDisabled, roles, teams } = response;
return {
info: { name, surname, email, login },
permissions: { isGrafanaAdmin, isDisabled },
roles,
teams,
};
};
export const getUser = async (id: number): Promise<User> => {
return await getBackendSrv().get('/api/users/' + id);
};
export const getUserSessions = async (id: number) => {
const sessions = await getBackendSrv().get('/api/admin/users/' + id + '/auth-tokens');
sessions.reverse();
return sessions.map((session: UserSession) => {
return {
id: session.id,
isActive: session.isActive,
seenAt: dateTime(session.seenAt).fromNow(),
createdAt: dateTime(session.createdAt).format('MMMM DD, YYYY'),
clientIp: session.clientIp,
browser: session.browser,
browserVersion: session.browserVersion,
os: session.os,
osVersion: session.osVersion,
device: session.device,
};
});
};
export const revokeUserSession = async (tokenId: number, userId: number) => {
return await getBackendSrv().post(`/api/admin/users/${userId}/revoke-auth-token`, {
authTokenId: tokenId,
});
};
export const revokeAllUserSessions = async (userId: number) => {
return await getBackendSrv().post(`/api/admin/users/${userId}/logout`);
};
import { reducerTester } from 'test/core/redux/reducerTester';
import {
clearUserErrorAction,
clearUserMappingInfoAction,
ldapConnectionInfoLoadedAction,
ldapFailedAction,
ldapReducer,
ldapSyncStatusLoadedAction,
ldapUserReducer,
userLoadedAction,
userAdminReducer,
userProfileLoadedAction,
userMappingInfoFailedAction,
userMappingInfoLoadedAction,
userSessionsLoadedAction,
} from './reducers';
import { LdapState, LdapUser, LdapUserState, User } from 'app/types';
import { LdapState, LdapUser, UserAdminState, UserDTO } from 'app/types';
const makeInitialLdapState = (): LdapState => ({
connectionInfo: [],
......@@ -23,11 +22,11 @@ const makeInitialLdapState = (): LdapState => ({
userError: null,
});
const makeInitialLdapUserState = (): LdapUserState => ({
const makeInitialUserAdminState = (): UserAdminState => ({
user: null,
ldapUser: null,
ldapSyncInfo: null,
sessions: [],
orgs: [],
isLoading: true,
});
const getTestUserMapping = (): LdapUser => ({
......@@ -45,13 +44,14 @@ const getTestUserMapping = (): LdapUser => ({
teams: [],
});
const getTestUser = (): User => ({
const getTestUser = (): UserDTO => ({
id: 1,
email: 'user@localhost',
login: 'user',
name: 'User',
avatarUrl: '',
label: '',
isGrafanaAdmin: false,
isDisabled: false,
});
describe('LDAP page reducer', () => {
......@@ -203,32 +203,28 @@ describe('LDAP page reducer', () => {
});
});
describe('Edit LDAP user page reducer', () => {
describe('Edit Admin user page reducer', () => {
describe('When user loaded', () => {
it('should set user and clear user error', () => {
const initialState = {
...makeInitialLdapUserState(),
userError: {
title: 'User not found',
body: 'Cannot find user',
},
...makeInitialUserAdminState(),
};
reducerTester<LdapUserState>()
.givenReducer(ldapUserReducer, initialState)
.whenActionIsDispatched(userLoadedAction(getTestUser()))
reducerTester<UserAdminState>()
.givenReducer(userAdminReducer, initialState)
.whenActionIsDispatched(userProfileLoadedAction(getTestUser()))
.thenStateShouldEqual({
...makeInitialLdapUserState(),
...makeInitialUserAdminState(),
user: getTestUser(),
userError: null,
});
});
});
describe('when userSessionsLoadedAction is dispatched', () => {
it('then state should be correct', () => {
reducerTester<LdapUserState>()
.givenReducer(ldapUserReducer, { ...makeInitialLdapUserState() })
reducerTester<UserAdminState>()
.givenReducer(userAdminReducer, { ...makeInitialUserAdminState() })
.whenActionIsDispatched(
userSessionsLoadedAction([
{
......@@ -246,7 +242,7 @@ describe('Edit LDAP user page reducer', () => {
])
)
.thenStateShouldEqual({
...makeInitialLdapUserState(),
...makeInitialUserAdminState(),
sessions: [
{
browser: 'Chrome',
......@@ -264,80 +260,4 @@ describe('Edit LDAP user page reducer', () => {
});
});
});
describe('when userMappingInfoLoadedAction is dispatched', () => {
it('then state should be correct', () => {
reducerTester<LdapUserState>()
.givenReducer(ldapUserReducer, {
...makeInitialLdapUserState(),
})
.whenActionIsDispatched(userMappingInfoLoadedAction(getTestUserMapping()))
.thenStateShouldEqual({
...makeInitialLdapUserState(),
ldapUser: getTestUserMapping(),
});
});
});
describe('when userMappingInfoFailedAction is dispatched', () => {
it('then state should be correct', () => {
reducerTester<LdapUserState>()
.givenReducer(ldapUserReducer, { ...makeInitialLdapUserState() })
.whenActionIsDispatched(
userMappingInfoFailedAction({
title: 'User not found',
body: 'Cannot find user',
})
)
.thenStateShouldEqual({
...makeInitialLdapUserState(),
userError: {
title: 'User not found',
body: 'Cannot find user',
},
});
});
});
describe('when clearUserErrorAction is dispatched', () => {
it('then state should be correct', () => {
reducerTester<LdapUserState>()
.givenReducer(ldapUserReducer, {
...makeInitialLdapUserState(),
userError: {
title: 'User not found',
body: 'Cannot find user',
},
})
.whenActionIsDispatched(clearUserErrorAction())
.thenStateShouldEqual({
...makeInitialLdapUserState(),
userError: null,
});
});
});
describe('when ldapSyncStatusLoadedAction is dispatched', () => {
it('then state should be correct', () => {
reducerTester<LdapUserState>()
.givenReducer(ldapUserReducer, {
...makeInitialLdapUserState(),
})
.whenActionIsDispatched(
ldapSyncStatusLoadedAction({
enabled: true,
schedule: '0 0 * * * *',
nextSync: '2019-01-01T12:00:00Z',
})
)
.thenStateShouldEqual({
...makeInitialLdapUserState(),
ldapSyncInfo: {
enabled: true,
schedule: '0 0 * * * *',
nextSync: '2019-01-01T12:00:00Z',
},
});
});
});
});
......@@ -4,10 +4,12 @@ import {
LdapError,
LdapState,
LdapUser,
LdapUserState,
SyncInfo,
User,
UserAdminState,
UserDTO,
UserOrg,
UserSession,
UserAdminError,
} from 'app/types';
const initialLdapState: LdapState = {
......@@ -18,13 +20,6 @@ const initialLdapState: LdapState = {
userError: null,
};
const initialLdapUserState: LdapUserState = {
user: null,
ldapUser: null,
ldapSyncInfo: null,
sessions: [],
};
const ldapSlice = createSlice({
name: 'ldap',
initialState: initialLdapState,
......@@ -75,59 +70,54 @@ export const {
export const ldapReducer = ldapSlice.reducer;
const ldapUserSlice = createSlice({
name: 'ldapUser',
initialState: initialLdapUserState,
// UserAdminPage
const initialUserAdminState: UserAdminState = {
user: null,
sessions: [],
orgs: [],
isLoading: true,
};
export const userAdminSlice = createSlice({
name: 'userAdmin',
initialState: initialUserAdminState,
reducers: {
userLoadedAction: (state, action: PayloadAction<User>): LdapUserState => ({
userProfileLoadedAction: (state, action: PayloadAction<UserDTO>): UserAdminState => ({
...state,
user: action.payload,
userError: null,
}),
userSessionsLoadedAction: (state, action: PayloadAction<UserSession[]>): LdapUserState => ({
userOrgsLoadedAction: (state, action: PayloadAction<UserOrg[]>): UserAdminState => ({
...state,
orgs: action.payload,
}),
userSessionsLoadedAction: (state, action: PayloadAction<UserSession[]>): UserAdminState => ({
...state,
sessions: action.payload,
}),
userSyncFailedAction: (state, action: PayloadAction<undefined>): LdapUserState => state,
userAdminPageLoadedAction: (state, action: PayloadAction<boolean>): UserAdminState => ({
...state,
isLoading: !action.payload,
}),
userAdminPageFailedAction: (state, action: PayloadAction<UserAdminError>): UserAdminState => ({
...state,
error: action.payload,
isLoading: false,
}),
},
extraReducers: builder =>
builder
.addCase(
userMappingInfoLoadedAction,
(state, action): LdapUserState => ({
...state,
ldapUser: action.payload,
})
)
.addCase(
userMappingInfoFailedAction,
(state, action): LdapUserState => ({
...state,
ldapUser: null,
userError: action.payload,
})
)
.addCase(
clearUserErrorAction,
(state, action): LdapUserState => ({
...state,
userError: null,
})
)
.addCase(
ldapSyncStatusLoadedAction,
(state, action): LdapUserState => ({
...state,
ldapSyncInfo: action.payload,
})
),
});
export const { userLoadedAction, userSessionsLoadedAction, userSyncFailedAction } = ldapUserSlice.actions;
export const {
userProfileLoadedAction,
userOrgsLoadedAction,
userSessionsLoadedAction,
userAdminPageLoadedAction,
userAdminPageFailedAction,
} = userAdminSlice.actions;
export const ldapUserReducer = ldapUserSlice.reducer;
export const userAdminReducer = userAdminSlice.reducer;
export default {
ldap: ldapReducer,
ldapUser: ldapUserReducer,
userAdmin: userAdminReducer,
};
......@@ -6,6 +6,7 @@ import CreateFolderCtrl from 'app/features/folders/CreateFolderCtrl';
import FolderDashboardsCtrl from 'app/features/folders/FolderDashboardsCtrl';
import DashboardImportCtrl from 'app/features/manage-dashboards/DashboardImportCtrl';
import LdapPage from 'app/features/admin/ldap/LdapPage';
import UserAdminPage from 'app/features/admin/UserAdminPage';
import config from 'app/core/config';
import { ILocationProvider, route } from 'angular';
// Types
......@@ -298,9 +299,15 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
templateUrl: 'public/app/features/admin/partials/new_user.html',
controller: 'AdminEditUserCtrl',
})
// .when('/admin/users/edit/:id', {
// templateUrl: 'public/app/features/admin/partials/edit_user.html',
// controller: 'AdminEditUserCtrl',
// })
.when('/admin/users/edit/:id', {
templateUrl: 'public/app/features/admin/partials/edit_user.html',
controller: 'AdminEditUserCtrl',
template: '<react-container />',
resolve: {
component: () => UserAdminPage,
},
})
.when('/admin/orgs', {
templateUrl: 'public/app/features/admin/partials/orgs.html',
......
import { User, UserSession } from 'app/types';
interface LdapMapping {
cfgAttrValue: string;
ldapValue: string;
......@@ -85,11 +83,3 @@ export interface LdapState {
userError?: LdapError;
ldapError?: LdapError;
}
export interface LdapUserState {
user?: User;
ldapUser?: LdapUser;
ldapSyncInfo?: SyncInfo;
sessions?: UserSession[];
userError?: LdapError;
}
......@@ -9,12 +9,12 @@ import { FolderState } from './folders';
import { DashboardState } from './dashboard';
import { DataSourcesState } from './datasources';
import { ExploreState } from './explore';
import { UsersState, UserState } from './user';
import { UsersState, UserState, UserAdminState } from './user';
import { OrganizationState } from './organization';
import { AppNotificationsState } from './appNotifications';
import { PluginsState } from './plugins';
import { ApplicationState } from './application';
import { LdapState, LdapUserState } from './ldap';
import { LdapState } from './ldap';
import { PanelEditorState } from '../features/dashboard/panel_editor/state/reducers';
import { ApiKeysState } from './apiKeys';
......@@ -36,8 +36,8 @@ export interface StoreState {
plugins: PluginsState;
application: ApplicationState;
ldap: LdapState;
ldapUser: LdapUserState;
apiKeys: ApiKeysState;
userAdmin: UserAdminState;
}
/*
......
......@@ -22,6 +22,21 @@ export interface User {
orgId?: number;
}
export interface UserDTO {
id: number;
login: string;
email: string;
name: string;
isGrafanaAdmin: boolean;
isDisabled: boolean;
isExternal?: boolean;
updatedAt?: string;
authLabels?: string[];
theme?: string;
avatarUrl?: string;
orgId?: number;
}
export interface Invitee {
code: string;
createdOn: string;
......@@ -67,3 +82,22 @@ export interface UserSession {
osVersion: string;
device: string;
}
export interface UserOrg {
name: string;
orgId: number;
role: string;
}
export interface UserAdminState {
user: UserDTO;
sessions: UserSession[];
orgs: UserOrg[];
isLoading: boolean;
error?: UserAdminError;
}
export interface UserAdminError {
title: string;
body: string;
}
......@@ -94,6 +94,7 @@
.page-body {
padding-top: $spacer * 2;
padding-bottom: $spacer * 4;
}
.page-heading {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment