Forráskód Böngészése

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
Peter Holmberg 6 éve
szülő
commit
3c61b563c3

+ 3 - 3
packages/grafana-runtime/src/services/backendSrv.ts

@@ -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

+ 1 - 0
pkg/api/index.go

@@ -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"},
 			},
 		})
 	}

+ 4 - 0
pkg/api/ldap_debug.go

@@ -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 {

+ 3 - 1
public/app/core/components/AlertBox/AlertBox.tsx

@@ -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>

+ 22 - 0
public/app/features/admin/DisabledUserInfo.tsx

@@ -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} />
+    </>
+  );
+};

+ 36 - 0
public/app/features/admin/UserInfo.tsx

@@ -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>
+  );
+};

+ 68 - 0
public/app/features/admin/UserSessions.tsx

@@ -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>
+      </>
+    );
+  }
+}

+ 69 - 0
public/app/features/admin/UserSyncInfo.tsx

@@ -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>
+      </>
+    );
+  }
+}

+ 82 - 0
public/app/features/admin/ldap/LdapConnectionStatus.tsx

@@ -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} />;
+};

+ 141 - 0
public/app/features/admin/ldap/LdapPage.tsx

@@ -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)
+);

+ 73 - 0
public/app/features/admin/ldap/LdapSyncInfo.tsx

@@ -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>
+      </>
+    );
+  }
+}

+ 56 - 0
public/app/features/admin/ldap/LdapUserGroups.tsx

@@ -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>
+  );
+};

+ 26 - 0
public/app/features/admin/ldap/LdapUserInfo.tsx

@@ -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} />
+      )}
+    </>
+  );
+};

+ 46 - 0
public/app/features/admin/ldap/LdapUserMappingInfo.tsx

@@ -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>
+  );
+};

+ 159 - 0
public/app/features/admin/ldap/LdapUserPage.tsx

@@ -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)
+);

+ 50 - 0
public/app/features/admin/ldap/LdapUserPermissions.tsx

@@ -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>
+  );
+};

+ 55 - 0
public/app/features/admin/ldap/LdapUserTeams.tsx

@@ -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>
+  );
+};

+ 5 - 5
public/app/features/admin/partials/users.html

@@ -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>

+ 137 - 0
public/app/features/admin/state/actions.ts

@@ -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));
+  };
+}

+ 63 - 0
public/app/features/admin/state/apis.ts

@@ -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`);
+};

+ 210 - 0
public/app/features/admin/state/reducers.test.ts

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

+ 135 - 0
public/app/features/admin/state/reducers.ts

@@ -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,
+};

+ 14 - 0
public/app/routes/routes.ts

@@ -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/>',

+ 2 - 0
public/app/store/configureStore.ts

@@ -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) {

+ 1 - 0
public/app/types/index.ts

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

+ 95 - 0
public/app/types/ldap.ts

@@ -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;
+}

+ 3 - 0
public/app/types/store.ts

@@ -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;
 }
 
 /*

+ 4 - 0
public/sass/components/_alerts.scss

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