mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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>
This commit is contained in:
parent
108039af33
commit
8505d90768
@ -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)
|
||||
}
|
||||
|
||||
|
@ -1,10 +1,12 @@
|
||||
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 {
|
||||
|
71
public/app/core/components/Select/OrgPicker.tsx
Normal file
71
public/app/core/components/Select/OrgPicker.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,22 +0,0 @@
|
||||
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} />
|
||||
</>
|
||||
);
|
||||
};
|
200
public/app/features/admin/UserAdminPage.tsx
Normal file
200
public/app/features/admin/UserAdminPage.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
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));
|
@ -1,36 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
82
public/app/features/admin/UserLdapSyncInfo.tsx
Normal file
82
public/app/features/admin/UserLdapSyncInfo.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
271
public/app/features/admin/UserOrgs.tsx
Normal file
271
public/app/features/admin/UserOrgs.tsx
Normal file
@ -0,0 +1,271 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
107
public/app/features/admin/UserPermissions.tsx
Normal file
107
public/app/features/admin/UserPermissions.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
329
public/app/features/admin/UserProfile.tsx
Normal file
329
public/app/features/admin/UserProfile.tsx
Normal file
@ -0,0 +1,329 @@
|
||||
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>
|
||||
);
|
||||
};
|
@ -1,4 +1,6 @@
|
||||
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,
|
||||
|
@ -1,161 +0,0 @@
|
||||
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));
|
@ -1,34 +1,196 @@
|
||||
import { updateLocation } from 'app/core/actions';
|
||||
import config from 'app/core/config';
|
||||
import { ThunkResult } from 'app/types';
|
||||
import { dateTime } from '@grafana/data';
|
||||
import { getBackendSrv } from '@grafana/runtime';
|
||||
import { ThunkResult, LdapUser, UserSession, UserDTO } from 'app/types';
|
||||
|
||||
import {
|
||||
getLdapState,
|
||||
getLdapSyncStatus,
|
||||
getUser,
|
||||
getUserInfo,
|
||||
getUserSessions,
|
||||
revokeAllUserSessions,
|
||||
revokeUserSession,
|
||||
syncLdapUser,
|
||||
} from './apis';
|
||||
import {
|
||||
clearUserErrorAction,
|
||||
clearUserMappingInfoAction,
|
||||
ldapConnectionInfoLoadedAction,
|
||||
ldapFailedAction,
|
||||
ldapSyncStatusLoadedAction,
|
||||
userLoadedAction,
|
||||
userMappingInfoFailedAction,
|
||||
userMappingInfoLoadedAction,
|
||||
userAdminPageLoadedAction,
|
||||
userProfileLoadedAction,
|
||||
userOrgsLoadedAction,
|
||||
userSessionsLoadedAction,
|
||||
userSyncFailedAction,
|
||||
userAdminPageFailedAction,
|
||||
ldapConnectionInfoLoadedAction,
|
||||
ldapSyncStatusLoadedAction,
|
||||
userMappingInfoLoadedAction,
|
||||
userMappingInfoFailedAction,
|
||||
clearUserMappingInfoAction,
|
||||
clearUserErrorAction,
|
||||
ldapFailedAction,
|
||||
} from './reducers';
|
||||
|
||||
// Actions
|
||||
// UserAdminPage
|
||||
|
||||
export function loadAdminUserPage(userId: number): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
try {
|
||||
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 userError = {
|
||||
title: error.data.message,
|
||||
body: error.data.error,
|
||||
};
|
||||
dispatch(userAdminPageFailedAction(userError));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function loadUserProfile(userId: number): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
const user = await getBackendSrv().get(`/api/users/${userId}`);
|
||||
dispatch(userProfileLoadedAction(user));
|
||||
};
|
||||
}
|
||||
|
||||
export function updateUser(user: UserDTO): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
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(userSessionsLoadedAction(sessions));
|
||||
};
|
||||
}
|
||||
|
||||
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 revokeAllSessions(userId: number): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
await getBackendSrv().post(`/api/admin/users/${userId}/logout`);
|
||||
dispatch(loadUserSessions(userId));
|
||||
};
|
||||
}
|
||||
|
||||
// LDAP user actions
|
||||
|
||||
export function loadLdapSyncStatus(): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
// Available only in enterprise
|
||||
if (config.buildInfo.isEnterprise) {
|
||||
const syncStatus = await getBackendSrv().get(`/api/admin/ldap-sync-status`);
|
||||
dispatch(ldapSyncStatusLoadedAction(syncStatus));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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 connectionInfo = await getLdapState();
|
||||
const connectionInfo = await getBackendSrv().get(`/api/admin/ldap/status`);
|
||||
dispatch(ldapConnectionInfoLoadedAction(connectionInfo));
|
||||
} catch (error) {
|
||||
error.isHandled = true;
|
||||
@ -41,20 +203,17 @@ export function loadLdapState(): ThunkResult<void> {
|
||||
};
|
||||
}
|
||||
|
||||
export function loadLdapSyncStatus(): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
if (config.buildInfo.isEnterprise) {
|
||||
// Available only in enterprise
|
||||
const syncStatus = await getLdapSyncStatus();
|
||||
dispatch(ldapSyncStatusLoadedAction(syncStatus));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function loadUserMapping(username: string): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
try {
|
||||
const userInfo = await getUserInfo(username);
|
||||
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;
|
||||
@ -80,54 +239,3 @@ export function clearUserMappingInfo(): ThunkResult<void> {
|
||||
dispatch(clearUserMappingInfoAction());
|
||||
};
|
||||
}
|
||||
|
||||
export function syncUser(userId: number): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
try {
|
||||
await syncLdapUser(userId);
|
||||
dispatch(loadLdapUserInfo(userId));
|
||||
dispatch(loadLdapSyncStatus());
|
||||
} catch (error) {
|
||||
dispatch(userSyncFailedAction());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function loadLdapUserInfo(userId: number): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
try {
|
||||
const user = await getUser(userId);
|
||||
dispatch(userLoadedAction(user));
|
||||
dispatch(loadUserSessions(userId));
|
||||
dispatch(loadUserMapping(user.login));
|
||||
} catch (error) {
|
||||
error.isHandled = true;
|
||||
const userError = {
|
||||
title: error.data.message,
|
||||
body: error.data.error,
|
||||
};
|
||||
dispatch(userMappingInfoFailedAction(userError));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function loadUserSessions(userId: number): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
const sessions = await getUserSessions(userId);
|
||||
dispatch(userSessionsLoadedAction(sessions));
|
||||
};
|
||||
}
|
||||
|
||||
export function revokeSession(tokenId: number, userId: number): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
await revokeUserSession(tokenId, userId);
|
||||
dispatch(loadUserSessions(userId));
|
||||
};
|
||||
}
|
||||
|
||||
export function revokeAllSessions(userId: number): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
await revokeAllUserSessions(userId);
|
||||
dispatch(loadUserSessions(userId));
|
||||
};
|
||||
}
|
||||
|
@ -1,6 +1,4 @@
|
||||
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`);
|
||||
};
|
||||
|
@ -1,18 +1,17 @@
|
||||
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',
|
||||
|
@ -1,5 +1,3 @@
|
||||
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 {
|
||||
|
Loading…
Reference in New Issue
Block a user