Ldap: Add LDAP debug page (#18759)

* Add items for navmodel and basic page

* add reducer and actions

* adding user mapping table component

* adding components for ldap tables

* add alert box on error

* close error alert box

* LDAP status page: connect APIs WIP

* LDAP debug: fetch connection status from API

* LDAP debug: fetch user info from API

* LDAP debug: improve connection error view

* LDAP debug: connection error tweaks

* LDAP debug: fix role mapping view

* LDAP debug: role mapping view tweaks

* LDAP debug: add bulk-sync button stub

* LDAP debug: minor refactor

* LDAP debug: show user teams

* LDAP debug: user info refactor

* LDAP debug: initial user page

* LDAP debug: minor refactor, remove unused angular wrapper

* LDAP debug: add sessions to user page

* LDAP debug: tweak user page

* LDAP debug: tweak view for disabled user

* LDAP debug: get sync info from API

* LDAP debug: user sync info

* LDAP debug: sync user button

* LDAP debug: clear error on page load

* LDAP debug: add user last sync info

* LDAP debug: actions refactor

* LDAP debug: roles and teams style tweaks

* Pass showAttributeMapping to LdapUserTeams

* LDAP debug: hide bulk sync button

* LDAP debug: refactor sessions component

* LDAP debug: fix loading user sessions

* LDAP debug: hide sync user button

* LDAP debug: fix fetching unavailable /ldap-sync-status endpoint

* LDAP debug: revert accidentally added fix

* LDAP debug: show error when LDAP is not enabled

* LDAP debug: refactor, move ldap components into ldap/ folder

* LDAP debug: styles refactoring

* LDAP debug: ldap reducer tests

* LDAP debug: ldap user reducer tests

* LDAP debug: fix connection error placement

* Text update

* LdapUser: Minor UI changes moving things around

* AlertBox: Removed icon-on-top as everywhere else it is centered, want to have it be consistent
This commit is contained in:
Peter Holmberg 2019-09-16 17:56:01 +02:00 committed by Alexander Zobnin
parent ba11958a52
commit 3c61b563c3
28 changed files with 1563 additions and 9 deletions

View File

@ -20,11 +20,11 @@ export interface BackendSrv {
delete(url: string): Promise<any>;
post(url: string, data: any): Promise<any>;
post(url: string, data?: any): Promise<any>;
patch(url: string, data: any): Promise<any>;
patch(url: string, data?: any): Promise<any>;
put(url: string, data: any): Promise<any>;
put(url: string, data?: any): Promise<any>;
// If there is an error, set: err.isHandled = true
// otherwise the backend will show a message for you

View File

@ -318,6 +318,7 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er
{Text: "Orgs", Id: "global-orgs", Url: setting.AppSubUrl + "/admin/orgs", Icon: "gicon gicon-org"},
{Text: "Settings", Id: "server-settings", Url: setting.AppSubUrl + "/admin/settings", Icon: "gicon gicon-preferences"},
{Text: "Stats", Id: "server-stats", Url: setting.AppSubUrl + "/admin/stats", Icon: "fa fa-fw fa-bar-chart"},
{Text: "LDAP", Id: "ldap", Url: setting.AppSubUrl + "/admin/ldap", Icon: "fa fa-fw fa-address-book-o"},
},
})
}

View File

@ -129,6 +129,10 @@ func (server *HTTPServer) GetLDAPStatus(c *models.ReqContext) Response {
ldap := newLDAP(ldapConfig.Servers)
if ldap == nil {
return Error(http.StatusInternalServerError, "Failed to find the LDAP server", nil)
}
statuses, err := ldap.Ping()
if err != nil {

View File

@ -1,4 +1,5 @@
import React, { FunctionComponent, ReactNode } from 'react';
import classNames from 'classnames';
import { AppNotificationSeverity } from 'app/types';
interface Props {
@ -23,8 +24,9 @@ function getIconFromSeverity(severity: AppNotificationSeverity): string {
}
export const AlertBox: FunctionComponent<Props> = ({ title, icon, body, severity, onClose }) => {
const alertClass = classNames('alert', `alert-${severity}`);
return (
<div className={`alert alert-${severity}`}>
<div className={alertClass}>
<div className="alert-icon">
<i className={icon || getIconFromSeverity(severity)} />
</div>

View File

@ -0,0 +1,22 @@
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} />
</>
);
};

View File

@ -0,0 +1,36 @@
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>
);
};

View File

@ -0,0 +1,68 @@
import React, { PureComponent } from 'react';
import { UserSession } from 'app/types';
interface Props {
sessions: UserSession[];
onSessionRevoke: (id: number) => void;
onAllSessionsRevoke: () => void;
}
export class UserSessions extends PureComponent<Props> {
handleSessionRevoke = (id: number) => {
return () => {
this.props.onSessionRevoke(id);
};
};
handleAllSessionsRevoke = () => {
this.props.onAllSessionsRevoke();
};
render() {
const { sessions } = this.props;
return (
<>
<h3 className="page-heading">Sessions</h3>
<div className="gf-form-group">
<div className="gf-form">
<table className="filter-table form-inline">
<thead>
<tr>
<th>Last seen</th>
<th>Logged on</th>
<th>IP address</th>
<th colSpan={2}>Browser &amp; OS</th>
</tr>
</thead>
<tbody>
{sessions &&
sessions.map((session, index) => (
<tr key={`${session.id}-${index}`}>
<td>{session.isActive ? 'Now' : session.seenAt}</td>
<td>{session.createdAt}</td>
<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>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="gf-form-button-row">
{sessions.length > 0 && (
<button className="btn btn-danger" onClick={this.handleAllSessionsRevoke}>
Logout user from all devices
</button>
)}
</div>
</div>
</>
);
}
}

View File

@ -0,0 +1,69 @@
import React, { PureComponent } from 'react';
import { dateTime } from '@grafana/data';
import { LdapUserSyncInfo } from 'app/types';
interface Props {
syncInfo: LdapUserSyncInfo;
onSync?: () => void;
}
interface State {
isSyncing: boolean;
}
const syncTimeFormat = 'dddd YYYY-MM-DD HH:mm zz';
export class UserSyncInfo extends PureComponent<Props, State> {
state = {
isSyncing: false,
};
handleSyncClick = async () => {
const { onSync } = this.props;
this.setState({ isSyncing: true });
try {
if (onSync) {
await onSync();
}
} finally {
this.setState({ isSyncing: false });
}
};
render() {
const { syncInfo } = this.props;
const { isSyncing } = this.state;
const nextSyncTime = syncInfo.nextSync ? dateTime(syncInfo.nextSync).format(syncTimeFormat) : '';
const prevSyncSuccessful = syncInfo && syncInfo.prevSync;
const prevSyncTime = prevSyncSuccessful ? dateTime(syncInfo.prevSync).format(syncTimeFormat) : '';
return (
<>
<h3 className="page-heading">
LDAP
<button className={`btn btn-secondary pull-right`} onClick={this.handleSyncClick} hidden={true}>
<span className="btn-title">Sync user</span>
{isSyncing && <i className="fa fa-spinner fa-fw fa-spin run-icon" />}
</button>
</h3>
<div className="gf-form-group">
<div className="gf-form">
<table className="filter-table form-inline">
<tbody>
<tr>
<td>Last synchronisation</td>
<td>{prevSyncTime}</td>
{prevSyncSuccessful && <td className="pull-right">Successful</td>}
</tr>
<tr>
<td>Next scheduled synchronisation</td>
<td colSpan={2}>{nextSyncTime}</td>
</tr>
</tbody>
</table>
</div>
</div>
</>
);
}
}

View File

@ -0,0 +1,82 @@
import React, { FC } from 'react';
import { AlertBox } from 'app/core/components/AlertBox/AlertBox';
import { AppNotificationSeverity, LdapConnectionInfo, LdapServerInfo } from 'app/types';
interface Props {
ldapConnectionInfo: LdapConnectionInfo;
}
export const LdapConnectionStatus: FC<Props> = ({ ldapConnectionInfo }) => {
return (
<>
<h3 className="page-heading">LDAP Connection</h3>
<div className="gf-form-group">
<div className="gf-form">
<table className="filter-table form-inline">
<thead>
<tr>
<th>Host</th>
<th colSpan={2}>Port</th>
</tr>
</thead>
<tbody>
{ldapConnectionInfo &&
ldapConnectionInfo.map((serverInfo, index) => (
<tr key={index}>
<td>{serverInfo.host}</td>
<td>{serverInfo.port}</td>
<td>
{serverInfo.available ? (
<i className="fa fa-fw fa-check pull-right" />
) : (
<i className="fa fa-fw fa-exclamation-triangle pull-right" />
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="gf-form-group">
<LdapErrorBox ldapConnectionInfo={ldapConnectionInfo} />
</div>
</div>
</>
);
};
interface LdapConnectionErrorProps {
ldapConnectionInfo: LdapConnectionInfo;
}
export const LdapErrorBox: FC<LdapConnectionErrorProps> = ({ ldapConnectionInfo }) => {
const hasError = ldapConnectionInfo.some(info => info.error);
if (!hasError) {
return null;
}
const connectionErrors: LdapServerInfo[] = [];
ldapConnectionInfo.forEach(info => {
if (info.error) {
connectionErrors.push(info);
}
});
const errorElements = connectionErrors.map((info, index) => (
<div key={index}>
<span style={{ fontWeight: 500 }}>
{info.host}:{info.port}
<br />
</span>
<span>{info.error}</span>
{index !== connectionErrors.length - 1 && (
<>
<br />
<br />
</>
)}
</div>
));
return <AlertBox title="Connection error" severity={AppNotificationSeverity.Error} body={errorElements} />;
};

View File

@ -0,0 +1,141 @@
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import { NavModel } from '@grafana/data';
import { FormField } from '@grafana/ui';
import { getNavModel } from 'app/core/selectors/navModel';
import config from 'app/core/config';
import Page from 'app/core/components/Page/Page';
import { AlertBox } from 'app/core/components/AlertBox/AlertBox';
import { LdapConnectionStatus } from './LdapConnectionStatus';
import { LdapSyncInfo } from './LdapSyncInfo';
import { LdapUserInfo } from './LdapUserInfo';
import { AppNotificationSeverity, LdapError, LdapUser, StoreState, SyncInfo, LdapConnectionInfo } from 'app/types';
import {
loadLdapState,
loadLdapSyncStatus,
loadUserMapping,
clearUserError,
clearUserMappingInfo,
} from '../state/actions';
interface Props {
navModel: NavModel;
ldapConnectionInfo: LdapConnectionInfo;
ldapUser: LdapUser;
ldapSyncInfo: SyncInfo;
ldapError: LdapError;
userError?: LdapError;
loadLdapState: typeof loadLdapState;
loadLdapSyncStatus: typeof loadLdapSyncStatus;
loadUserMapping: typeof loadUserMapping;
clearUserError: typeof clearUserError;
clearUserMappingInfo: typeof clearUserMappingInfo;
}
interface State {
isLoading: boolean;
}
export class LdapPage extends PureComponent<Props, State> {
state = {
isLoading: true,
};
async componentDidMount() {
await this.props.clearUserMappingInfo();
await this.fetchLDAPStatus();
this.setState({ isLoading: false });
}
async fetchLDAPStatus() {
const { loadLdapState, loadLdapSyncStatus } = this.props;
return Promise.all([loadLdapState(), loadLdapSyncStatus()]);
}
async fetchUserMapping(username: string) {
const { loadUserMapping } = this.props;
return await loadUserMapping(username);
}
search = (event: any) => {
event.preventDefault();
const username = event.target.elements['username'].value;
if (username) {
this.fetchUserMapping(username);
}
};
onClearUserError = () => {
this.props.clearUserError();
};
render() {
const { ldapUser, userError, ldapError, ldapSyncInfo, ldapConnectionInfo, navModel } = this.props;
const { isLoading } = this.state;
return (
<Page navModel={navModel}>
<Page.Contents isLoading={isLoading}>
<>
{ldapError && ldapError.title && (
<div className="gf-form-group">
<AlertBox title={ldapError.title} severity={AppNotificationSeverity.Error} body={ldapError.body} />
</div>
)}
<LdapConnectionStatus ldapConnectionInfo={ldapConnectionInfo} />
{config.buildInfo.isEnterprise && ldapSyncInfo && <LdapSyncInfo ldapSyncInfo={ldapSyncInfo} />}
<h3 className="page-heading">User mapping</h3>
<div className="gf-form-group">
<form onSubmit={this.search} className="gf-form-inline">
<FormField label="User name" labelWidth={8} inputWidth={30} type="text" id="username" name="username" />
<button type="submit" className="btn btn-primary">
Test LDAP mapping
</button>
</form>
</div>
{userError && userError.title && (
<div className="gf-form-group">
<AlertBox
title={userError.title}
severity={AppNotificationSeverity.Error}
body={userError.body}
onClose={this.onClearUserError}
/>
</div>
)}
{ldapUser && <LdapUserInfo ldapUser={ldapUser} showAttributeMapping={true} />}
</>
</Page.Contents>
</Page>
);
}
}
const mapStateToProps = (state: StoreState) => ({
navModel: getNavModel(state.navIndex, 'ldap'),
ldapConnectionInfo: state.ldap.connectionInfo,
ldapUser: state.ldap.user,
ldapSyncInfo: state.ldap.syncInfo,
userError: state.ldap.userError,
ldapError: state.ldap.ldapError,
});
const mapDispatchToProps = {
loadLdapState,
loadLdapSyncStatus,
loadUserMapping,
clearUserError,
clearUserMappingInfo,
};
export default hot(module)(
connect(
mapStateToProps,
mapDispatchToProps
)(LdapPage)
);

View File

@ -0,0 +1,73 @@
import React, { PureComponent } from 'react';
import { dateTime } from '@grafana/data';
import { SyncInfo } from 'app/types';
interface Props {
ldapSyncInfo: SyncInfo;
}
interface State {
isSyncing: boolean;
}
const syncTimeFormat = 'dddd YYYY-MM-DD HH:mm zz';
export class LdapSyncInfo extends PureComponent<Props, State> {
state = {
isSyncing: false,
};
handleSyncClick = () => {
console.log('Bulk-sync now');
this.setState({ isSyncing: !this.state.isSyncing });
};
render() {
const { ldapSyncInfo } = this.props;
const { isSyncing } = this.state;
const nextSyncTime = dateTime(ldapSyncInfo.nextSync).format(syncTimeFormat);
const prevSyncSuccessful = ldapSyncInfo && ldapSyncInfo.prevSync;
const prevSyncTime = prevSyncSuccessful ? dateTime(ldapSyncInfo.prevSync.started).format(syncTimeFormat) : '';
return (
<>
<h3 className="page-heading">
LDAP Synchronisation
<button className={`btn btn-secondary pull-right`} onClick={this.handleSyncClick} hidden={true}>
<span className="btn-title">Bulk-sync now</span>
{isSyncing && <i className="fa fa-spinner fa-fw fa-spin run-icon" />}
</button>
</h3>
<div className="gf-form-group">
<div className="gf-form">
<table className="filter-table form-inline">
<tbody>
<tr>
<td>Active synchronisation</td>
<td colSpan={2}>{ldapSyncInfo.enabled ? 'Enabled' : 'Disabled'}</td>
</tr>
<tr>
<td>Scheduled</td>
<td>{ldapSyncInfo.schedule}</td>
</tr>
<tr>
<td>Next scheduled synchronisation</td>
<td>{nextSyncTime}</td>
</tr>
<tr>
<td>Last synchronisation</td>
{prevSyncSuccessful && (
<>
<td>{prevSyncTime}</td>
<td>Successful</td>
</>
)}
</tr>
</tbody>
</table>
</div>
</div>
</>
);
}
}

View File

@ -0,0 +1,56 @@
import React, { FC } from 'react';
import { Tooltip } from '@grafana/ui';
import { LdapRole } from 'app/types';
interface Props {
groups: LdapRole[];
showAttributeMapping?: boolean;
}
export const LdapUserGroups: FC<Props> = ({ groups, showAttributeMapping }) => {
const items = showAttributeMapping ? groups : groups.filter(item => item.orgRole);
const roleColumnClass = showAttributeMapping && 'width-14';
return (
<div className="gf-form-group">
<div className="gf-form">
<table className="filter-table form-inline">
<thead>
<tr>
<th>Organisation</th>
<th>Role</th>
{showAttributeMapping && <th colSpan={2}>LDAP Group</th>}
</tr>
</thead>
<tbody>
{items.map((group, index) => {
return (
<tr key={`${group.orgId}-${index}`}>
<td className="width-16">{group.orgName}</td>
<td className={roleColumnClass}>{group.orgRole}</td>
{showAttributeMapping && (
<>
<td>{group.groupDN}</td>
<td>
{!group.orgRole && (
<span className="text-warning pull-right">
No match
<Tooltip placement="top" content="No matching groups found" theme={'info'}>
<div className="gf-form-help-icon gf-form-help-icon--right-normal">
<i className="fa fa-info-circle" />
</div>
</Tooltip>
</span>
)}
</td>
</>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
};

View File

@ -0,0 +1,26 @@
import React, { FC } from 'react';
import { LdapUserMappingInfo } from './LdapUserMappingInfo';
import { LdapUserPermissions } from './LdapUserPermissions';
import { LdapUserGroups } from './LdapUserGroups';
import { LdapUserTeams } from './LdapUserTeams';
import { LdapUser } from 'app/types';
interface Props {
ldapUser: LdapUser;
showAttributeMapping?: boolean;
}
export const LdapUserInfo: FC<Props> = ({ ldapUser, showAttributeMapping }) => {
return (
<>
<LdapUserMappingInfo info={ldapUser.info} showAttributeMapping={showAttributeMapping} />
<LdapUserPermissions permissions={ldapUser.permissions} />
{ldapUser.roles && ldapUser.roles.length > 0 && (
<LdapUserGroups groups={ldapUser.roles} showAttributeMapping={showAttributeMapping} />
)}
{ldapUser.teams && ldapUser.teams.length > 0 && (
<LdapUserTeams teams={ldapUser.teams} showAttributeMapping={showAttributeMapping} />
)}
</>
);
};

View File

@ -0,0 +1,46 @@
import React, { FC } from 'react';
import { LdapUserInfo } from 'app/types';
interface Props {
info: LdapUserInfo;
showAttributeMapping?: boolean;
}
export const LdapUserMappingInfo: FC<Props> = ({ info, showAttributeMapping }) => {
return (
<div className="gf-form-group">
<div className="gf-form">
<table className="filter-table form-inline">
<thead>
<tr>
<th colSpan={2}>User information</th>
{showAttributeMapping && <th>LDAP attribute</th>}
</tr>
</thead>
<tbody>
<tr>
<td className="width-16">First name</td>
<td>{info.name.ldapValue}</td>
{showAttributeMapping && <td>{info.name.cfgAttrValue}</td>}
</tr>
<tr>
<td className="width-16">Surname</td>
<td>{info.surname.ldapValue}</td>
{showAttributeMapping && <td>{info.surname.cfgAttrValue}</td>}
</tr>
<tr>
<td className="width-16">Username</td>
<td>{info.login.ldapValue}</td>
{showAttributeMapping && <td>{info.login.cfgAttrValue}</td>}
</tr>
<tr>
<td className="width-16">Email</td>
<td>{info.email.ldapValue}</td>
{showAttributeMapping && <td>{info.email.cfgAttrValue}</td>}
</tr>
</tbody>
</table>
</div>
</div>
);
};

View File

@ -0,0 +1,159 @@
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import { NavModel } from '@grafana/data';
import Page from 'app/core/components/Page/Page';
import { AlertBox } from 'app/core/components/AlertBox/AlertBox';
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);
};
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">
<AlertBox
title={userError.title}
severity={AppNotificationSeverity.Error}
body={userError.body}
onClose={this.onClearUserError}
/>
</div>
)}
{ldapUser && <LdapUserInfo ldapUser={ldapUser} />}
{!ldapUser && user && <UserInfo user={user} />}
{userSyncInfo && <UserSyncInfo syncInfo={userSyncInfo} onSync={this.onSyncUser} />}
{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)
);

View File

@ -0,0 +1,50 @@
import React, { FC } from 'react';
import { LdapPermissions } from 'app/types';
interface Props {
permissions: LdapPermissions;
}
export const LdapUserPermissions: FC<Props> = ({ permissions }) => {
return (
<div className="gf-form-group">
<div className="gf-form">
<table className="filter-table form-inline">
<thead>
<tr>
<th colSpan={1}>Permissions</th>
</tr>
</thead>
<tbody>
<tr>
<td className="width-16"> Grafana admin</td>
<td>
{permissions.isGrafanaAdmin ? (
<>
<i className="gicon gicon-shield" /> Yes
</>
) : (
'No'
)}
</td>
</tr>
<tr>
<td className="width-16">Status</td>
<td>
{permissions.isDisabled ? (
<>
<i className="fa fa-fw fa-times" /> Inactive
</>
) : (
<>
<i className="fa fa-fw fa-check" /> Active
</>
)}
</td>
</tr>
</tbody>
</table>
</div>
</div>
);
};

View File

@ -0,0 +1,55 @@
import React, { FC } from 'react';
import { css } from 'emotion';
import { Tooltip } from '@grafana/ui';
import { LdapTeam } from 'app/types';
interface Props {
teams: LdapTeam[];
showAttributeMapping?: boolean;
}
export const LdapUserTeams: FC<Props> = ({ teams, showAttributeMapping }) => {
const items = showAttributeMapping ? teams : teams.filter(item => item.teamName);
const teamColumnClass = showAttributeMapping && 'width-14';
const noMatchPlaceholderStyle = css`
display: flex;
`;
return (
<div className="gf-form-group">
<div className="gf-form">
<table className="filter-table form-inline">
<thead>
<tr>
<th>Organisation</th>
<th>Team</th>
{showAttributeMapping && <th>LDAP</th>}
</tr>
</thead>
<tbody>
{items.map((team, index) => {
return (
<tr key={`${team.teamName}-${index}`}>
<td className="width-16">
{team.orgName || (
<div className={`text-warning ${noMatchPlaceholderStyle}`}>
No match
<Tooltip placement="top" content="No matching teams found" theme={'info'}>
<div className="gf-form-help-icon gf-form-help-icon--right-normal">
<i className="fa fa-info-circle" />
</div>
</Tooltip>
</div>
)}
</td>
<td className={teamColumnClass}>{team.teamName}</td>
{showAttributeMapping && <td>{team.groupDN}</td>}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
};

View File

@ -30,27 +30,27 @@
<tbody>
<tr ng-repeat="user in ctrl.users">
<td class="width-4 text-center link-td">
<a href="admin/users/edit/{{user.id}}">
<a href="admin/users/{{user.authLabel === 'LDAP' ? 'ldap/' : ''}}edit/{{user.id}}">
<img class="filter-table__avatar" ng-src="{{user.avatarUrl}}"></img>
</a>
</td>
<td class="link-td">
<a href="admin/users/edit/{{user.id}}">
<a href="admin/users/{{user.authLabel === 'LDAP' ? 'ldap/' : ''}}edit/{{user.id}}">
{{user.login}}
</a>
</td>
<td class="link-td">
<a href="admin/users/edit/{{user.id}}">
<a href="admin/users/{{user.authLabel === 'LDAP' ? 'ldap/' : ''}}edit/{{user.id}}">
{{user.email}}
</a>
</td>
<td class="link-td">
<a href="admin/users/edit/{{user.id}}">
<a href="admin/users/{{user.authLabel === 'LDAP' ? 'ldap/' : ''}}edit/{{user.id}}">
{{user.lastSeenAtAge}}
</a>
</td>
<td class="link-td">
<a href="admin/users/edit/{{user.id}}">
<a href="admin/users/{{user.authLabel === 'LDAP' ? 'ldap/' : ''}}edit/{{user.id}}">
<i class="fa fa-shield" ng-show="user.isAdmin" bs-tooltip="'Grafana Admin'"></i>
</a>
</td>

View File

@ -0,0 +1,137 @@
import { actionCreatorFactory, noPayloadActionCreatorFactory } from 'app/core/redux';
import config from 'app/core/config';
import { ThunkResult, SyncInfo, LdapUser, LdapConnectionInfo, LdapError, UserSession, User } from 'app/types';
import {
getUserInfo,
getLdapState,
syncLdapUser,
getUser,
getUserSessions,
revokeUserSession,
revokeAllUserSessions,
getLdapSyncStatus,
} from './apis';
// Action types
export const ldapConnectionInfoLoadedAction = actionCreatorFactory<LdapConnectionInfo>(
'ldap/CONNECTION_INFO_LOADED'
).create();
export const ldapSyncStatusLoadedAction = actionCreatorFactory<SyncInfo>('ldap/SYNC_STATUS_LOADED').create();
export const userMappingInfoLoadedAction = actionCreatorFactory<LdapUser>('ldap/USER_INFO_LOADED').create();
export const userMappingInfoFailedAction = actionCreatorFactory<LdapError>('ldap/USER_INFO_FAILED').create();
export const clearUserMappingInfoAction = noPayloadActionCreatorFactory('ldap/CLEAR_USER_MAPPING_INFO').create();
export const clearUserErrorAction = noPayloadActionCreatorFactory('ldap/CLEAR_USER_ERROR').create();
export const ldapFailedAction = actionCreatorFactory<LdapError>('ldap/LDAP_FAILED').create();
export const userLoadedAction = actionCreatorFactory<User>('USER_LOADED').create();
export const userSessionsLoadedAction = actionCreatorFactory<UserSession[]>('USER_SESSIONS_LOADED').create();
export const userSyncFailedAction = noPayloadActionCreatorFactory('USER_SYNC_FAILED').create();
export const revokeUserSessionAction = noPayloadActionCreatorFactory('REVOKE_USER_SESSION').create();
export const revokeAllUserSessionsAction = noPayloadActionCreatorFactory('REVOKE_ALL_USER_SESSIONS').create();
// Actions
export function loadLdapState(): ThunkResult<void> {
return async dispatch => {
try {
const connectionInfo = await getLdapState();
dispatch(ldapConnectionInfoLoadedAction(connectionInfo));
} catch (error) {
const ldapError = {
title: error.data.message,
body: error.data.error,
};
dispatch(ldapFailedAction(ldapError));
}
};
}
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);
dispatch(userMappingInfoLoadedAction(userInfo));
} catch (error) {
const userError = {
title: error.data.message,
body: error.data.error,
};
dispatch(clearUserMappingInfoAction());
dispatch(userMappingInfoFailedAction(userError));
}
};
}
export function clearUserError(): ThunkResult<void> {
return dispatch => {
dispatch(clearUserErrorAction());
};
}
export function clearUserMappingInfo(): ThunkResult<void> {
return dispatch => {
dispatch(clearUserErrorAction());
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) {
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));
};
}

View File

@ -1,4 +1,6 @@
import { getBackendSrv } from '@grafana/runtime';
import { dateTime } from '@grafana/data';
import { LdapUser, LdapConnectionInfo, UserSession, SyncInfo, User } from 'app/types';
export interface ServerStat {
name: string;
@ -31,3 +33,64 @@ 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> => {
try {
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,
};
} catch (error) {
throw error;
}
};
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`);
};

View File

@ -0,0 +1,210 @@
import { Reducer } from 'redux';
import { reducerTester } from 'test/core/redux/reducerTester';
import { ActionOf } from 'app/core/redux/actionCreatorFactory';
import { ldapReducer, ldapUserReducer } from './reducers';
import {
ldapConnectionInfoLoadedAction,
ldapSyncStatusLoadedAction,
userMappingInfoLoadedAction,
userMappingInfoFailedAction,
ldapFailedAction,
userLoadedAction,
} from './actions';
import { LdapState, LdapUserState, LdapUser, User } from 'app/types';
const makeInitialLdapState = (): LdapState => ({
connectionInfo: [],
syncInfo: null,
user: null,
ldapError: null,
connectionError: null,
userError: null,
});
const makeInitialLdapUserState = (): LdapUserState => ({
user: null,
ldapUser: null,
ldapSyncInfo: null,
sessions: [],
});
const getTestUserMapping = (): LdapUser => ({
info: {
email: { cfgAttrValue: 'mail', ldapValue: 'user@localhost' },
name: { cfgAttrValue: 'givenName', ldapValue: 'User' },
surname: { cfgAttrValue: 'sn', ldapValue: '' },
login: { cfgAttrValue: 'cn', ldapValue: 'user' },
},
permissions: {
isGrafanaAdmin: false,
isDisabled: false,
},
roles: [],
teams: [],
});
const getTestUser = (): User => ({
id: 1,
email: 'user@localhost',
login: 'user',
name: 'User',
avatarUrl: '',
label: '',
});
describe('LDAP page reducer', () => {
describe('When page loaded', () => {
describe('When connection info loaded', () => {
it('should set connection info and clear error', () => {
const initalState = {
...makeInitialLdapState(),
};
reducerTester()
.givenReducer(ldapReducer as Reducer<LdapState, ActionOf<any>>, initalState)
.whenActionIsDispatched(
ldapConnectionInfoLoadedAction([
{
available: true,
host: 'localhost',
port: 389,
error: null,
},
])
)
.thenStateShouldEqual({
...makeInitialLdapState(),
connectionInfo: [
{
available: true,
host: 'localhost',
port: 389,
error: null,
},
],
ldapError: null,
});
});
});
describe('When connection failed', () => {
it('should set ldap error', () => {
const initalState = {
...makeInitialLdapState(),
};
reducerTester()
.givenReducer(ldapReducer as Reducer<LdapState, ActionOf<any>>, initalState)
.whenActionIsDispatched(
ldapFailedAction({
title: 'LDAP error',
body: 'Failed to connect',
})
)
.thenStateShouldEqual({
...makeInitialLdapState(),
ldapError: {
title: 'LDAP error',
body: 'Failed to connect',
},
});
});
});
describe('When LDAP sync status loaded', () => {
it('should set sync info', () => {
const initalState = {
...makeInitialLdapState(),
};
reducerTester()
.givenReducer(ldapReducer as Reducer<LdapState, ActionOf<any>>, initalState)
.whenActionIsDispatched(
ldapSyncStatusLoadedAction({
enabled: true,
schedule: '0 0 * * * *',
nextSync: '2019-01-01T12:00:00Z',
})
)
.thenStateShouldEqual({
...makeInitialLdapState(),
syncInfo: {
enabled: true,
schedule: '0 0 * * * *',
nextSync: '2019-01-01T12:00:00Z',
},
});
});
});
});
describe('When user mapping info loaded', () => {
it('should set sync info and clear user error', () => {
const initalState = {
...makeInitialLdapState(),
userError: {
title: 'User not found',
body: 'Cannot find user',
},
};
reducerTester()
.givenReducer(ldapReducer as Reducer<LdapState, ActionOf<any>>, initalState)
.whenActionIsDispatched(userMappingInfoLoadedAction(getTestUserMapping()))
.thenStateShouldEqual({
...makeInitialLdapState(),
user: getTestUserMapping(),
userError: null,
});
});
});
describe('When user not found', () => {
it('should set user error and clear user info', () => {
const initalState = {
...makeInitialLdapState(),
user: getTestUserMapping(),
};
reducerTester()
.givenReducer(ldapReducer as Reducer<LdapState, ActionOf<any>>, initalState)
.whenActionIsDispatched(
userMappingInfoFailedAction({
title: 'User not found',
body: 'Cannot find user',
})
)
.thenStateShouldEqual({
...makeInitialLdapState(),
user: null,
userError: {
title: 'User not found',
body: 'Cannot find user',
},
});
});
});
});
describe('Edit LDAP user page reducer', () => {
describe('When user loaded', () => {
it('should set user and clear user error', () => {
const initalState = {
...makeInitialLdapUserState(),
userError: {
title: 'User not found',
body: 'Cannot find user',
},
};
reducerTester()
.givenReducer(ldapUserReducer as Reducer<LdapUserState, ActionOf<any>>, initalState)
.whenActionIsDispatched(userLoadedAction(getTestUser()))
.thenStateShouldEqual({
...makeInitialLdapUserState(),
user: getTestUser(),
userError: null,
});
});
});
});

View File

@ -0,0 +1,135 @@
import { reducerFactory } from 'app/core/redux';
import { LdapState, LdapUserState } from 'app/types';
import {
ldapConnectionInfoLoadedAction,
ldapFailedAction,
userMappingInfoLoadedAction,
userMappingInfoFailedAction,
clearUserErrorAction,
userLoadedAction,
userSessionsLoadedAction,
ldapSyncStatusLoadedAction,
clearUserMappingInfoAction,
} from './actions';
const initialLdapState: LdapState = {
connectionInfo: [],
syncInfo: null,
user: null,
connectionError: null,
userError: null,
};
const initialLdapUserState: LdapUserState = {
user: null,
ldapUser: null,
ldapSyncInfo: null,
sessions: [],
};
export const ldapReducer = reducerFactory(initialLdapState)
.addMapper({
filter: ldapConnectionInfoLoadedAction,
mapper: (state, action) => ({
...state,
ldapError: null,
connectionInfo: action.payload,
}),
})
.addMapper({
filter: ldapFailedAction,
mapper: (state, action) => ({
...state,
ldapError: action.payload,
}),
})
.addMapper({
filter: ldapSyncStatusLoadedAction,
mapper: (state, action) => ({
...state,
syncInfo: action.payload,
}),
})
.addMapper({
filter: userMappingInfoLoadedAction,
mapper: (state, action) => ({
...state,
user: action.payload,
userError: null,
}),
})
.addMapper({
filter: userMappingInfoFailedAction,
mapper: (state, action) => ({
...state,
user: null,
userError: action.payload,
}),
})
.addMapper({
filter: clearUserMappingInfoAction,
mapper: (state, action) => ({
...state,
user: null,
}),
})
.addMapper({
filter: clearUserErrorAction,
mapper: state => ({
...state,
userError: null,
}),
})
.create();
export const ldapUserReducer = reducerFactory(initialLdapUserState)
.addMapper({
filter: userMappingInfoLoadedAction,
mapper: (state, action) => ({
...state,
ldapUser: action.payload,
}),
})
.addMapper({
filter: userMappingInfoFailedAction,
mapper: (state, action) => ({
...state,
ldapUser: null,
userError: action.payload,
}),
})
.addMapper({
filter: clearUserErrorAction,
mapper: state => ({
...state,
userError: null,
}),
})
.addMapper({
filter: ldapSyncStatusLoadedAction,
mapper: (state, action) => ({
...state,
ldapSyncInfo: action.payload,
}),
})
.addMapper({
filter: userLoadedAction,
mapper: (state, action) => ({
...state,
user: action.payload,
userError: null,
}),
})
.addMapper({
filter: userSessionsLoadedAction,
mapper: (state, action) => ({
...state,
sessions: action.payload,
}),
})
.create();
export default {
ldap: ldapReducer,
ldapUser: ldapUserReducer,
};

View File

@ -6,6 +6,8 @@ import { applyRouteRegistrationHandlers } from './registry';
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 LdapUserPage from 'app/features/admin/ldap/LdapUserPage';
import config from 'app/core/config';
import { route, ILocationProvider } from 'angular';
@ -257,6 +259,12 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
templateUrl: 'public/app/features/admin/partials/edit_user.html',
controller: 'AdminEditUserCtrl',
})
.when('/admin/users/ldap/edit/:id', {
template: '<react-container />',
resolve: {
component: () => LdapUserPage,
},
})
.when('/admin/orgs', {
templateUrl: 'public/app/features/admin/partials/orgs.html',
controller: 'AdminListOrgsCtrl',
@ -273,6 +281,12 @@ export function setupAngularRoutes($routeProvider: route.IRouteProvider, $locati
component: () => import(/* webpackChunkName: "ServerStats" */ 'app/features/admin/ServerStats'),
},
})
.when('/admin/ldap', {
template: '<react-container />',
resolve: {
component: () => LdapPage,
},
})
// LOGIN / SIGNUP
.when('/login', {
template: '<react-container/>',

View File

@ -13,6 +13,7 @@ import dataSourcesReducers from 'app/features/datasources/state/reducers';
import usersReducers from 'app/features/users/state/reducers';
import userReducers from 'app/features/profile/state/reducers';
import organizationReducers from 'app/features/org/state/reducers';
import ldapReducers from 'app/features/admin/state/reducers';
import { setStore } from './store';
import { StoreState } from 'app/types/store';
import { toggleLogActionsMiddleware } from 'app/core/middlewares/application';
@ -30,6 +31,7 @@ const rootReducers = {
...usersReducers,
...userReducers,
...organizationReducers,
...ldapReducers,
};
export function addRootReducer(reducers: any) {

View File

@ -13,3 +13,4 @@ export * from './appNotifications';
export * from './search';
export * from './explore';
export * from './store';
export * from './ldap';

95
public/app/types/ldap.ts Normal file
View File

@ -0,0 +1,95 @@
import { User, UserSession } from 'app/types';
interface LdapMapping {
cfgAttrValue: string;
ldapValue: string;
}
export interface LdapError {
title: string;
body: string;
}
export interface SyncInfo {
enabled: boolean;
schedule: string;
nextSync: string;
prevSync?: SyncResult;
}
export interface LdapUserSyncInfo {
nextSync?: string;
prevSync?: string;
status?: string;
}
export interface SyncResult {
started: string;
elapsed: string;
UpdatedUserIds: number[];
MissingUserIds: number[];
FailedUsers?: FailedUser[];
}
export interface FailedUser {
Login: string;
Error: string;
}
export interface LdapRole {
orgId: number;
orgName: string;
orgRole: string;
groupDN: string;
}
export interface LdapTeam {
orgName: string;
teamName: string;
groupDN: string;
}
export interface LdapUserInfo {
name: LdapMapping;
surname: LdapMapping;
email: LdapMapping;
login: LdapMapping;
}
export interface LdapPermissions {
isGrafanaAdmin: boolean;
isDisabled: boolean;
}
export interface LdapUser {
info: LdapUserInfo;
permissions: LdapPermissions;
roles: LdapRole[];
teams: LdapTeam[];
}
export interface LdapServerInfo {
available: boolean;
host: string;
port: number;
error: string;
}
export type LdapConnectionInfo = LdapServerInfo[];
export interface LdapState {
connectionInfo: LdapConnectionInfo;
user?: LdapUser;
syncInfo?: SyncInfo;
connectionError?: LdapError;
userError?: LdapError;
ldapError?: LdapError;
}
export interface LdapUserState {
user?: User;
ldapUser?: LdapUser;
ldapSyncInfo?: SyncInfo;
sessions?: UserSession[];
userError?: LdapError;
}

View File

@ -14,6 +14,7 @@ import { AppNotificationsState } from './appNotifications';
import { PluginsState } from './plugins';
import { NavIndex } from '@grafana/data';
import { ApplicationState } from './application';
import { LdapState, LdapUserState } from './ldap';
export interface StoreState {
navIndex: NavIndex;
@ -31,6 +32,8 @@ export interface StoreState {
user: UserState;
plugins: PluginsState;
application: ApplicationState;
ldap: LdapState;
ldapUser: LdapUserState;
}
/*

View File

@ -79,3 +79,7 @@
.alert-body {
flex-grow: 1;
}
.alert-icon-on-top {
align-items: flex-start;
}