Commit 3c8820ab by Peter Holmberg

invites table

parent 13666c84
......@@ -4,19 +4,14 @@ import LayoutSelector, { LayoutMode } from '../LayoutSelector/LayoutSelector';
export interface Props {
searchQuery: string;
layoutMode?: LayoutMode;
showLayoutMode: boolean;
setLayoutMode?: (mode: LayoutMode) => {};
setSearchQuery: (value: string) => {};
linkButton: { href: string; title: string };
}
export default class OrgActionBar extends PureComponent<Props> {
static defaultProps = {
showLayoutMode: true,
};
render() {
const { searchQuery, layoutMode, setLayoutMode, linkButton, setSearchQuery, showLayoutMode } = this.props;
const { searchQuery, layoutMode, setLayoutMode, linkButton, setSearchQuery } = this.props;
return (
<div className="page-action-bar">
......@@ -31,9 +26,7 @@ export default class OrgActionBar extends PureComponent<Props> {
/>
<i className="gf-form-input-icon fa fa-search" />
</label>
{showLayoutMode && (
<LayoutSelector mode={layoutMode} onLayoutModeChanged={(mode: LayoutMode) => setLayoutMode(mode)} />
)}
<LayoutSelector mode={layoutMode} onLayoutModeChanged={(mode: LayoutMode) => setLayoutMode(mode)} />
</div>
<div className="page-action-bar__spacer" />
<a className="btn btn-success" href={linkButton.href} target="_blank">
......
import React, { createRef, PureComponent } from 'react';
import { Invitee } from 'app/types';
export interface Props {
invitees: Invitee[];
revokeInvite: (code: string) => void;
}
export default class InviteesTable extends PureComponent<Props> {
private copyRef = createRef<HTMLTextAreaElement>();
copyToClipboard = () => {
const node = this.copyRef.current;
if (node) {
node.select();
document.execCommand('copy');
}
};
render() {
const { invitees, revokeInvite } = this.props;
return (
<table className="filter-table form-inline">
<thead>
<tr>
<th>Email</th>
<th>Name</th>
<th />
<th style={{ width: '34px' }} />
</tr>
</thead>
<tbody>
{invitees.map((invitee, index) => {
return (
<tr key={`${invitee.id}-${index}`}>
<td>{invitee.email}</td>
<td>{invitee.name}</td>
<td className="text-right">
<button className="btn btn-inverse btn-mini" onClick={this.copyToClipboard}>
<textarea readOnly={true} value={invitee.url} style={{ display: 'none' }} ref={this.copyRef} />
<i className="fa fa-clipboard" /> Copy Invite
</button>
&nbsp;
</td>
<td>
<button className="btn btn-danger btn-mini" onClick={() => revokeInvite(invitee.code)}>
<i className="fa fa-remove" />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
);
}
}
import React, { PureComponent } from 'react';
import { connect } from 'react-redux';
import { setUsersSearchQuery } from './state/actions';
import { getInviteesCount, getUsersSearchQuery } from './state/selectors';
interface Props {
searchQuery: string;
setUsersSearchQuery: typeof setUsersSearchQuery;
showInvites: () => void;
pendingInvitesCount: number;
canInvite: boolean;
externalUserMngLinkUrl: string;
externalUserMngLinkName: string;
}
export class UsersActionBar extends PureComponent<Props> {
render() {
const {
canInvite,
externalUserMngLinkName,
externalUserMngLinkUrl,
searchQuery,
pendingInvitesCount,
setUsersSearchQuery,
showInvites,
} = this.props;
return (
<div className="page-action-bar">
<div className="gf-form gf-form--grow">
<label className="gf-form--has-input-icon">
<input
type="text"
className="gf-form-input width-20"
value={searchQuery}
onChange={event => setUsersSearchQuery(event.target.value)}
placeholder="Filter by name or type"
/>
<i className="gf-form-input-icon fa fa-search" />
</label>
<div className="page-action-bar__spacer" />
{pendingInvitesCount > 0 && (
<button className="btn btn-inverse" onClick={showInvites}>
Pending Invites ({pendingInvitesCount})
</button>
)}
{canInvite && (
<a className="btn btn-success" href="org/users/invite">
<i className="fa fa-plus" />
<span>Invite</span>
</a>
)}
{externalUserMngLinkUrl && (
<a className="btn btn-success" href={externalUserMngLinkUrl} target="_blank">
<i className="fa fa-external-link-square" />
{externalUserMngLinkName}
</a>
)}
</div>
</div>
);
}
}
function mapStateToProps(state) {
return {
searchQuery: getUsersSearchQuery(state.users),
pendingInvitesCount: getInviteesCount(state.users),
externalUserMngLinkName: state.users.externalUserMngLinkName,
externalUserMngLinkUrl: state.users.externalUserMngLinkUrl,
canInvite: state.users.canInvite,
};
}
const mapDispatchToProps = {
setUsersSearchQuery,
};
export default connect(mapStateToProps, mapDispatchToProps)(UsersActionBar);
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import OrgActionBar from 'app/core/components/OrgActionBar/OrgActionBar';
import PageHeader from 'app/core/components/PageHeader/PageHeader';
import UsersActionBar from './UsersActionBar';
import UsersTable from 'app/features/users/UsersTable';
import { NavModel, User } from 'app/types';
import InviteesTable from './InviteesTable';
import { Invitee, NavModel, User } from 'app/types';
import appEvents from 'app/core/app_events';
import { loadUsers, setUsersSearchQuery, updateUser, removeUser } from './state/actions';
import { loadUsers, loadInvitees, revokeInvite, setUsersSearchQuery, updateUser, removeUser } from './state/actions';
import { getNavModel } from '../../core/selectors/navModel';
import { getUsers, getUsersSearchQuery } from './state/selectors';
import { getInvitees, getUsers, getUsersSearchQuery } from './state/selectors';
export interface Props {
navModel: NavModel;
invitees: Invitee[];
users: User[];
searchQuery: string;
externalUserMngInfo: string;
loadUsers: typeof loadUsers;
loadInvitees: typeof loadInvitees;
setUsersSearchQuery: typeof setUsersSearchQuery;
updateUser: typeof updateUser;
removeUser: typeof removeUser;
revokeInvite: typeof revokeInvite;
}
export class UsersListPage extends PureComponent<Props> {
export interface State {
showInvites: boolean;
}
export class UsersListPage extends PureComponent<Props, State> {
state = {
showInvites: false,
};
componentDidMount() {
this.fetchUsers();
this.fetchInvitees();
}
async fetchUsers() {
return await this.props.loadUsers();
}
async fetchInvitees() {
return await this.props.loadInvitees();
}
onRoleChange = (role, user) => {
const updatedUser = { ...user, role: role };
......@@ -47,29 +65,38 @@ export class UsersListPage extends PureComponent<Props> {
});
};
render() {
const { navModel, searchQuery, setUsersSearchQuery, users } = this.props;
onRevokeInvite = code => {
this.props.revokeInvite(code);
};
const linkButton = {
href: '/org/users/add',
title: 'Add user',
};
showInvites = () => {
this.setState(prevState => ({
showInvites: !prevState.showInvites,
}));
};
render() {
const { externalUserMngInfo, invitees, navModel, users } = this.props;
return (
<div>
<PageHeader model={navModel} />
<div className="page-container page-body">
<OrgActionBar
searchQuery={searchQuery}
showLayoutMode={false}
setSearchQuery={setUsersSearchQuery}
linkButton={linkButton}
/>
<UsersTable
users={users}
onRoleChange={(role, user) => this.onRoleChange(role, user)}
onRemoveUser={user => this.onRemoveUser(user)}
/>
<UsersActionBar showInvites={this.showInvites} />
{externalUserMngInfo && (
<div className="grafana-info-box">
<span>{externalUserMngInfo}</span>
</div>
)}
{this.state.showInvites ? (
<InviteesTable invitees={invitees} revokeInvite={code => this.onRevokeInvite(code)} />
) : (
<UsersTable
users={users}
onRoleChange={(role, user) => this.onRoleChange(role, user)}
onRemoveUser={user => this.onRemoveUser(user)}
/>
)}
</div>
</div>
);
......@@ -81,14 +108,18 @@ function mapStateToProps(state) {
navModel: getNavModel(state.navIndex, 'users'),
users: getUsers(state.users),
searchQuery: getUsersSearchQuery(state.users),
invitees: getInvitees(state.users),
externalUserMngInfo: state.users.externalUserMngInfo,
};
}
const mapDispatchToProps = {
loadUsers,
loadInvitees,
setUsersSearchQuery,
updateUser,
removeUser,
revokeInvite,
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(UsersListPage));
......@@ -11,58 +11,56 @@ const UsersTable: SFC<Props> = props => {
const { users, onRoleChange, onRemoveUser } = props;
return (
<div>
<table className="filter-table form-inline">
<thead>
<tr>
<th />
<th>Login</th>
<th>Email</th>
<th>Seen</th>
<th>Role</th>
<th style={{ width: '34px' }} />
</tr>
</thead>
<tbody>
{users.map((user, index) => {
return (
<tr key={`${user.userId}-${index}`}>
<td className="width-4 text-center">
<img className="filter-table__avatar" src={user.avatarUrl} />
</td>
<td>{user.login}</td>
<td>
<span className="ellipsis">{user.email}</span>
</td>
<td>{user.lastSeenAtAge}</td>
<td>
<div className="gf-form-select-wrapper width-12">
<select
value={user.role}
className="gf-form-input"
onChange={event => onRoleChange(event.target.value, user)}
>
{['Viewer', 'Editor', 'Admin'].map((option, index) => {
return (
<option value={option} key={`${option}-${index}`}>
{option}
</option>
);
})}
</select>
</div>
</td>
<td>
<div onClick={() => onRemoveUser(user)} className="btn btn-danger btn-mini">
<i className="fa fa-remove" />
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<table className="filter-table form-inline">
<thead>
<tr>
<th />
<th>Login</th>
<th>Email</th>
<th>Seen</th>
<th>Role</th>
<th style={{ width: '34px' }} />
</tr>
</thead>
<tbody>
{users.map((user, index) => {
return (
<tr key={`${user.userId}-${index}`}>
<td className="width-4 text-center">
<img className="filter-table__avatar" src={user.avatarUrl} />
</td>
<td>{user.login}</td>
<td>
<span className="ellipsis">{user.email}</span>
</td>
<td>{user.lastSeenAtAge}</td>
<td>
<div className="gf-form-select-wrapper width-12">
<select
value={user.role}
className="gf-form-input"
onChange={event => onRoleChange(event.target.value, user)}
>
{['Viewer', 'Editor', 'Admin'].map((option, index) => {
return (
<option value={option} key={`${option}-${index}`}>
{option}
</option>
);
})}
</select>
</div>
</td>
<td>
<div onClick={() => onRemoveUser(user)} className="btn btn-danger btn-mini">
<i className="fa fa-remove" />
</div>
</td>
</tr>
);
})}
</tbody>
</table>
);
};
......
import { ThunkAction } from 'redux-thunk';
import { StoreState } from '../../../types';
import { getBackendSrv } from '../../../core/services/backend_srv';
import { User } from 'app/types';
import { Invitee, User } from 'app/types';
export enum ActionTypes {
LoadUsers = 'LOAD_USERS',
LoadInvitees = 'LOAD_INVITEES',
SetUsersSearchQuery = 'SET_USERS_SEARCH_QUERY',
}
......@@ -13,6 +14,11 @@ export interface LoadUsersAction {
payload: User[];
}
export interface LoadInviteesAction {
type: ActionTypes.LoadInvitees;
payload: Invitee[];
}
export interface SetUsersSearchQueryAction {
type: ActionTypes.SetUsersSearchQuery;
payload: string;
......@@ -23,12 +29,17 @@ const usersLoaded = (users: User[]): LoadUsersAction => ({
payload: users,
});
const inviteesLoaded = (invitees: Invitee[]): LoadInviteesAction => ({
type: ActionTypes.LoadInvitees,
payload: invitees,
});
export const setUsersSearchQuery = (query: string): SetUsersSearchQueryAction => ({
type: ActionTypes.SetUsersSearchQuery,
payload: query,
});
export type Action = LoadUsersAction | SetUsersSearchQueryAction;
export type Action = LoadUsersAction | SetUsersSearchQueryAction | LoadInviteesAction;
type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>;
......@@ -39,6 +50,13 @@ export function loadUsers(): ThunkResult<void> {
};
}
export function loadInvitees(): ThunkResult<void> {
return async dispatch => {
const invitees = await getBackendSrv().get('/api/org/invites');
dispatch(inviteesLoaded(invitees));
};
}
export function updateUser(user: User): ThunkResult<void> {
return async dispatch => {
await getBackendSrv().patch(`/api/org/users/${user.userId}`, user);
......@@ -52,3 +70,10 @@ export function removeUser(userId: number): ThunkResult<void> {
dispatch(loadUsers());
};
}
export function revokeInvite(code: string): ThunkResult<void> {
return async dispatch => {
await getBackendSrv().patch(`/api/org/invites/${code}/revoke`, {});
dispatch(loadInvitees());
};
}
import { User, UsersState } from 'app/types';
import { Invitee, User, UsersState } from 'app/types';
import { Action, ActionTypes } from './actions';
import config from '../../../core/config';
export const initialState: UsersState = { users: [] as User[], searchQuery: '' };
export const initialState: UsersState = {
invitees: [] as Invitee[],
users: [] as User[],
searchQuery: '',
canInvite: !config.disableLoginForm && !config.externalUserMngLinkName,
externalUserMngInfo: config.externalUserMngInfo,
externalUserMngLinkName: config.externalUserMngLinkName,
externalUserMngLinkUrl: config.externalUserMngLinkUrl,
};
export const usersReducer = (state = initialState, action: Action): UsersState => {
switch (action.type) {
case ActionTypes.LoadUsers:
return { ...state, users: action.payload };
case ActionTypes.LoadInvitees:
return { ...state, invitees: action.payload };
case ActionTypes.SetUsersSearchQuery:
return { ...state, searchQuery: action.payload };
}
......
......@@ -6,4 +6,13 @@ export const getUsers = state => {
});
};
export const getInvitees = state => {
const regex = new RegExp(state.searchQuery, 'i');
return state.invitees.filter(invitee => {
return regex.test(invitee.name) || regex.test(invitee.email);
});
};
export const getInviteesCount = state => state.invitees.length;
export const getUsersSearchQuery = state => state.searchQuery;
......@@ -7,7 +7,7 @@ import { DashboardState } from './dashboard';
import { DashboardAcl, OrgRole, PermissionLevel } from './acl';
import { DataSource, DataSourcesState } from './datasources';
import { PluginMeta, Plugin, PluginsState } from './plugins';
import { User, UsersState } from './users';
import { Invitee, User, UsersState } from './users';
export {
Team,
......@@ -37,6 +37,7 @@ export {
Plugin,
PluginsState,
DataSourcesState,
Invitee,
User,
UsersState,
};
......
export interface Invitee {
code: string;
createdOn: string;
email: string;
emailSent: boolean;
emailSentOn: string;
id: number;
invitedByEmail: string;
invitedByLogin: string;
invitedByName: string;
name: string;
orgId: number;
role: string;
status: string;
url: string;
}
export interface User {
avatarUrl: string;
email: string;
......@@ -11,5 +28,10 @@ export interface User {
export interface UsersState {
users: User[];
invitees: Invitee[];
searchQuery: string;
canInvite: boolean;
externalUserMngLinkUrl: string;
externalUserMngLinkName: string;
externalUserMngInfo: string;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment