mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Ldap: Add LDAP debug page (#18759)
* Add items for navmodel and basic page * add reducer and actions * adding user mapping table component * adding components for ldap tables * add alert box on error * close error alert box * LDAP status page: connect APIs WIP * LDAP debug: fetch connection status from API * LDAP debug: fetch user info from API * LDAP debug: improve connection error view * LDAP debug: connection error tweaks * LDAP debug: fix role mapping view * LDAP debug: role mapping view tweaks * LDAP debug: add bulk-sync button stub * LDAP debug: minor refactor * LDAP debug: show user teams * LDAP debug: user info refactor * LDAP debug: initial user page * LDAP debug: minor refactor, remove unused angular wrapper * LDAP debug: add sessions to user page * LDAP debug: tweak user page * LDAP debug: tweak view for disabled user * LDAP debug: get sync info from API * LDAP debug: user sync info * LDAP debug: sync user button * LDAP debug: clear error on page load * LDAP debug: add user last sync info * LDAP debug: actions refactor * LDAP debug: roles and teams style tweaks * Pass showAttributeMapping to LdapUserTeams * LDAP debug: hide bulk sync button * LDAP debug: refactor sessions component * LDAP debug: fix loading user sessions * LDAP debug: hide sync user button * LDAP debug: fix fetching unavailable /ldap-sync-status endpoint * LDAP debug: revert accidentally added fix * LDAP debug: show error when LDAP is not enabled * LDAP debug: refactor, move ldap components into ldap/ folder * LDAP debug: styles refactoring * LDAP debug: ldap reducer tests * LDAP debug: ldap user reducer tests * LDAP debug: fix connection error placement * Text update * LdapUser: Minor UI changes moving things around * AlertBox: Removed icon-on-top as everywhere else it is centered, want to have it be consistent
This commit is contained in:
parent
ba11958a52
commit
3c61b563c3
@ -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
|
||||
|
@ -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"},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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
public/app/features/admin/DisabledUserInfo.tsx
Normal file
22
public/app/features/admin/DisabledUserInfo.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import React, { FC } from 'react';
|
||||
import { UserInfo } from './UserInfo';
|
||||
import { LdapUserPermissions } from './ldap/LdapUserPermissions';
|
||||
import { User } from 'app/types';
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export const DisabledUserInfo: FC<Props> = ({ user }) => {
|
||||
return (
|
||||
<>
|
||||
<LdapUserPermissions
|
||||
permissions={{
|
||||
isGrafanaAdmin: (user as any).isGrafanaAdmin,
|
||||
isDisabled: (user as any).isDisabled,
|
||||
}}
|
||||
/>
|
||||
<UserInfo user={user} />
|
||||
</>
|
||||
);
|
||||
};
|
36
public/app/features/admin/UserInfo.tsx
Normal file
36
public/app/features/admin/UserInfo.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import React, { FC } from 'react';
|
||||
import { User } from 'app/types';
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
}
|
||||
|
||||
export const UserInfo: FC<Props> = ({ user }) => {
|
||||
return (
|
||||
<div className="gf-form-group">
|
||||
<div className="gf-form">
|
||||
<table className="filter-table form-inline">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colSpan={2}>User information</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="width-16">Name</td>
|
||||
<td>{user.name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="width-16">Username</td>
|
||||
<td>{user.login}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="width-16">Email</td>
|
||||
<td>{user.email}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
68
public/app/features/admin/UserSessions.tsx
Normal file
68
public/app/features/admin/UserSessions.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { UserSession } from 'app/types';
|
||||
|
||||
interface Props {
|
||||
sessions: UserSession[];
|
||||
|
||||
onSessionRevoke: (id: number) => void;
|
||||
onAllSessionsRevoke: () => void;
|
||||
}
|
||||
|
||||
export class UserSessions extends PureComponent<Props> {
|
||||
handleSessionRevoke = (id: number) => {
|
||||
return () => {
|
||||
this.props.onSessionRevoke(id);
|
||||
};
|
||||
};
|
||||
|
||||
handleAllSessionsRevoke = () => {
|
||||
this.props.onAllSessionsRevoke();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { sessions } = this.props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="page-heading">Sessions</h3>
|
||||
<div className="gf-form-group">
|
||||
<div className="gf-form">
|
||||
<table className="filter-table form-inline">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Last seen</th>
|
||||
<th>Logged on</th>
|
||||
<th>IP address</th>
|
||||
<th colSpan={2}>Browser & 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
public/app/features/admin/UserSyncInfo.tsx
Normal file
69
public/app/features/admin/UserSyncInfo.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { dateTime } from '@grafana/data';
|
||||
import { LdapUserSyncInfo } from 'app/types';
|
||||
|
||||
interface Props {
|
||||
syncInfo: LdapUserSyncInfo;
|
||||
onSync?: () => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
isSyncing: boolean;
|
||||
}
|
||||
|
||||
const syncTimeFormat = 'dddd YYYY-MM-DD HH:mm zz';
|
||||
|
||||
export class UserSyncInfo extends PureComponent<Props, State> {
|
||||
state = {
|
||||
isSyncing: false,
|
||||
};
|
||||
|
||||
handleSyncClick = async () => {
|
||||
const { onSync } = this.props;
|
||||
this.setState({ isSyncing: true });
|
||||
try {
|
||||
if (onSync) {
|
||||
await onSync();
|
||||
}
|
||||
} finally {
|
||||
this.setState({ isSyncing: false });
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { syncInfo } = this.props;
|
||||
const { isSyncing } = this.state;
|
||||
const nextSyncTime = syncInfo.nextSync ? dateTime(syncInfo.nextSync).format(syncTimeFormat) : '';
|
||||
const prevSyncSuccessful = syncInfo && syncInfo.prevSync;
|
||||
const prevSyncTime = prevSyncSuccessful ? dateTime(syncInfo.prevSync).format(syncTimeFormat) : '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="page-heading">
|
||||
LDAP
|
||||
<button className={`btn btn-secondary pull-right`} onClick={this.handleSyncClick} hidden={true}>
|
||||
<span className="btn-title">Sync user</span>
|
||||
{isSyncing && <i className="fa fa-spinner fa-fw fa-spin run-icon" />}
|
||||
</button>
|
||||
</h3>
|
||||
<div className="gf-form-group">
|
||||
<div className="gf-form">
|
||||
<table className="filter-table form-inline">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Last synchronisation</td>
|
||||
<td>{prevSyncTime}</td>
|
||||
{prevSyncSuccessful && <td className="pull-right">Successful</td>}
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Next scheduled synchronisation</td>
|
||||
<td colSpan={2}>{nextSyncTime}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
82
public/app/features/admin/ldap/LdapConnectionStatus.tsx
Normal file
82
public/app/features/admin/ldap/LdapConnectionStatus.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import React, { FC } from 'react';
|
||||
import { AlertBox } from 'app/core/components/AlertBox/AlertBox';
|
||||
import { AppNotificationSeverity, LdapConnectionInfo, LdapServerInfo } from 'app/types';
|
||||
|
||||
interface Props {
|
||||
ldapConnectionInfo: LdapConnectionInfo;
|
||||
}
|
||||
|
||||
export const LdapConnectionStatus: FC<Props> = ({ ldapConnectionInfo }) => {
|
||||
return (
|
||||
<>
|
||||
<h3 className="page-heading">LDAP Connection</h3>
|
||||
<div className="gf-form-group">
|
||||
<div className="gf-form">
|
||||
<table className="filter-table form-inline">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Host</th>
|
||||
<th colSpan={2}>Port</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ldapConnectionInfo &&
|
||||
ldapConnectionInfo.map((serverInfo, index) => (
|
||||
<tr key={index}>
|
||||
<td>{serverInfo.host}</td>
|
||||
<td>{serverInfo.port}</td>
|
||||
<td>
|
||||
{serverInfo.available ? (
|
||||
<i className="fa fa-fw fa-check pull-right" />
|
||||
) : (
|
||||
<i className="fa fa-fw fa-exclamation-triangle pull-right" />
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="gf-form-group">
|
||||
<LdapErrorBox ldapConnectionInfo={ldapConnectionInfo} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface LdapConnectionErrorProps {
|
||||
ldapConnectionInfo: LdapConnectionInfo;
|
||||
}
|
||||
|
||||
export const LdapErrorBox: FC<LdapConnectionErrorProps> = ({ ldapConnectionInfo }) => {
|
||||
const hasError = ldapConnectionInfo.some(info => info.error);
|
||||
if (!hasError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const connectionErrors: LdapServerInfo[] = [];
|
||||
ldapConnectionInfo.forEach(info => {
|
||||
if (info.error) {
|
||||
connectionErrors.push(info);
|
||||
}
|
||||
});
|
||||
|
||||
const errorElements = connectionErrors.map((info, index) => (
|
||||
<div key={index}>
|
||||
<span style={{ fontWeight: 500 }}>
|
||||
{info.host}:{info.port}
|
||||
<br />
|
||||
</span>
|
||||
<span>{info.error}</span>
|
||||
{index !== connectionErrors.length - 1 && (
|
||||
<>
|
||||
<br />
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
|
||||
return <AlertBox title="Connection error" severity={AppNotificationSeverity.Error} body={errorElements} />;
|
||||
};
|
141
public/app/features/admin/ldap/LdapPage.tsx
Normal file
141
public/app/features/admin/ldap/LdapPage.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import { connect } from 'react-redux';
|
||||
import { NavModel } from '@grafana/data';
|
||||
import { FormField } from '@grafana/ui';
|
||||
import { getNavModel } from 'app/core/selectors/navModel';
|
||||
import config from 'app/core/config';
|
||||
import Page from 'app/core/components/Page/Page';
|
||||
import { AlertBox } from 'app/core/components/AlertBox/AlertBox';
|
||||
import { LdapConnectionStatus } from './LdapConnectionStatus';
|
||||
import { LdapSyncInfo } from './LdapSyncInfo';
|
||||
import { LdapUserInfo } from './LdapUserInfo';
|
||||
import { AppNotificationSeverity, LdapError, LdapUser, StoreState, SyncInfo, LdapConnectionInfo } from 'app/types';
|
||||
import {
|
||||
loadLdapState,
|
||||
loadLdapSyncStatus,
|
||||
loadUserMapping,
|
||||
clearUserError,
|
||||
clearUserMappingInfo,
|
||||
} from '../state/actions';
|
||||
|
||||
interface Props {
|
||||
navModel: NavModel;
|
||||
ldapConnectionInfo: LdapConnectionInfo;
|
||||
ldapUser: LdapUser;
|
||||
ldapSyncInfo: SyncInfo;
|
||||
ldapError: LdapError;
|
||||
userError?: LdapError;
|
||||
|
||||
loadLdapState: typeof loadLdapState;
|
||||
loadLdapSyncStatus: typeof loadLdapSyncStatus;
|
||||
loadUserMapping: typeof loadUserMapping;
|
||||
clearUserError: typeof clearUserError;
|
||||
clearUserMappingInfo: typeof clearUserMappingInfo;
|
||||
}
|
||||
|
||||
interface State {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export class LdapPage extends PureComponent<Props, State> {
|
||||
state = {
|
||||
isLoading: true,
|
||||
};
|
||||
|
||||
async componentDidMount() {
|
||||
await this.props.clearUserMappingInfo();
|
||||
await this.fetchLDAPStatus();
|
||||
this.setState({ isLoading: false });
|
||||
}
|
||||
|
||||
async fetchLDAPStatus() {
|
||||
const { loadLdapState, loadLdapSyncStatus } = this.props;
|
||||
return Promise.all([loadLdapState(), loadLdapSyncStatus()]);
|
||||
}
|
||||
|
||||
async fetchUserMapping(username: string) {
|
||||
const { loadUserMapping } = this.props;
|
||||
return await loadUserMapping(username);
|
||||
}
|
||||
|
||||
search = (event: any) => {
|
||||
event.preventDefault();
|
||||
const username = event.target.elements['username'].value;
|
||||
if (username) {
|
||||
this.fetchUserMapping(username);
|
||||
}
|
||||
};
|
||||
|
||||
onClearUserError = () => {
|
||||
this.props.clearUserError();
|
||||
};
|
||||
|
||||
render() {
|
||||
const { ldapUser, userError, ldapError, ldapSyncInfo, ldapConnectionInfo, navModel } = this.props;
|
||||
const { isLoading } = this.state;
|
||||
|
||||
return (
|
||||
<Page navModel={navModel}>
|
||||
<Page.Contents isLoading={isLoading}>
|
||||
<>
|
||||
{ldapError && ldapError.title && (
|
||||
<div className="gf-form-group">
|
||||
<AlertBox title={ldapError.title} severity={AppNotificationSeverity.Error} body={ldapError.body} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LdapConnectionStatus ldapConnectionInfo={ldapConnectionInfo} />
|
||||
|
||||
{config.buildInfo.isEnterprise && ldapSyncInfo && <LdapSyncInfo ldapSyncInfo={ldapSyncInfo} />}
|
||||
|
||||
<h3 className="page-heading">User mapping</h3>
|
||||
<div className="gf-form-group">
|
||||
<form onSubmit={this.search} className="gf-form-inline">
|
||||
<FormField label="User name" labelWidth={8} inputWidth={30} type="text" id="username" name="username" />
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Test LDAP mapping
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{userError && userError.title && (
|
||||
<div className="gf-form-group">
|
||||
<AlertBox
|
||||
title={userError.title}
|
||||
severity={AppNotificationSeverity.Error}
|
||||
body={userError.body}
|
||||
onClose={this.onClearUserError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{ldapUser && <LdapUserInfo ldapUser={ldapUser} showAttributeMapping={true} />}
|
||||
</>
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: StoreState) => ({
|
||||
navModel: getNavModel(state.navIndex, 'ldap'),
|
||||
ldapConnectionInfo: state.ldap.connectionInfo,
|
||||
ldapUser: state.ldap.user,
|
||||
ldapSyncInfo: state.ldap.syncInfo,
|
||||
userError: state.ldap.userError,
|
||||
ldapError: state.ldap.ldapError,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = {
|
||||
loadLdapState,
|
||||
loadLdapSyncStatus,
|
||||
loadUserMapping,
|
||||
clearUserError,
|
||||
clearUserMappingInfo,
|
||||
};
|
||||
|
||||
export default hot(module)(
|
||||
connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(LdapPage)
|
||||
);
|
73
public/app/features/admin/ldap/LdapSyncInfo.tsx
Normal file
73
public/app/features/admin/ldap/LdapSyncInfo.tsx
Normal file
@ -0,0 +1,73 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { dateTime } from '@grafana/data';
|
||||
import { SyncInfo } from 'app/types';
|
||||
|
||||
interface Props {
|
||||
ldapSyncInfo: SyncInfo;
|
||||
}
|
||||
|
||||
interface State {
|
||||
isSyncing: boolean;
|
||||
}
|
||||
|
||||
const syncTimeFormat = 'dddd YYYY-MM-DD HH:mm zz';
|
||||
|
||||
export class LdapSyncInfo extends PureComponent<Props, State> {
|
||||
state = {
|
||||
isSyncing: false,
|
||||
};
|
||||
|
||||
handleSyncClick = () => {
|
||||
console.log('Bulk-sync now');
|
||||
this.setState({ isSyncing: !this.state.isSyncing });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { ldapSyncInfo } = this.props;
|
||||
const { isSyncing } = this.state;
|
||||
const nextSyncTime = dateTime(ldapSyncInfo.nextSync).format(syncTimeFormat);
|
||||
const prevSyncSuccessful = ldapSyncInfo && ldapSyncInfo.prevSync;
|
||||
const prevSyncTime = prevSyncSuccessful ? dateTime(ldapSyncInfo.prevSync.started).format(syncTimeFormat) : '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<h3 className="page-heading">
|
||||
LDAP Synchronisation
|
||||
<button className={`btn btn-secondary pull-right`} onClick={this.handleSyncClick} hidden={true}>
|
||||
<span className="btn-title">Bulk-sync now</span>
|
||||
{isSyncing && <i className="fa fa-spinner fa-fw fa-spin run-icon" />}
|
||||
</button>
|
||||
</h3>
|
||||
<div className="gf-form-group">
|
||||
<div className="gf-form">
|
||||
<table className="filter-table form-inline">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Active synchronisation</td>
|
||||
<td colSpan={2}>{ldapSyncInfo.enabled ? 'Enabled' : 'Disabled'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Scheduled</td>
|
||||
<td>{ldapSyncInfo.schedule}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Next scheduled synchronisation</td>
|
||||
<td>{nextSyncTime}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Last synchronisation</td>
|
||||
{prevSyncSuccessful && (
|
||||
<>
|
||||
<td>{prevSyncTime}</td>
|
||||
<td>Successful</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
56
public/app/features/admin/ldap/LdapUserGroups.tsx
Normal file
56
public/app/features/admin/ldap/LdapUserGroups.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import React, { FC } from 'react';
|
||||
import { Tooltip } from '@grafana/ui';
|
||||
import { LdapRole } from 'app/types';
|
||||
|
||||
interface Props {
|
||||
groups: LdapRole[];
|
||||
showAttributeMapping?: boolean;
|
||||
}
|
||||
|
||||
export const LdapUserGroups: FC<Props> = ({ groups, showAttributeMapping }) => {
|
||||
const items = showAttributeMapping ? groups : groups.filter(item => item.orgRole);
|
||||
const roleColumnClass = showAttributeMapping && 'width-14';
|
||||
|
||||
return (
|
||||
<div className="gf-form-group">
|
||||
<div className="gf-form">
|
||||
<table className="filter-table form-inline">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Organisation</th>
|
||||
<th>Role</th>
|
||||
{showAttributeMapping && <th colSpan={2}>LDAP Group</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((group, index) => {
|
||||
return (
|
||||
<tr key={`${group.orgId}-${index}`}>
|
||||
<td className="width-16">{group.orgName}</td>
|
||||
<td className={roleColumnClass}>{group.orgRole}</td>
|
||||
{showAttributeMapping && (
|
||||
<>
|
||||
<td>{group.groupDN}</td>
|
||||
<td>
|
||||
{!group.orgRole && (
|
||||
<span className="text-warning pull-right">
|
||||
No match
|
||||
<Tooltip placement="top" content="No matching groups found" theme={'info'}>
|
||||
<div className="gf-form-help-icon gf-form-help-icon--right-normal">
|
||||
<i className="fa fa-info-circle" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
26
public/app/features/admin/ldap/LdapUserInfo.tsx
Normal file
26
public/app/features/admin/ldap/LdapUserInfo.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import React, { FC } from 'react';
|
||||
import { LdapUserMappingInfo } from './LdapUserMappingInfo';
|
||||
import { LdapUserPermissions } from './LdapUserPermissions';
|
||||
import { LdapUserGroups } from './LdapUserGroups';
|
||||
import { LdapUserTeams } from './LdapUserTeams';
|
||||
import { LdapUser } from 'app/types';
|
||||
|
||||
interface Props {
|
||||
ldapUser: LdapUser;
|
||||
showAttributeMapping?: boolean;
|
||||
}
|
||||
|
||||
export const LdapUserInfo: FC<Props> = ({ ldapUser, showAttributeMapping }) => {
|
||||
return (
|
||||
<>
|
||||
<LdapUserMappingInfo info={ldapUser.info} showAttributeMapping={showAttributeMapping} />
|
||||
<LdapUserPermissions permissions={ldapUser.permissions} />
|
||||
{ldapUser.roles && ldapUser.roles.length > 0 && (
|
||||
<LdapUserGroups groups={ldapUser.roles} showAttributeMapping={showAttributeMapping} />
|
||||
)}
|
||||
{ldapUser.teams && ldapUser.teams.length > 0 && (
|
||||
<LdapUserTeams teams={ldapUser.teams} showAttributeMapping={showAttributeMapping} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
46
public/app/features/admin/ldap/LdapUserMappingInfo.tsx
Normal file
46
public/app/features/admin/ldap/LdapUserMappingInfo.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import React, { FC } from 'react';
|
||||
import { LdapUserInfo } from 'app/types';
|
||||
|
||||
interface Props {
|
||||
info: LdapUserInfo;
|
||||
showAttributeMapping?: boolean;
|
||||
}
|
||||
|
||||
export const LdapUserMappingInfo: FC<Props> = ({ info, showAttributeMapping }) => {
|
||||
return (
|
||||
<div className="gf-form-group">
|
||||
<div className="gf-form">
|
||||
<table className="filter-table form-inline">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colSpan={2}>User information</th>
|
||||
{showAttributeMapping && <th>LDAP attribute</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="width-16">First name</td>
|
||||
<td>{info.name.ldapValue}</td>
|
||||
{showAttributeMapping && <td>{info.name.cfgAttrValue}</td>}
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="width-16">Surname</td>
|
||||
<td>{info.surname.ldapValue}</td>
|
||||
{showAttributeMapping && <td>{info.surname.cfgAttrValue}</td>}
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="width-16">Username</td>
|
||||
<td>{info.login.ldapValue}</td>
|
||||
{showAttributeMapping && <td>{info.login.cfgAttrValue}</td>}
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="width-16">Email</td>
|
||||
<td>{info.email.ldapValue}</td>
|
||||
{showAttributeMapping && <td>{info.email.cfgAttrValue}</td>}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
159
public/app/features/admin/ldap/LdapUserPage.tsx
Normal file
159
public/app/features/admin/ldap/LdapUserPage.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
import { hot } from 'react-hot-loader';
|
||||
import { connect } from 'react-redux';
|
||||
import { NavModel } from '@grafana/data';
|
||||
import Page from 'app/core/components/Page/Page';
|
||||
import { AlertBox } from 'app/core/components/AlertBox/AlertBox';
|
||||
import { getNavModel } from 'app/core/selectors/navModel';
|
||||
import {
|
||||
AppNotificationSeverity,
|
||||
LdapError,
|
||||
LdapUser,
|
||||
StoreState,
|
||||
User,
|
||||
UserSession,
|
||||
SyncInfo,
|
||||
LdapUserSyncInfo,
|
||||
} from 'app/types';
|
||||
import {
|
||||
clearUserError,
|
||||
loadLdapUserInfo,
|
||||
revokeSession,
|
||||
revokeAllSessions,
|
||||
loadLdapSyncStatus,
|
||||
syncUser,
|
||||
} from '../state/actions';
|
||||
import { LdapUserInfo } from './LdapUserInfo';
|
||||
import { getRouteParamsId } from 'app/core/selectors/location';
|
||||
import { UserSessions } from '../UserSessions';
|
||||
import { UserInfo } from '../UserInfo';
|
||||
import { UserSyncInfo } from '../UserSyncInfo';
|
||||
|
||||
interface Props {
|
||||
navModel: NavModel;
|
||||
userId: number;
|
||||
user: User;
|
||||
sessions: UserSession[];
|
||||
ldapUser: LdapUser;
|
||||
userError?: LdapError;
|
||||
ldapSyncInfo?: SyncInfo;
|
||||
|
||||
loadLdapUserInfo: typeof loadLdapUserInfo;
|
||||
clearUserError: typeof clearUserError;
|
||||
loadLdapSyncStatus: typeof loadLdapSyncStatus;
|
||||
syncUser: typeof syncUser;
|
||||
revokeSession: typeof revokeSession;
|
||||
revokeAllSessions: typeof revokeAllSessions;
|
||||
}
|
||||
|
||||
interface State {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export class LdapUserPage extends PureComponent<Props, State> {
|
||||
state = {
|
||||
isLoading: true,
|
||||
};
|
||||
|
||||
async componentDidMount() {
|
||||
const { userId, loadLdapUserInfo, loadLdapSyncStatus } = this.props;
|
||||
try {
|
||||
await loadLdapUserInfo(userId);
|
||||
await loadLdapSyncStatus();
|
||||
} finally {
|
||||
this.setState({ isLoading: false });
|
||||
}
|
||||
}
|
||||
|
||||
onClearUserError = () => {
|
||||
this.props.clearUserError();
|
||||
};
|
||||
|
||||
onSyncUser = () => {
|
||||
const { syncUser, user } = this.props;
|
||||
if (syncUser && user) {
|
||||
syncUser(user.id);
|
||||
}
|
||||
};
|
||||
|
||||
onSessionRevoke = (tokenId: number) => {
|
||||
const { userId, revokeSession } = this.props;
|
||||
revokeSession(tokenId, userId);
|
||||
};
|
||||
|
||||
onAllSessionsRevoke = () => {
|
||||
const { userId, revokeAllSessions } = this.props;
|
||||
revokeAllSessions(userId);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { user, ldapUser, userError, navModel, sessions, ldapSyncInfo } = this.props;
|
||||
const { isLoading } = this.state;
|
||||
|
||||
const userSyncInfo: LdapUserSyncInfo = {};
|
||||
if (ldapSyncInfo) {
|
||||
userSyncInfo.nextSync = ldapSyncInfo.nextSync;
|
||||
}
|
||||
if (user) {
|
||||
userSyncInfo.prevSync = (user as any).updatedAt;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page navModel={navModel}>
|
||||
<Page.Contents isLoading={isLoading}>
|
||||
<div className="grafana-info-box">
|
||||
This user is synced via LDAP – all changes must be done in LDAP or mappings.
|
||||
</div>
|
||||
{userError && userError.title && (
|
||||
<div className="gf-form-group">
|
||||
<AlertBox
|
||||
title={userError.title}
|
||||
severity={AppNotificationSeverity.Error}
|
||||
body={userError.body}
|
||||
onClose={this.onClearUserError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ldapUser && <LdapUserInfo ldapUser={ldapUser} />}
|
||||
{!ldapUser && user && <UserInfo user={user} />}
|
||||
{userSyncInfo && <UserSyncInfo syncInfo={userSyncInfo} onSync={this.onSyncUser} />}
|
||||
|
||||
{sessions && (
|
||||
<UserSessions
|
||||
sessions={sessions}
|
||||
onSessionRevoke={this.onSessionRevoke}
|
||||
onAllSessionsRevoke={this.onAllSessionsRevoke}
|
||||
/>
|
||||
)}
|
||||
</Page.Contents>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: StoreState) => ({
|
||||
userId: getRouteParamsId(state.location),
|
||||
navModel: getNavModel(state.navIndex, 'global-users'),
|
||||
user: state.ldapUser.user,
|
||||
ldapUser: state.ldapUser.ldapUser,
|
||||
userError: state.ldapUser.userError,
|
||||
ldapSyncInfo: state.ldapUser.ldapSyncInfo,
|
||||
sessions: state.ldapUser.sessions,
|
||||
});
|
||||
|
||||
const mapDispatchToProps = {
|
||||
loadLdapUserInfo,
|
||||
loadLdapSyncStatus,
|
||||
syncUser,
|
||||
revokeSession,
|
||||
revokeAllSessions,
|
||||
clearUserError,
|
||||
};
|
||||
|
||||
export default hot(module)(
|
||||
connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(LdapUserPage)
|
||||
);
|
50
public/app/features/admin/ldap/LdapUserPermissions.tsx
Normal file
50
public/app/features/admin/ldap/LdapUserPermissions.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React, { FC } from 'react';
|
||||
import { LdapPermissions } from 'app/types';
|
||||
|
||||
interface Props {
|
||||
permissions: LdapPermissions;
|
||||
}
|
||||
|
||||
export const LdapUserPermissions: FC<Props> = ({ permissions }) => {
|
||||
return (
|
||||
<div className="gf-form-group">
|
||||
<div className="gf-form">
|
||||
<table className="filter-table form-inline">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colSpan={1}>Permissions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="width-16"> Grafana admin</td>
|
||||
<td>
|
||||
{permissions.isGrafanaAdmin ? (
|
||||
<>
|
||||
<i className="gicon gicon-shield" /> Yes
|
||||
</>
|
||||
) : (
|
||||
'No'
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="width-16">Status</td>
|
||||
<td>
|
||||
{permissions.isDisabled ? (
|
||||
<>
|
||||
<i className="fa fa-fw fa-times" /> Inactive
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<i className="fa fa-fw fa-check" /> Active
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
55
public/app/features/admin/ldap/LdapUserTeams.tsx
Normal file
55
public/app/features/admin/ldap/LdapUserTeams.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
import React, { FC } from 'react';
|
||||
import { css } from 'emotion';
|
||||
import { Tooltip } from '@grafana/ui';
|
||||
import { LdapTeam } from 'app/types';
|
||||
|
||||
interface Props {
|
||||
teams: LdapTeam[];
|
||||
showAttributeMapping?: boolean;
|
||||
}
|
||||
|
||||
export const LdapUserTeams: FC<Props> = ({ teams, showAttributeMapping }) => {
|
||||
const items = showAttributeMapping ? teams : teams.filter(item => item.teamName);
|
||||
const teamColumnClass = showAttributeMapping && 'width-14';
|
||||
const noMatchPlaceholderStyle = css`
|
||||
display: flex;
|
||||
`;
|
||||
|
||||
return (
|
||||
<div className="gf-form-group">
|
||||
<div className="gf-form">
|
||||
<table className="filter-table form-inline">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Organisation</th>
|
||||
<th>Team</th>
|
||||
{showAttributeMapping && <th>LDAP</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((team, index) => {
|
||||
return (
|
||||
<tr key={`${team.teamName}-${index}`}>
|
||||
<td className="width-16">
|
||||
{team.orgName || (
|
||||
<div className={`text-warning ${noMatchPlaceholderStyle}`}>
|
||||
No match
|
||||
<Tooltip placement="top" content="No matching teams found" theme={'info'}>
|
||||
<div className="gf-form-help-icon gf-form-help-icon--right-normal">
|
||||
<i className="fa fa-info-circle" />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className={teamColumnClass}>{team.teamName}</td>
|
||||
{showAttributeMapping && <td>{team.groupDN}</td>}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -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
public/app/features/admin/state/actions.ts
Normal file
137
public/app/features/admin/state/actions.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { actionCreatorFactory, noPayloadActionCreatorFactory } from 'app/core/redux';
|
||||
import config from 'app/core/config';
|
||||
import { ThunkResult, SyncInfo, LdapUser, LdapConnectionInfo, LdapError, UserSession, User } from 'app/types';
|
||||
import {
|
||||
getUserInfo,
|
||||
getLdapState,
|
||||
syncLdapUser,
|
||||
getUser,
|
||||
getUserSessions,
|
||||
revokeUserSession,
|
||||
revokeAllUserSessions,
|
||||
getLdapSyncStatus,
|
||||
} from './apis';
|
||||
|
||||
// Action types
|
||||
|
||||
export const ldapConnectionInfoLoadedAction = actionCreatorFactory<LdapConnectionInfo>(
|
||||
'ldap/CONNECTION_INFO_LOADED'
|
||||
).create();
|
||||
export const ldapSyncStatusLoadedAction = actionCreatorFactory<SyncInfo>('ldap/SYNC_STATUS_LOADED').create();
|
||||
export const userMappingInfoLoadedAction = actionCreatorFactory<LdapUser>('ldap/USER_INFO_LOADED').create();
|
||||
export const userMappingInfoFailedAction = actionCreatorFactory<LdapError>('ldap/USER_INFO_FAILED').create();
|
||||
export const clearUserMappingInfoAction = noPayloadActionCreatorFactory('ldap/CLEAR_USER_MAPPING_INFO').create();
|
||||
export const clearUserErrorAction = noPayloadActionCreatorFactory('ldap/CLEAR_USER_ERROR').create();
|
||||
export const ldapFailedAction = actionCreatorFactory<LdapError>('ldap/LDAP_FAILED').create();
|
||||
|
||||
export const userLoadedAction = actionCreatorFactory<User>('USER_LOADED').create();
|
||||
export const userSessionsLoadedAction = actionCreatorFactory<UserSession[]>('USER_SESSIONS_LOADED').create();
|
||||
export const userSyncFailedAction = noPayloadActionCreatorFactory('USER_SYNC_FAILED').create();
|
||||
export const revokeUserSessionAction = noPayloadActionCreatorFactory('REVOKE_USER_SESSION').create();
|
||||
export const revokeAllUserSessionsAction = noPayloadActionCreatorFactory('REVOKE_ALL_USER_SESSIONS').create();
|
||||
|
||||
// Actions
|
||||
|
||||
export function loadLdapState(): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
try {
|
||||
const connectionInfo = await getLdapState();
|
||||
dispatch(ldapConnectionInfoLoadedAction(connectionInfo));
|
||||
} catch (error) {
|
||||
const ldapError = {
|
||||
title: error.data.message,
|
||||
body: error.data.error,
|
||||
};
|
||||
dispatch(ldapFailedAction(ldapError));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function loadLdapSyncStatus(): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
if (config.buildInfo.isEnterprise) {
|
||||
// Available only in enterprise
|
||||
const syncStatus = await getLdapSyncStatus();
|
||||
dispatch(ldapSyncStatusLoadedAction(syncStatus));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function loadUserMapping(username: string): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
try {
|
||||
const userInfo = await getUserInfo(username);
|
||||
dispatch(userMappingInfoLoadedAction(userInfo));
|
||||
} catch (error) {
|
||||
const userError = {
|
||||
title: error.data.message,
|
||||
body: error.data.error,
|
||||
};
|
||||
dispatch(clearUserMappingInfoAction());
|
||||
dispatch(userMappingInfoFailedAction(userError));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function clearUserError(): ThunkResult<void> {
|
||||
return dispatch => {
|
||||
dispatch(clearUserErrorAction());
|
||||
};
|
||||
}
|
||||
|
||||
export function clearUserMappingInfo(): ThunkResult<void> {
|
||||
return dispatch => {
|
||||
dispatch(clearUserErrorAction());
|
||||
dispatch(clearUserMappingInfoAction());
|
||||
};
|
||||
}
|
||||
|
||||
export function syncUser(userId: number): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
try {
|
||||
await syncLdapUser(userId);
|
||||
dispatch(loadLdapUserInfo(userId));
|
||||
dispatch(loadLdapSyncStatus());
|
||||
} catch (error) {
|
||||
dispatch(userSyncFailedAction());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function loadLdapUserInfo(userId: number): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
try {
|
||||
const user = await getUser(userId);
|
||||
dispatch(userLoadedAction(user));
|
||||
dispatch(loadUserSessions(userId));
|
||||
dispatch(loadUserMapping(user.login));
|
||||
} catch (error) {
|
||||
const userError = {
|
||||
title: error.data.message,
|
||||
body: error.data.error,
|
||||
};
|
||||
dispatch(userMappingInfoFailedAction(userError));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function loadUserSessions(userId: number): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
const sessions = await getUserSessions(userId);
|
||||
dispatch(userSessionsLoadedAction(sessions));
|
||||
};
|
||||
}
|
||||
|
||||
export function revokeSession(tokenId: number, userId: number): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
await revokeUserSession(tokenId, userId);
|
||||
dispatch(loadUserSessions(userId));
|
||||
};
|
||||
}
|
||||
|
||||
export function revokeAllSessions(userId: number): ThunkResult<void> {
|
||||
return async dispatch => {
|
||||
await revokeAllUserSessions(userId);
|
||||
dispatch(loadUserSessions(userId));
|
||||
};
|
||||
}
|
@ -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
public/app/features/admin/state/reducers.test.ts
Normal file
210
public/app/features/admin/state/reducers.test.ts
Normal file
@ -0,0 +1,210 @@
|
||||
import { Reducer } from 'redux';
|
||||
import { reducerTester } from 'test/core/redux/reducerTester';
|
||||
import { ActionOf } from 'app/core/redux/actionCreatorFactory';
|
||||
import { ldapReducer, ldapUserReducer } from './reducers';
|
||||
import {
|
||||
ldapConnectionInfoLoadedAction,
|
||||
ldapSyncStatusLoadedAction,
|
||||
userMappingInfoLoadedAction,
|
||||
userMappingInfoFailedAction,
|
||||
ldapFailedAction,
|
||||
userLoadedAction,
|
||||
} from './actions';
|
||||
import { LdapState, LdapUserState, LdapUser, User } from 'app/types';
|
||||
|
||||
const makeInitialLdapState = (): LdapState => ({
|
||||
connectionInfo: [],
|
||||
syncInfo: null,
|
||||
user: null,
|
||||
ldapError: null,
|
||||
connectionError: null,
|
||||
userError: null,
|
||||
});
|
||||
|
||||
const makeInitialLdapUserState = (): LdapUserState => ({
|
||||
user: null,
|
||||
ldapUser: null,
|
||||
ldapSyncInfo: null,
|
||||
sessions: [],
|
||||
});
|
||||
|
||||
const getTestUserMapping = (): LdapUser => ({
|
||||
info: {
|
||||
email: { cfgAttrValue: 'mail', ldapValue: 'user@localhost' },
|
||||
name: { cfgAttrValue: 'givenName', ldapValue: 'User' },
|
||||
surname: { cfgAttrValue: 'sn', ldapValue: '' },
|
||||
login: { cfgAttrValue: 'cn', ldapValue: 'user' },
|
||||
},
|
||||
permissions: {
|
||||
isGrafanaAdmin: false,
|
||||
isDisabled: false,
|
||||
},
|
||||
roles: [],
|
||||
teams: [],
|
||||
});
|
||||
|
||||
const getTestUser = (): User => ({
|
||||
id: 1,
|
||||
email: 'user@localhost',
|
||||
login: 'user',
|
||||
name: 'User',
|
||||
avatarUrl: '',
|
||||
label: '',
|
||||
});
|
||||
|
||||
describe('LDAP page reducer', () => {
|
||||
describe('When page loaded', () => {
|
||||
describe('When connection info loaded', () => {
|
||||
it('should set connection info and clear error', () => {
|
||||
const initalState = {
|
||||
...makeInitialLdapState(),
|
||||
};
|
||||
|
||||
reducerTester()
|
||||
.givenReducer(ldapReducer as Reducer<LdapState, ActionOf<any>>, initalState)
|
||||
.whenActionIsDispatched(
|
||||
ldapConnectionInfoLoadedAction([
|
||||
{
|
||||
available: true,
|
||||
host: 'localhost',
|
||||
port: 389,
|
||||
error: null,
|
||||
},
|
||||
])
|
||||
)
|
||||
.thenStateShouldEqual({
|
||||
...makeInitialLdapState(),
|
||||
connectionInfo: [
|
||||
{
|
||||
available: true,
|
||||
host: 'localhost',
|
||||
port: 389,
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
ldapError: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('When connection failed', () => {
|
||||
it('should set ldap error', () => {
|
||||
const initalState = {
|
||||
...makeInitialLdapState(),
|
||||
};
|
||||
|
||||
reducerTester()
|
||||
.givenReducer(ldapReducer as Reducer<LdapState, ActionOf<any>>, initalState)
|
||||
.whenActionIsDispatched(
|
||||
ldapFailedAction({
|
||||
title: 'LDAP error',
|
||||
body: 'Failed to connect',
|
||||
})
|
||||
)
|
||||
.thenStateShouldEqual({
|
||||
...makeInitialLdapState(),
|
||||
ldapError: {
|
||||
title: 'LDAP error',
|
||||
body: 'Failed to connect',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('When LDAP sync status loaded', () => {
|
||||
it('should set sync info', () => {
|
||||
const initalState = {
|
||||
...makeInitialLdapState(),
|
||||
};
|
||||
|
||||
reducerTester()
|
||||
.givenReducer(ldapReducer as Reducer<LdapState, ActionOf<any>>, initalState)
|
||||
.whenActionIsDispatched(
|
||||
ldapSyncStatusLoadedAction({
|
||||
enabled: true,
|
||||
schedule: '0 0 * * * *',
|
||||
nextSync: '2019-01-01T12:00:00Z',
|
||||
})
|
||||
)
|
||||
.thenStateShouldEqual({
|
||||
...makeInitialLdapState(),
|
||||
syncInfo: {
|
||||
enabled: true,
|
||||
schedule: '0 0 * * * *',
|
||||
nextSync: '2019-01-01T12:00:00Z',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('When user mapping info loaded', () => {
|
||||
it('should set sync info and clear user error', () => {
|
||||
const initalState = {
|
||||
...makeInitialLdapState(),
|
||||
userError: {
|
||||
title: 'User not found',
|
||||
body: 'Cannot find user',
|
||||
},
|
||||
};
|
||||
|
||||
reducerTester()
|
||||
.givenReducer(ldapReducer as Reducer<LdapState, ActionOf<any>>, initalState)
|
||||
.whenActionIsDispatched(userMappingInfoLoadedAction(getTestUserMapping()))
|
||||
.thenStateShouldEqual({
|
||||
...makeInitialLdapState(),
|
||||
user: getTestUserMapping(),
|
||||
userError: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('When user not found', () => {
|
||||
it('should set user error and clear user info', () => {
|
||||
const initalState = {
|
||||
...makeInitialLdapState(),
|
||||
user: getTestUserMapping(),
|
||||
};
|
||||
|
||||
reducerTester()
|
||||
.givenReducer(ldapReducer as Reducer<LdapState, ActionOf<any>>, initalState)
|
||||
.whenActionIsDispatched(
|
||||
userMappingInfoFailedAction({
|
||||
title: 'User not found',
|
||||
body: 'Cannot find user',
|
||||
})
|
||||
)
|
||||
.thenStateShouldEqual({
|
||||
...makeInitialLdapState(),
|
||||
user: null,
|
||||
userError: {
|
||||
title: 'User not found',
|
||||
body: 'Cannot find user',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edit LDAP user page reducer', () => {
|
||||
describe('When user loaded', () => {
|
||||
it('should set user and clear user error', () => {
|
||||
const initalState = {
|
||||
...makeInitialLdapUserState(),
|
||||
userError: {
|
||||
title: 'User not found',
|
||||
body: 'Cannot find user',
|
||||
},
|
||||
};
|
||||
|
||||
reducerTester()
|
||||
.givenReducer(ldapUserReducer as Reducer<LdapUserState, ActionOf<any>>, initalState)
|
||||
.whenActionIsDispatched(userLoadedAction(getTestUser()))
|
||||
.thenStateShouldEqual({
|
||||
...makeInitialLdapUserState(),
|
||||
user: getTestUser(),
|
||||
userError: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
135
public/app/features/admin/state/reducers.ts
Normal file
135
public/app/features/admin/state/reducers.ts
Normal file
@ -0,0 +1,135 @@
|
||||
import { reducerFactory } from 'app/core/redux';
|
||||
import { LdapState, LdapUserState } from 'app/types';
|
||||
import {
|
||||
ldapConnectionInfoLoadedAction,
|
||||
ldapFailedAction,
|
||||
userMappingInfoLoadedAction,
|
||||
userMappingInfoFailedAction,
|
||||
clearUserErrorAction,
|
||||
userLoadedAction,
|
||||
userSessionsLoadedAction,
|
||||
ldapSyncStatusLoadedAction,
|
||||
clearUserMappingInfoAction,
|
||||
} from './actions';
|
||||
|
||||
const initialLdapState: LdapState = {
|
||||
connectionInfo: [],
|
||||
syncInfo: null,
|
||||
user: null,
|
||||
connectionError: null,
|
||||
userError: null,
|
||||
};
|
||||
|
||||
const initialLdapUserState: LdapUserState = {
|
||||
user: null,
|
||||
ldapUser: null,
|
||||
ldapSyncInfo: null,
|
||||
sessions: [],
|
||||
};
|
||||
|
||||
export const ldapReducer = reducerFactory(initialLdapState)
|
||||
.addMapper({
|
||||
filter: ldapConnectionInfoLoadedAction,
|
||||
mapper: (state, action) => ({
|
||||
...state,
|
||||
ldapError: null,
|
||||
connectionInfo: action.payload,
|
||||
}),
|
||||
})
|
||||
.addMapper({
|
||||
filter: ldapFailedAction,
|
||||
mapper: (state, action) => ({
|
||||
...state,
|
||||
ldapError: action.payload,
|
||||
}),
|
||||
})
|
||||
.addMapper({
|
||||
filter: ldapSyncStatusLoadedAction,
|
||||
mapper: (state, action) => ({
|
||||
...state,
|
||||
syncInfo: action.payload,
|
||||
}),
|
||||
})
|
||||
.addMapper({
|
||||
filter: userMappingInfoLoadedAction,
|
||||
mapper: (state, action) => ({
|
||||
...state,
|
||||
user: action.payload,
|
||||
userError: null,
|
||||
}),
|
||||
})
|
||||
.addMapper({
|
||||
filter: userMappingInfoFailedAction,
|
||||
mapper: (state, action) => ({
|
||||
...state,
|
||||
user: null,
|
||||
userError: action.payload,
|
||||
}),
|
||||
})
|
||||
.addMapper({
|
||||
filter: clearUserMappingInfoAction,
|
||||
mapper: (state, action) => ({
|
||||
...state,
|
||||
user: null,
|
||||
}),
|
||||
})
|
||||
.addMapper({
|
||||
filter: clearUserErrorAction,
|
||||
mapper: state => ({
|
||||
...state,
|
||||
userError: null,
|
||||
}),
|
||||
})
|
||||
.create();
|
||||
|
||||
export const ldapUserReducer = reducerFactory(initialLdapUserState)
|
||||
.addMapper({
|
||||
filter: userMappingInfoLoadedAction,
|
||||
mapper: (state, action) => ({
|
||||
...state,
|
||||
ldapUser: action.payload,
|
||||
}),
|
||||
})
|
||||
.addMapper({
|
||||
filter: userMappingInfoFailedAction,
|
||||
mapper: (state, action) => ({
|
||||
...state,
|
||||
ldapUser: null,
|
||||
userError: action.payload,
|
||||
}),
|
||||
})
|
||||
.addMapper({
|
||||
filter: clearUserErrorAction,
|
||||
mapper: state => ({
|
||||
...state,
|
||||
userError: null,
|
||||
}),
|
||||
})
|
||||
.addMapper({
|
||||
filter: ldapSyncStatusLoadedAction,
|
||||
mapper: (state, action) => ({
|
||||
...state,
|
||||
ldapSyncInfo: action.payload,
|
||||
}),
|
||||
})
|
||||
.addMapper({
|
||||
filter: userLoadedAction,
|
||||
mapper: (state, action) => ({
|
||||
...state,
|
||||
user: action.payload,
|
||||
userError: null,
|
||||
}),
|
||||
})
|
||||
.addMapper({
|
||||
filter: userSessionsLoadedAction,
|
||||
mapper: (state, action) => ({
|
||||
...state,
|
||||
sessions: action.payload,
|
||||
}),
|
||||
})
|
||||
.create();
|
||||
|
||||
export default {
|
||||
ldap: ldapReducer,
|
||||
ldapUser: ldapUserReducer,
|
||||
};
|
@ -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/>',
|
||||
|
@ -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) {
|
||||
|
@ -13,3 +13,4 @@ export * from './appNotifications';
|
||||
export * from './search';
|
||||
export * from './explore';
|
||||
export * from './store';
|
||||
export * from './ldap';
|
||||
|
95
public/app/types/ldap.ts
Normal file
95
public/app/types/ldap.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { User, UserSession } from 'app/types';
|
||||
|
||||
interface LdapMapping {
|
||||
cfgAttrValue: string;
|
||||
ldapValue: string;
|
||||
}
|
||||
|
||||
export interface LdapError {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface SyncInfo {
|
||||
enabled: boolean;
|
||||
schedule: string;
|
||||
nextSync: string;
|
||||
prevSync?: SyncResult;
|
||||
}
|
||||
|
||||
export interface LdapUserSyncInfo {
|
||||
nextSync?: string;
|
||||
prevSync?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
started: string;
|
||||
elapsed: string;
|
||||
UpdatedUserIds: number[];
|
||||
MissingUserIds: number[];
|
||||
FailedUsers?: FailedUser[];
|
||||
}
|
||||
|
||||
export interface FailedUser {
|
||||
Login: string;
|
||||
Error: string;
|
||||
}
|
||||
|
||||
export interface LdapRole {
|
||||
orgId: number;
|
||||
orgName: string;
|
||||
orgRole: string;
|
||||
groupDN: string;
|
||||
}
|
||||
|
||||
export interface LdapTeam {
|
||||
orgName: string;
|
||||
teamName: string;
|
||||
groupDN: string;
|
||||
}
|
||||
|
||||
export interface LdapUserInfo {
|
||||
name: LdapMapping;
|
||||
surname: LdapMapping;
|
||||
email: LdapMapping;
|
||||
login: LdapMapping;
|
||||
}
|
||||
|
||||
export interface LdapPermissions {
|
||||
isGrafanaAdmin: boolean;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
export interface LdapUser {
|
||||
info: LdapUserInfo;
|
||||
permissions: LdapPermissions;
|
||||
roles: LdapRole[];
|
||||
teams: LdapTeam[];
|
||||
}
|
||||
|
||||
export interface LdapServerInfo {
|
||||
available: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type LdapConnectionInfo = LdapServerInfo[];
|
||||
|
||||
export interface LdapState {
|
||||
connectionInfo: LdapConnectionInfo;
|
||||
user?: LdapUser;
|
||||
syncInfo?: SyncInfo;
|
||||
connectionError?: LdapError;
|
||||
userError?: LdapError;
|
||||
ldapError?: LdapError;
|
||||
}
|
||||
|
||||
export interface LdapUserState {
|
||||
user?: User;
|
||||
ldapUser?: LdapUser;
|
||||
ldapSyncInfo?: SyncInfo;
|
||||
sessions?: UserSession[];
|
||||
userError?: LdapError;
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
/*
|
||||
|
@ -79,3 +79,7 @@
|
||||
.alert-body {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.alert-icon-on-top {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user