From 3c61b563c3e51caf0d0b6334bf5ab06d746dfe1a Mon Sep 17 00:00:00 2001 From: Peter Holmberg Date: Mon, 16 Sep 2019 17:56:01 +0200 Subject: [PATCH] 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 --- .../src/services/backendSrv.ts | 6 +- pkg/api/index.go | 1 + pkg/api/ldap_debug.go | 4 + .../app/core/components/AlertBox/AlertBox.tsx | 4 +- .../app/features/admin/DisabledUserInfo.tsx | 22 ++ public/app/features/admin/UserInfo.tsx | 36 +++ public/app/features/admin/UserSessions.tsx | 68 ++++++ public/app/features/admin/UserSyncInfo.tsx | 69 ++++++ .../admin/ldap/LdapConnectionStatus.tsx | 82 +++++++ public/app/features/admin/ldap/LdapPage.tsx | 141 ++++++++++++ .../app/features/admin/ldap/LdapSyncInfo.tsx | 73 ++++++ .../features/admin/ldap/LdapUserGroups.tsx | 56 +++++ .../app/features/admin/ldap/LdapUserInfo.tsx | 26 +++ .../admin/ldap/LdapUserMappingInfo.tsx | 46 ++++ .../app/features/admin/ldap/LdapUserPage.tsx | 159 +++++++++++++ .../admin/ldap/LdapUserPermissions.tsx | 50 +++++ .../app/features/admin/ldap/LdapUserTeams.tsx | 55 +++++ public/app/features/admin/partials/users.html | 10 +- public/app/features/admin/state/actions.ts | 137 ++++++++++++ public/app/features/admin/state/apis.ts | 63 ++++++ .../app/features/admin/state/reducers.test.ts | 210 ++++++++++++++++++ public/app/features/admin/state/reducers.ts | 135 +++++++++++ public/app/routes/routes.ts | 14 ++ public/app/store/configureStore.ts | 2 + public/app/types/index.ts | 1 + public/app/types/ldap.ts | 95 ++++++++ public/app/types/store.ts | 3 + public/sass/components/_alerts.scss | 4 + 28 files changed, 1563 insertions(+), 9 deletions(-) create mode 100644 public/app/features/admin/DisabledUserInfo.tsx create mode 100644 public/app/features/admin/UserInfo.tsx create mode 100644 public/app/features/admin/UserSessions.tsx create mode 100644 public/app/features/admin/UserSyncInfo.tsx create mode 100644 public/app/features/admin/ldap/LdapConnectionStatus.tsx create mode 100644 public/app/features/admin/ldap/LdapPage.tsx create mode 100644 public/app/features/admin/ldap/LdapSyncInfo.tsx create mode 100644 public/app/features/admin/ldap/LdapUserGroups.tsx create mode 100644 public/app/features/admin/ldap/LdapUserInfo.tsx create mode 100644 public/app/features/admin/ldap/LdapUserMappingInfo.tsx create mode 100644 public/app/features/admin/ldap/LdapUserPage.tsx create mode 100644 public/app/features/admin/ldap/LdapUserPermissions.tsx create mode 100644 public/app/features/admin/ldap/LdapUserTeams.tsx create mode 100644 public/app/features/admin/state/actions.ts create mode 100644 public/app/features/admin/state/reducers.test.ts create mode 100644 public/app/features/admin/state/reducers.ts create mode 100644 public/app/types/ldap.ts diff --git a/packages/grafana-runtime/src/services/backendSrv.ts b/packages/grafana-runtime/src/services/backendSrv.ts index a30296eca8c..68e07c18df7 100644 --- a/packages/grafana-runtime/src/services/backendSrv.ts +++ b/packages/grafana-runtime/src/services/backendSrv.ts @@ -20,11 +20,11 @@ export interface BackendSrv { delete(url: string): Promise; - post(url: string, data: any): Promise; + post(url: string, data?: any): Promise; - patch(url: string, data: any): Promise; + patch(url: string, data?: any): Promise; - put(url: string, data: any): Promise; + put(url: string, data?: any): Promise; // If there is an error, set: err.isHandled = true // otherwise the backend will show a message for you diff --git a/pkg/api/index.go b/pkg/api/index.go index 3f5018f14e7..c22f3498a6a 100644 --- a/pkg/api/index.go +++ b/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"}, }, }) } diff --git a/pkg/api/ldap_debug.go b/pkg/api/ldap_debug.go index 7ccbb8fbeca..b5f9d91bf4a 100644 --- a/pkg/api/ldap_debug.go +++ b/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 { diff --git a/public/app/core/components/AlertBox/AlertBox.tsx b/public/app/core/components/AlertBox/AlertBox.tsx index 3bedf71dd5b..c9e19920fbb 100644 --- a/public/app/core/components/AlertBox/AlertBox.tsx +++ b/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 = ({ title, icon, body, severity, onClose }) => { + const alertClass = classNames('alert', `alert-${severity}`); return ( -
+
diff --git a/public/app/features/admin/DisabledUserInfo.tsx b/public/app/features/admin/DisabledUserInfo.tsx new file mode 100644 index 00000000000..4b7000b7980 --- /dev/null +++ b/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 = ({ user }) => { + return ( + <> + + + + ); +}; diff --git a/public/app/features/admin/UserInfo.tsx b/public/app/features/admin/UserInfo.tsx new file mode 100644 index 00000000000..a71a7527978 --- /dev/null +++ b/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 = ({ user }) => { + return ( +
+
+ + + + + + + + + + + + + + + + + + + + +
User information
Name{user.name}
Username{user.login}
Email{user.email}
+
+
+ ); +}; diff --git a/public/app/features/admin/UserSessions.tsx b/public/app/features/admin/UserSessions.tsx new file mode 100644 index 00000000000..c07c90227ca --- /dev/null +++ b/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 { + handleSessionRevoke = (id: number) => { + return () => { + this.props.onSessionRevoke(id); + }; + }; + + handleAllSessionsRevoke = () => { + this.props.onAllSessionsRevoke(); + }; + + render() { + const { sessions } = this.props; + + return ( + <> +

Sessions

+
+
+ + + + + + + + + + + {sessions && + sessions.map((session, index) => ( + + + + + + + + ))} + +
Last seenLogged onIP addressBrowser & OS
{session.isActive ? 'Now' : session.seenAt}{session.createdAt}{session.clientIp}{`${session.browser} on ${session.os} ${session.osVersion}`} + +
+
+
+ {sessions.length > 0 && ( + + )} +
+
+ + ); + } +} diff --git a/public/app/features/admin/UserSyncInfo.tsx b/public/app/features/admin/UserSyncInfo.tsx new file mode 100644 index 00000000000..577014d0392 --- /dev/null +++ b/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 { + 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 ( + <> +

+ LDAP + +

+
+
+ + + + + + {prevSyncSuccessful && } + + + + + + +
Last synchronisation{prevSyncTime}Successful
Next scheduled synchronisation{nextSyncTime}
+
+
+ + ); + } +} diff --git a/public/app/features/admin/ldap/LdapConnectionStatus.tsx b/public/app/features/admin/ldap/LdapConnectionStatus.tsx new file mode 100644 index 00000000000..f289bb74407 --- /dev/null +++ b/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 = ({ ldapConnectionInfo }) => { + return ( + <> +

LDAP Connection

+
+
+ + + + + + + + + {ldapConnectionInfo && + ldapConnectionInfo.map((serverInfo, index) => ( + + + + + + ))} + +
HostPort
{serverInfo.host}{serverInfo.port} + {serverInfo.available ? ( + + ) : ( + + )} +
+
+
+ +
+
+ + ); +}; + +interface LdapConnectionErrorProps { + ldapConnectionInfo: LdapConnectionInfo; +} + +export const LdapErrorBox: FC = ({ 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) => ( +
+ + {info.host}:{info.port} +
+
+ {info.error} + {index !== connectionErrors.length - 1 && ( + <> +
+
+ + )} +
+ )); + + return ; +}; diff --git a/public/app/features/admin/ldap/LdapPage.tsx b/public/app/features/admin/ldap/LdapPage.tsx new file mode 100644 index 00000000000..57b865badc3 --- /dev/null +++ b/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 { + 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 ( + + + <> + {ldapError && ldapError.title && ( +
+ +
+ )} + + + + {config.buildInfo.isEnterprise && ldapSyncInfo && } + +

User mapping

+
+
+ + + +
+ {userError && userError.title && ( +
+ +
+ )} + {ldapUser && } + +
+
+ ); + } +} + +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) +); diff --git a/public/app/features/admin/ldap/LdapSyncInfo.tsx b/public/app/features/admin/ldap/LdapSyncInfo.tsx new file mode 100644 index 00000000000..e03352f1cbc --- /dev/null +++ b/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 { + 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 ( + <> +

+ LDAP Synchronisation + +

+
+
+ + + + + + + + + + + + + + + + + {prevSyncSuccessful && ( + <> + + + + )} + + +
Active synchronisation{ldapSyncInfo.enabled ? 'Enabled' : 'Disabled'}
Scheduled{ldapSyncInfo.schedule}
Next scheduled synchronisation{nextSyncTime}
Last synchronisation{prevSyncTime}Successful
+
+
+ + ); + } +} diff --git a/public/app/features/admin/ldap/LdapUserGroups.tsx b/public/app/features/admin/ldap/LdapUserGroups.tsx new file mode 100644 index 00000000000..d38fbb956cc --- /dev/null +++ b/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 = ({ groups, showAttributeMapping }) => { + const items = showAttributeMapping ? groups : groups.filter(item => item.orgRole); + const roleColumnClass = showAttributeMapping && 'width-14'; + + return ( +
+
+ + + + + + {showAttributeMapping && } + + + + {items.map((group, index) => { + return ( + + + + {showAttributeMapping && ( + <> + + + + )} + + ); + })} + +
OrganisationRoleLDAP Group
{group.orgName}{group.orgRole}{group.groupDN} + {!group.orgRole && ( + + No match + +
+ +
+
+
+ )} +
+
+
+ ); +}; diff --git a/public/app/features/admin/ldap/LdapUserInfo.tsx b/public/app/features/admin/ldap/LdapUserInfo.tsx new file mode 100644 index 00000000000..3aed200c4df --- /dev/null +++ b/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 = ({ ldapUser, showAttributeMapping }) => { + return ( + <> + + + {ldapUser.roles && ldapUser.roles.length > 0 && ( + + )} + {ldapUser.teams && ldapUser.teams.length > 0 && ( + + )} + + ); +}; diff --git a/public/app/features/admin/ldap/LdapUserMappingInfo.tsx b/public/app/features/admin/ldap/LdapUserMappingInfo.tsx new file mode 100644 index 00000000000..26998042faf --- /dev/null +++ b/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 = ({ info, showAttributeMapping }) => { + return ( +
+
+ + + + + {showAttributeMapping && } + + + + + + + {showAttributeMapping && } + + + + + {showAttributeMapping && } + + + + + {showAttributeMapping && } + + + + + {showAttributeMapping && } + + +
User informationLDAP attribute
First name{info.name.ldapValue}{info.name.cfgAttrValue}
Surname{info.surname.ldapValue}{info.surname.cfgAttrValue}
Username{info.login.ldapValue}{info.login.cfgAttrValue}
Email{info.email.ldapValue}{info.email.cfgAttrValue}
+
+
+ ); +}; diff --git a/public/app/features/admin/ldap/LdapUserPage.tsx b/public/app/features/admin/ldap/LdapUserPage.tsx new file mode 100644 index 00000000000..5a8b99c1504 --- /dev/null +++ b/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 { + 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 ( + + +
+ This user is synced via LDAP – all changes must be done in LDAP or mappings. +
+ {userError && userError.title && ( +
+ +
+ )} + + {ldapUser && } + {!ldapUser && user && } + {userSyncInfo && } + + {sessions && ( + + )} +
+
+ ); + } +} + +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) +); diff --git a/public/app/features/admin/ldap/LdapUserPermissions.tsx b/public/app/features/admin/ldap/LdapUserPermissions.tsx new file mode 100644 index 00000000000..180c4015912 --- /dev/null +++ b/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 = ({ permissions }) => { + return ( +
+
+ + + + + + + + + + + + + + + + +
Permissions
Grafana admin + {permissions.isGrafanaAdmin ? ( + <> + Yes + + ) : ( + 'No' + )} +
Status + {permissions.isDisabled ? ( + <> + Inactive + + ) : ( + <> + Active + + )} +
+
+
+ ); +}; diff --git a/public/app/features/admin/ldap/LdapUserTeams.tsx b/public/app/features/admin/ldap/LdapUserTeams.tsx new file mode 100644 index 00000000000..30ac81b5231 --- /dev/null +++ b/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 = ({ teams, showAttributeMapping }) => { + const items = showAttributeMapping ? teams : teams.filter(item => item.teamName); + const teamColumnClass = showAttributeMapping && 'width-14'; + const noMatchPlaceholderStyle = css` + display: flex; + `; + + return ( +
+
+ + + + + + {showAttributeMapping && } + + + + {items.map((team, index) => { + return ( + + + + {showAttributeMapping && } + + ); + })} + +
OrganisationTeamLDAP
+ {team.orgName || ( +
+ No match + +
+ +
+
+
+ )} +
{team.teamName}{team.groupDN}
+
+
+ ); +}; diff --git a/public/app/features/admin/partials/users.html b/public/app/features/admin/partials/users.html index 21346b92aa7..9122300cc62 100644 --- a/public/app/features/admin/partials/users.html +++ b/public/app/features/admin/partials/users.html @@ -30,27 +30,27 @@ - + - + {{user.login}} - + {{user.email}} - + {{user.lastSeenAtAge}} - + diff --git a/public/app/features/admin/state/actions.ts b/public/app/features/admin/state/actions.ts new file mode 100644 index 00000000000..93501db91d8 --- /dev/null +++ b/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( + 'ldap/CONNECTION_INFO_LOADED' +).create(); +export const ldapSyncStatusLoadedAction = actionCreatorFactory('ldap/SYNC_STATUS_LOADED').create(); +export const userMappingInfoLoadedAction = actionCreatorFactory('ldap/USER_INFO_LOADED').create(); +export const userMappingInfoFailedAction = actionCreatorFactory('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('ldap/LDAP_FAILED').create(); + +export const userLoadedAction = actionCreatorFactory('USER_LOADED').create(); +export const userSessionsLoadedAction = actionCreatorFactory('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 { + 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 { + return async dispatch => { + if (config.buildInfo.isEnterprise) { + // Available only in enterprise + const syncStatus = await getLdapSyncStatus(); + dispatch(ldapSyncStatusLoadedAction(syncStatus)); + } + }; +} + +export function loadUserMapping(username: string): ThunkResult { + 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 { + return dispatch => { + dispatch(clearUserErrorAction()); + }; +} + +export function clearUserMappingInfo(): ThunkResult { + return dispatch => { + dispatch(clearUserErrorAction()); + dispatch(clearUserMappingInfoAction()); + }; +} + +export function syncUser(userId: number): ThunkResult { + return async dispatch => { + try { + await syncLdapUser(userId); + dispatch(loadLdapUserInfo(userId)); + dispatch(loadLdapSyncStatus()); + } catch (error) { + dispatch(userSyncFailedAction()); + } + }; +} + +export function loadLdapUserInfo(userId: number): ThunkResult { + 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 { + return async dispatch => { + const sessions = await getUserSessions(userId); + dispatch(userSessionsLoadedAction(sessions)); + }; +} + +export function revokeSession(tokenId: number, userId: number): ThunkResult { + return async dispatch => { + await revokeUserSession(tokenId, userId); + dispatch(loadUserSessions(userId)); + }; +} + +export function revokeAllSessions(userId: number): ThunkResult { + return async dispatch => { + await revokeAllUserSessions(userId); + dispatch(loadUserSessions(userId)); + }; +} diff --git a/public/app/features/admin/state/apis.ts b/public/app/features/admin/state/apis.ts index 1166fa4dc01..f7194c62086 100644 --- a/public/app/features/admin/state/apis.ts +++ b/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 => { throw error; } }; + +export const getLdapState = async (): Promise => { + return await getBackendSrv().get(`/api/admin/ldap/status`); +}; + +export const getLdapSyncStatus = async (): Promise => { + 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 => { + 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 => { + 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`); +}; diff --git a/public/app/features/admin/state/reducers.test.ts b/public/app/features/admin/state/reducers.test.ts new file mode 100644 index 00000000000..2e181742ce6 --- /dev/null +++ b/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>, 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>, 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>, 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>, 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>, 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>, initalState) + .whenActionIsDispatched(userLoadedAction(getTestUser())) + .thenStateShouldEqual({ + ...makeInitialLdapUserState(), + user: getTestUser(), + userError: null, + }); + }); + }); +}); diff --git a/public/app/features/admin/state/reducers.ts b/public/app/features/admin/state/reducers.ts new file mode 100644 index 00000000000..a8353379d33 --- /dev/null +++ b/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, +}; diff --git a/public/app/routes/routes.ts b/public/app/routes/routes.ts index 6a1f8d9d15d..1c7f7821f2e 100644 --- a/public/app/routes/routes.ts +++ b/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: '', + 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: '', + resolve: { + component: () => LdapPage, + }, + }) // LOGIN / SIGNUP .when('/login', { template: '', diff --git a/public/app/store/configureStore.ts b/public/app/store/configureStore.ts index f549f81bcc8..7c210cb81eb 100644 --- a/public/app/store/configureStore.ts +++ b/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) { diff --git a/public/app/types/index.ts b/public/app/types/index.ts index 6da9d859f5c..a47f1123024 100644 --- a/public/app/types/index.ts +++ b/public/app/types/index.ts @@ -13,3 +13,4 @@ export * from './appNotifications'; export * from './search'; export * from './explore'; export * from './store'; +export * from './ldap'; diff --git a/public/app/types/ldap.ts b/public/app/types/ldap.ts new file mode 100644 index 00000000000..cebb32958da --- /dev/null +++ b/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; +} diff --git a/public/app/types/store.ts b/public/app/types/store.ts index 26e2749cb5a..d39e77f46f1 100644 --- a/public/app/types/store.ts +++ b/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; } /* diff --git a/public/sass/components/_alerts.scss b/public/sass/components/_alerts.scss index 56e7c8e3444..628d0718113 100644 --- a/public/sass/components/_alerts.scss +++ b/public/sass/components/_alerts.scss @@ -79,3 +79,7 @@ .alert-body { flex-grow: 1; } + +.alert-icon-on-top { + align-items: flex-start; +}