From 52000ee9de9e554e944eccb45e231c1410527c4c Mon Sep 17 00:00:00 2001 From: Allen Gilliland <agilliland@gmail.com> Date: Tue, 29 Sep 2015 18:26:50 -0700 Subject: [PATCH] initial work on new People admin section. still a WIP but we now have everything converted over react/redux and most of the core functionality is in place. --- .../app/admin/people/actions.js | 126 ++++++++++++++ .../people/components/AdminPeople.react.js | 156 ++++++++++++++++++ .../people/components/EditUserForm.react.js | 115 +++++++++++++ .../components/UserActionsSelect.react.js | 76 +++++++++ .../people/components/UserRoleSelect.react.js | 76 +++++++++ .../people/containers/AdminPeopleApp.react.js | 14 ++ .../app/admin/people/partials/people.html | 32 ---- .../app/admin/people/partials/people_add.html | 37 ----- .../app/admin/people/people.controllers.js | 110 +++--------- .../app/admin/people/people.module.js | 7 +- .../app/admin/people/reducers.js | 36 ++++ .../app/admin/people/selectors.js | 18 ++ .../app/components/UserAvatar.react.js | 9 +- resources/frontend_client/app/css/admin.css | 37 +++++ resources/frontend_client/app/icon_paths.js | 1 + resources/frontend_client/app/lib/core.js | 10 ++ 16 files changed, 696 insertions(+), 164 deletions(-) create mode 100644 resources/frontend_client/app/admin/people/actions.js create mode 100644 resources/frontend_client/app/admin/people/components/AdminPeople.react.js create mode 100644 resources/frontend_client/app/admin/people/components/EditUserForm.react.js create mode 100644 resources/frontend_client/app/admin/people/components/UserActionsSelect.react.js create mode 100644 resources/frontend_client/app/admin/people/components/UserRoleSelect.react.js create mode 100644 resources/frontend_client/app/admin/people/containers/AdminPeopleApp.react.js delete mode 100644 resources/frontend_client/app/admin/people/partials/people.html delete mode 100644 resources/frontend_client/app/admin/people/partials/people_add.html create mode 100644 resources/frontend_client/app/admin/people/reducers.js create mode 100644 resources/frontend_client/app/admin/people/selectors.js diff --git a/resources/frontend_client/app/admin/people/actions.js b/resources/frontend_client/app/admin/people/actions.js new file mode 100644 index 00000000000..376d88978e3 --- /dev/null +++ b/resources/frontend_client/app/admin/people/actions.js @@ -0,0 +1,126 @@ +"use strict"; + +import _ from "underscore"; +import { createAction } from "redux-actions"; +import moment from "moment"; +import { normalize, Schema, arrayOf } from "normalizr"; + + +// HACK: just use our Angular resources for now +function AngularResourceProxy(serviceName, methods) { + methods.forEach((methodName) => { + this[methodName] = function(...args) { + let service = angular.element(document.querySelector("body")).injector().get(serviceName); + return service[methodName](...args).$promise; + } + }); +} + +// similar to createAction but accepts a (redux-thunk style) thunk and dispatches based on whether +// the promise returned from the thunk resolves or rejects, similar to redux-promise +function createThunkAction(actionType, actionThunkCreator) { + return function(...actionArgs) { + var thunk = actionThunkCreator(...actionArgs); + return async function(dispatch, getState) { + try { + let payload = await thunk(dispatch, getState); + dispatch({ type: actionType, payload }); + } catch (error) { + dispatch({ type: actionType, payload: error, error: true }); + throw error; + } + } + } +} + +const user = new Schema('user'); + + +// resource wrappers +const UserApi = new AngularResourceProxy("User", ["list", "update", "create", "delete"]); + + +// action constants +export const CREATE_USER = 'CREATE_USER'; +export const DELETE_USER = 'DELETE_USER'; +export const FETCH_USERS = 'FETCH_USERS'; +export const GRANT_ADMIN = 'GRANT_ADMIN'; +export const REVOKE_ADMIN = 'REVOKE_ADMIN'; +export const SHOW_ADD_PERSON = 'SHOW_ADD_PERSON'; +export const SHOW_EDIT_DETAILS = 'SHOW_EDIT_DETAILS'; +export const UPDATE_USER = 'UPDATE_USER'; + + +// action creators + +export const showAddPersonModal = createAction(SHOW_ADD_PERSON); +export const showEditDetailsModal = createAction(SHOW_EDIT_DETAILS); + +export const createUser = createThunkAction(CREATE_USER, function(user) { + return async function(dispatch, getState) { + // apply any user defaults here + user.is_superuser = false; + + let newUser = await UserApi.create(user); + newUser.last_login = (newUser.last_login) ? moment(newUser.last_login) : null; + + return newUser; + }; +}); + +export const deleteUser = createThunkAction(DELETE_USER, function(user) { + return async function(dispatch, getState) { + let resp = await UserApi.delete({ + userId: user.id + }); + return user; + }; +}); + +export const fetchUsers = createThunkAction(FETCH_USERS, function() { + return async function(dispatch, getState) { + let users = await UserApi.list(); + + for (var u of users) { + u.last_login = (u.last_login) ? moment(u.last_login) : null; + } + + return normalize(users, arrayOf(user)); + }; +}); + +export const grantAdmin = createThunkAction(GRANT_ADMIN, function(user) { + return async function(dispatch, getState) { + // give this user admin perms + user.is_superuser = true; + + // do the update + let updatedUser = await UserApi.update(user); + updatedUser.last_login = (updatedUser.last_login) ? moment(updatedUser.last_login) : null; + + return updatedUser; + }; +}); + +export const revokeAdmin = createThunkAction(REVOKE_ADMIN, function(user) { + return async function(dispatch, getState) { + // remove user admin perms + user.is_superuser = false; + + // do the update + let updatedUser = await UserApi.update(user); + updatedUser.last_login = (updatedUser.last_login) ? moment(updatedUser.last_login) : null; + + return updatedUser; + }; +}); + +export const updateUser = createThunkAction(UPDATE_USER, function(user) { + return async function(dispatch, getState) { + let updatedUser = await UserApi.update(user); + + updatedUser.last_login = (updatedUser.last_login) ? moment(updatedUser.last_login) : null; + + return updatedUser; + }; +}); diff --git a/resources/frontend_client/app/admin/people/components/AdminPeople.react.js b/resources/frontend_client/app/admin/people/components/AdminPeople.react.js new file mode 100644 index 00000000000..46380360670 --- /dev/null +++ b/resources/frontend_client/app/admin/people/components/AdminPeople.react.js @@ -0,0 +1,156 @@ +"use strict"; + +import React, { Component, PropTypes } from "react"; +import _ from "underscore"; + +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper.react"; +import Modal from "metabase/components/Modal.react"; +import ModalContent from "metabase/components/ModalContent.react"; +import UserAvatar from "metabase/components/UserAvatar.react"; + +import EditUserForm from "./EditUserForm.react"; +import UserActionsSelect from "./UserActionsSelect.react"; +import UserRoleSelect from "./UserRoleSelect.react"; +import { createUser, + fetchUsers, + grantAdmin, + revokeAdmin, + showAddPersonModal, + showEditDetailsModal, + updateUser } from "../actions"; + + +export default class AdminPeople extends Component { + + constructor(props) { + super(props); + + this.state = { error: null }; + } + + async componentDidMount() { + try { + await this.props.dispatch(fetchUsers()); + } catch (error) { + this.setState({ error }); + } + } + + onRoleChange(user, roleDef) { + if (roleDef.id === "user" && user.is_superuser) { + this.props.dispatch(revokeAdmin(user)); + } else if (roleDef.id === "admin" && !user.is_superuser) { + this.props.dispatch(grantAdmin(user)); + } + } + + onAddPerson(user) { + // close the modal no matter what + this.props.dispatch(showAddPersonModal(null)); + + if (user) { + // time to create a new user! + + // if email is not setup -> generate temp password and allow user to retrieve it + // when email available -> confirm that invitation was sent + + this.props.dispatch(createUser(user)); + } + } + + renderAddPersonModal() { + if (!this.props.showAddPersonModal) return false; + + return ( + <Modal> + <ModalContent title="Add Person" + closeFn={() => this.props.dispatch(showAddPersonModal(false))}> + <EditUserForm + buttonText="Add Person" + submitFn={this.onAddPerson.bind(this)} /> + </ModalContent> + </Modal> + ); + } + + onEditDetails(user) { + // close the modal no matter what + this.props.dispatch(showEditDetailsModal(null)); + + if (user) { + this.props.dispatch(updateUser(user)); + } + } + + renderEditDetailsModal() { + if (!this.props.showEditDetailsModal) return false; + + return ( + <Modal> + <ModalContent title="Edit Details" + closeFn={() => this.props.dispatch(showEditDetailsModal(null))}> + <EditUserForm + user={this.props.showEditDetailsModal} + submitFn={this.onEditDetails.bind(this)} /> + </ModalContent> + </Modal> + ); + } + + render() { + let users = _.values(this.props.users); + let { error } = this.state; + + return ( + <LoadingAndErrorWrapper loading={!users} error={error}> + {() => + <div className="wrapper"> + {this.renderAddPersonModal()} + {this.renderEditDetailsModal()} + + <section className="PageHeader clearfix"> + <a className="Button Button--primary float-right" href="#" onClick={() => this.props.dispatch(showAddPersonModal(true))}>Add person</a> + <h2 className="PageTitle">People</h2> + </section> + + <section> + <table className="ContentTable"> + <thead> + <tr> + <th>Name</th> + <th>Email</th> + <th>Role</th> + <th>Last Seen</th> + <th></th> + </tr> + </thead> + <tbody> + { users.map(user => + <tr> + <td><span className="text-white inline-block"><UserAvatar background={(user.is_superuser) ? "bg-purple" : "bg-brand"} user={user} /></span> <span className="ml2 text-bold">{user.common_name}</span></td> + <td>{user.email}</td> + <td> + <UserRoleSelect + user={user} + onChangeFn={this.onRoleChange.bind(this)} /> + </td> + <td>{user.last_login.fromNow()}</td> + <td className="text-right"> + <UserActionsSelect user={user} dispatch={this.props.dispatch} /> + </td> + </tr> + )} + </tbody> + </table> + </section> + </div> + } + </LoadingAndErrorWrapper> + ); + } +} + +AdminPeople.propTypes = { + dispatch: PropTypes.func.isRequired, + users: PropTypes.array +}; diff --git a/resources/frontend_client/app/admin/people/components/EditUserForm.react.js b/resources/frontend_client/app/admin/people/components/EditUserForm.react.js new file mode 100644 index 00000000000..10fb63d80dd --- /dev/null +++ b/resources/frontend_client/app/admin/people/components/EditUserForm.react.js @@ -0,0 +1,115 @@ +"use strict"; + +import React, { Component, PropTypes } from "react"; +import _ from "underscore"; +import cx from "classnames"; + +import FormField from "metabase/components/form/FormField.react"; +import FormLabel from "metabase/components/form/FormLabel.react"; +import FormMessage from "metabase/components/form/FormMessage.react"; +import MetabaseSettings from "metabase/lib/settings"; +import MetabaseUtils from "metabase/lib/utils"; + + +export default class EditUserForm extends Component { + + constructor(props) { + super(props); + this.state = { formError: null, valid: false } + } + + validateForm() { + let { valid } = this.state; + let isValid = true; + + // required: first_name, last_name, email + for (var fieldName in this.refs) { + let node = React.findDOMNode(this.refs[fieldName]); + if (node.required && MetabaseUtils.isEmpty(node.value)) isValid = false; + }; + + if(isValid !== valid) { + this.setState({ + 'valid': isValid + }); + } + } + + onChange() { + this.validateForm(); + } + + formSubmitted(e) { + e.preventDefault(); + + this.setState({ + formError: null + }); + + let formErrors = {data:{errors:{}}}; + + // validate email address + if (!MetabaseUtils.validEmail(React.findDOMNode(this.refs.email).value)) { + formErrors.data.errors.email = "Not a valid formatted email address"; + } + + if (_.keys(formErrors.data.errors).length > 0) { + this.setState({ + formError: formErrors + }); + return; + } + + let user = (this.props.user) ? _.clone(this.props.user) : {}; + + user.first_name = React.findDOMNode(this.refs.firstName).value; + user.last_name = React.findDOMNode(this.refs.lastName).value; + user.email = React.findDOMNode(this.refs.email).value; + + this.props.submitFn(user); + } + + cancel() { + this.props.submitFn(null); + } + + render() { + const { buttonText, user } = this.props; + const { formError, valid } = this.state; + + return ( + <form className="Form-new" name="userForm" onSubmit={this.formSubmitted.bind(this)} noValidate className="mt2"> + <FormField fieldName="first_name" formError={formError}> + <FormLabel title="First name" fieldName="first_name" formError={formError}></FormLabel> + <input ref="firstName" className="Form-input Form-offset full" name="name" defaultValue={(user) ? user.first_name : null} placeholder="Johnny" onChange={this.onChange.bind(this)} /> + <span className="Form-charm"></span> + </FormField> + + <FormField fieldName="last_name" formError={formError}> + <FormLabel title="Last name" fieldName="last_name" formError={formError}></FormLabel> + <input ref="lastName" className="Form-input Form-offset" name="name" defaultValue={(user) ? user.last_name : null} placeholder="Appleseed" required onChange={this.onChange.bind(this)} /> + <span className="Form-charm"></span> + </FormField> + + <FormField fieldName="email" formError={formError}> + <FormLabel title="Email address" fieldName="email" formError={formError}></FormLabel> + <input ref="email" className="Form-input Form-offset full" name="email" defaultValue={(user) ? user.email : null} placeholder="youlooknicetoday@email.com" required onChange={this.onChange.bind(this)} /> + <span className="Form-charm"></span> + </FormField> + + <div className="Form-actions"> + <button className={cx("Button", "mr2", {"Button--primary": valid})} disabled={!valid}> + { buttonText ? buttonText : "Save Changes" } + </button> + or <a className="link ml1 text-bold" href="" onClick={this.cancel.bind(this)}>Cancel</a> + </div> + </form> + ); + } +} + +EditUserForm.propTypes = { + buttonText: PropTypes.string, + submitFn: PropTypes.func.isRequired, + user: PropTypes.object +} diff --git a/resources/frontend_client/app/admin/people/components/UserActionsSelect.react.js b/resources/frontend_client/app/admin/people/components/UserActionsSelect.react.js new file mode 100644 index 00000000000..5c61372f117 --- /dev/null +++ b/resources/frontend_client/app/admin/people/components/UserActionsSelect.react.js @@ -0,0 +1,76 @@ +"use strict"; + +import React, { Component, PropTypes } from "react"; + +import Icon from "metabase/components/Icon.react"; +import PopoverWithTrigger from "metabase/components/PopoverWithTrigger.react"; + +import { deleteUser, showEditDetailsModal } from "../actions"; + + +export default class UserActionsSelect extends Component { + + constructor(props) { + super(props); + + this.styles = { + menuList: { + minWidth: "90px" + } + }; + } + + onEditDetails() { + this.props.dispatch(showEditDetailsModal(this.props.user)); + this.refs.popover.toggle(); + } + + onResendInvite() { + + } + + onResetPassword() { + + } + + onRemoveUser() { + this.props.dispatch(deleteUser(this.props.user)); + this.refs.popover.toggle(); + } + + render() { + let { user } = this.props; + + var triggerElement = ( + <span className="text-grey-1"><Icon name={'ellipsis'}></Icon></span> + ); + + var tetherOptions = { + attachment: 'top right', + targetAttachment: 'bottom right', + targetOffset: '5px 0', + constraints: [{ to: 'window', attachment: 'together', pin: ['top', 'bottom']}] + }; + + return ( + <PopoverWithTrigger ref="popover" + className={"PopoverBody PopoverBody--withArrow block"} + tetherOptions={tetherOptions} + triggerElement={triggerElement}> + <ul className="UserActionsSelect"> + <li onClick={this.onEditDetails.bind(this)}>Edit Details</li> + { user.last_login === null ? + <li onClick={this.onResendInvite.bind(this)}>Re-send Invite</li> + : + <li onClick={this.onResetPassword.bind(this)}>Reset Password</li> + } + <li className="Remove" onClick={this.onRemoveUser.bind(this)}>Remove</li> + </ul> + </PopoverWithTrigger> + ); + } +} + +UserActionsSelect.propTypes = { + user: React.PropTypes.object.isRequired +}; diff --git a/resources/frontend_client/app/admin/people/components/UserRoleSelect.react.js b/resources/frontend_client/app/admin/people/components/UserRoleSelect.react.js new file mode 100644 index 00000000000..f7d8337ab54 --- /dev/null +++ b/resources/frontend_client/app/admin/people/components/UserRoleSelect.react.js @@ -0,0 +1,76 @@ +"use strict"; + +import React, { Component, PropTypes } from "react"; +import cx from "classnames"; + +import ColumnarSelector from "metabase/components/ColumnarSelector.react"; +import Icon from "metabase/components/Icon.react"; +import MetabaseCore from "metabase/lib/core"; +import PopoverWithTrigger from "metabase/components/PopoverWithTrigger.react"; + + +export default class UserRoleSelect extends Component { + + toggle () { + this.refs.popover.toggle(); + } + + render() { + let { user, onChangeFn } = this.props; + const roleDef = (user.is_superuser) ? MetabaseCore.user_roles[1] : MetabaseCore.user_roles[0]; + + var triggerElement = ( + <div className={"flex align-center"}> + <span className="mr1">{roleDef.name}</span> + <Icon className="text-grey-2" name="chevrondown" width="10" height="10"/> + </div> + ); + + var sections = {}; + MetabaseCore.user_roles.forEach(function (option) { + var sectionName = option.section || ""; + sections[sectionName] = sections[sectionName] || { title: sectionName || undefined, items: [] }; + sections[sectionName].items.push(option); + }); + sections = Object.keys(sections).map((sectionName) => sections[sectionName]); + + var columns = [ + { + selectedItem: roleDef, + sections: sections, + itemTitleFn: (item) => item.name, + itemDescriptionFn: (item) => item.description, + itemSelectFn: (item) => { + onChangeFn(user, item); + this.toggle(); + } + } + ]; + + var tetherOptions = { + attachment: 'top center', + targetAttachment: 'bottom center', + targetOffset: '5px 0', + constraints: [{ to: 'window', attachment: 'together', pin: ['top', 'bottom']}] + }; + + return ( + <PopoverWithTrigger ref="popover" + className={"PopoverBody PopoverBody--withArrow UserRolePopover block"} + tetherOptions={tetherOptions} + triggerElement={triggerElement} + triggerClasses={cx("AdminSelectBorderless", "py1", {"text-purple": user.is_superuser, "text-brand": !user.is_superuser})}> + <ColumnarSelector columns={columns}/> + </PopoverWithTrigger> + ); + } +} + +UserRoleSelect.defaultProps = { + isInitiallyOpen: false +}; + +UserRoleSelect.propTypes = { + user: React.PropTypes.object.isRequired, + onChangeFn: React.PropTypes.func.isRequired +}; diff --git a/resources/frontend_client/app/admin/people/containers/AdminPeopleApp.react.js b/resources/frontend_client/app/admin/people/containers/AdminPeopleApp.react.js new file mode 100644 index 00000000000..575c1c2882c --- /dev/null +++ b/resources/frontend_client/app/admin/people/containers/AdminPeopleApp.react.js @@ -0,0 +1,14 @@ +"use strict"; + +import React, { Component, PropTypes } from "react"; +import { connect } from "react-redux"; + +import AdminPeople from "../components/AdminPeople.react"; +import { adminPeopleSelectors } from "../selectors"; + +@connect(adminPeopleSelectors) +export default class AdminPeopleApp extends Component { + render() { + return <AdminPeople {...this.props} />; + } +} diff --git a/resources/frontend_client/app/admin/people/partials/people.html b/resources/frontend_client/app/admin/people/partials/people.html deleted file mode 100644 index bef4b0ea2da..00000000000 --- a/resources/frontend_client/app/admin/people/partials/people.html +++ /dev/null @@ -1,32 +0,0 @@ -<div class="wrapper"> - <section class="PageHeader clearfix"> - <a class="Button Button--primary float-right" href="/admin/people/add">Add person</a> - <h2 class="PageTitle">People</h2> - </section> - - <section> - <table class="ContentTable"> - <thead> - <tr> - <th>Name</th> - <th></th> - </tr> - </thead> - <tbody> - <tr ng-repeat="user in people"> - <td> - {{user.common_name}} <span ng-show="user.is_superuser" class="AdminBadge">Admin</span> - </td> - <td class="Table-actions"> - <button class="Button" ng-click="toggle(user.id)"> - <span ng-show="user.is_superuser">Revoke</span> - <span ng-show="!user.is_superuser">Grant</span> - admin - </button> - <button class="Button Button--danger" ng-click="delete(user.id)" delete-confirm>Remove</button> - </td> - </tr> - </tbody> - </table> - </section> -</div> diff --git a/resources/frontend_client/app/admin/people/partials/people_add.html b/resources/frontend_client/app/admin/people/partials/people_add.html deleted file mode 100644 index d4818c34920..00000000000 --- a/resources/frontend_client/app/admin/people/partials/people_add.html +++ /dev/null @@ -1,37 +0,0 @@ -<div class="wrapper"> - <section class="Breadcrumbs"> - <a class="Breadcrumb Breadcrumb--path" href="/admin/people/">People</a> - <mb-icon name="chevronright" class="Breadcrumb-divider" width="12px" height="12px"></mb-icon> - <h2 class="Breadcrumb Breadcrumb--page">Add person</h2> - </section> - - <section class="Grid Grid--gutters Grid--full Grid--2of3"> - <div class="Grid-cell Cell--2of3"> - <form class="Form-new bordered rounded shadowed" name="form" novalidate> - <div class="Form-field" mb-form-field="first_name"> - <mb-form-label display-name="First name" field-name="first_name"></mb-form-label> - <input class="Form-input Form-offset full" name="first_name" placeholder="John" ng-model="newUser.first_name" required autofocus /> - <span class="Form-charm"></span> - </div> - - <div class="Form-field" mb-form-field="last_name"> - <mb-form-label display-name="Last name" field-name="last_name"></mb-form-label> - <input class="Form-input Form-offset full" name="last_name" placeholder="Doe" ng-model="newUser.last_name" required /> - <span class="Form-charm"></span> - </div> - - <div class="Form-field" mb-form-field="email"> - <mb-form-label display-name="Email" field-name="email"></mb-form-label> - <input class="Form-input Form-offset full" name="email" placeholder="johndoe@mycompany.com" ng-model="newUser.email" required /> - <span class="Form-charm"></span> - </div> - - <div class="Form-actions"> - <button class="Button" ng-class="{'Button--primary': form.$valid}" ng-click="save(newUser)" ng-disabled="!form.$valid"> - Create - </button> - </div> - </form> - </div> - </section> -</div> diff --git a/resources/frontend_client/app/admin/people/people.controllers.js b/resources/frontend_client/app/admin/people/people.controllers.js index 41087c81ae2..3d1fd28b8fe 100644 --- a/resources/frontend_client/app/admin/people/people.controllers.js +++ b/resources/frontend_client/app/admin/people/people.controllers.js @@ -1,100 +1,36 @@ 'use strict'; -var PeopleControllers = angular.module('metabaseadmin.people.controllers', [ - 'metabase.services', - 'metabase.forms' -]); - -PeopleControllers.controller('PeopleList', ['$scope', 'User', - function($scope, User) { - - // grant superuser permission for a given user - var grant = function(user) { - user.is_superuser = true; - - User.update(user, function (result) { - $scope.people.forEach(function (u) { - if (u.id === user.id) { - u.is_superuser = true; - } - }); - }, function (error) { - console.log('error', error); - $scope.alertError('failed to grant superuser to user'); - }); - }; - - // revoke superuser permission for a given user - var revoke = function(user) { - user.is_superuser = false; +import { createStore, applyMiddleware, combineReducers, compose } from 'redux'; +import promiseMiddleware from 'redux-promise'; +import thunkMidleware from "redux-thunk"; - User.update(user, function (result) { - $scope.people.forEach(function (u) { - if (u.id === user.id) { - u.is_superuser = false; - } - }); - }, function (error) { - console.log('error', error); - $scope.alertError('failed to revoke superuser from user'); - }); - }; - - // toggle superuser permission for a given user - $scope.toggle = function(userId) { - $scope.people.forEach(function (user) { - if (user.id === userId) { - if (user.is_superuser) { - revoke(user); - } else { - grant(user); - } - } - }); - }; +import AdminPeopleApp from './containers/AdminPeopleApp.react'; +import * as reducers from './reducers'; - // completely remove a given user - // TODO: we need this api function now - $scope.delete = function(userId) { - User.delete({ - userId: userId - }, function(result) { - for (var i = 0; i < $scope.people.length; i++) { - if($scope.people[i].id === userId) { - $scope.people.splice(i, 1); - break; - } - } - }, function (error) { - console.log('error', error); - $scope.alertError('failed to remove user'); - }); - }; - User.list(function (result) { - $scope.people = result; - }, function (error) { - console.log('error', error); - }); - } -]); +const finalCreateStore = compose( + applyMiddleware( + thunkMidleware, + promiseMiddleware + ), + createStore +); +const reducer = combineReducers(reducers); -PeopleControllers.controller('PeopleAdd', ['$scope', '$location', 'User', - function($scope, $location, User) { - $scope.save = function(newUser) { - $scope.$broadcast("form:reset"); +var PeopleControllers = angular.module('metabaseadmin.people.controllers', ['metabase.services']); - newUser.is_superuser = false; +PeopleControllers.controller('PeopleList', ['$scope', '$location', '$route', '$routeParams', + function($scope, $location, $route, $routeParams) { - // TODO: we need this function!! - User.create(newUser, function (result) { - // just go back to people listing page for now - $location.path('/admin/people/'); - }, function (error) { - $scope.$broadcast("form:api-error", error); - }); + $scope.Component = AdminPeopleApp; + $scope.props = { + user: $scope.user, + onChangeLocation: function(url) { + $scope.$apply(() => $location.url(url)); + } }; + $scope.store = finalCreateStore(reducer, {}); } ]); diff --git a/resources/frontend_client/app/admin/people/people.module.js b/resources/frontend_client/app/admin/people/people.module.js index b3cca254f6e..51719964a3d 100644 --- a/resources/frontend_client/app/admin/people/people.module.js +++ b/resources/frontend_client/app/admin/people/people.module.js @@ -6,12 +6,7 @@ var AdminPeople = angular.module('metabaseadmin.people', [ AdminPeople.config(['$routeProvider', function ($routeProvider) { $routeProvider.when('/admin/people/', { - templateUrl: '/app/admin/people/partials/people.html', + template: '<div mb-redux-component class="flex flex-column flex-full" />', controller: 'PeopleList' }); - - $routeProvider.when('/admin/people/add', { - templateUrl: '/app/admin/people/partials/people_add.html', - controller: 'PeopleAdd' - }); }]); diff --git a/resources/frontend_client/app/admin/people/reducers.js b/resources/frontend_client/app/admin/people/reducers.js new file mode 100644 index 00000000000..db006aa17f9 --- /dev/null +++ b/resources/frontend_client/app/admin/people/reducers.js @@ -0,0 +1,36 @@ +"use strict"; + +import _ from "underscore"; + +import { handleActions } from 'redux-actions'; + +import { + CREATE_USER, + DELETE_USER, + FETCH_USERS, + GRANT_ADMIN, + REVOKE_ADMIN, + SHOW_ADD_PERSON, + SHOW_EDIT_DETAILS, + UPDATE_USER +} from './actions'; + + +export const showAddPersonModal = handleActions({ + [SHOW_ADD_PERSON]: { next: (state, { payload }) => payload } +}, false); + +export const showEditDetailsModal = handleActions({ + [SHOW_EDIT_DETAILS]: { next: (state, { payload }) => payload } +}, null); + +// { let newState = { ...state }; delete newState[user.id]; return newState; } + +export const users = handleActions({ + [FETCH_USERS]: { next: (state, { payload }) => ({ ...payload.entities.user }) }, + [CREATE_USER]: { next: (state, { payload: user }) => ({ ...state, [user.id]: user }) }, + [DELETE_USER]: { next: (state, { payload: user }) => _.omit(state, user.id) }, + [GRANT_ADMIN]: { next: (state, { payload: user }) => ({ ...state, [user.id]: user }) }, + [REVOKE_ADMIN]: { next: (state, { payload: user }) => ({ ...state, [user.id]: user }) }, + [UPDATE_USER]: { next: (state, { payload: user }) => ({ ...state, [user.id]: user }) }, +}, null); diff --git a/resources/frontend_client/app/admin/people/selectors.js b/resources/frontend_client/app/admin/people/selectors.js new file mode 100644 index 00000000000..57d649119f8 --- /dev/null +++ b/resources/frontend_client/app/admin/people/selectors.js @@ -0,0 +1,18 @@ +"use strict"; + +import { createSelector } from 'reselect'; + +// our master selector which combines all of our partial selectors above +export const adminPeopleSelectors = createSelector( + [state => state.showAddPersonModal, + state => state.showEditDetailsModal, + state => state.users], + + (showAddPersonModal, + showEditDetailsModal, + users) => + + ({showAddPersonModal, + showEditDetailsModal, + users}) +); \ No newline at end of file diff --git a/resources/frontend_client/app/components/UserAvatar.react.js b/resources/frontend_client/app/components/UserAvatar.react.js index b0a95418e54..42a1e4ffa1a 100644 --- a/resources/frontend_client/app/components/UserAvatar.react.js +++ b/resources/frontend_client/app/components/UserAvatar.react.js @@ -1,6 +1,6 @@ 'use strict'; -import React, { Component } from 'react'; +import React, { Component, PropTypes } from 'react'; import cx from 'classnames'; export default class UserAvatar extends Component { @@ -51,5 +51,10 @@ export default class UserAvatar extends Component { } UserAvatar.defaultProps = { - background: 'bg-brand', + background: 'bg-brand' } + +UserAvatar.propTypes = { + background: PropTypes.string, + user: PropTypes.object.isRequired +} \ No newline at end of file diff --git a/resources/frontend_client/app/css/admin.css b/resources/frontend_client/app/css/admin.css index 0332dd7175a..5270179c6db 100644 --- a/resources/frontend_client/app/css/admin.css +++ b/resources/frontend_client/app/css/admin.css @@ -284,6 +284,13 @@ min-width: 90px; } +.AdminSelectBorderless { + display: inline-block; + font-size: 14px; + font-weight: 700; + margin-bottom: 3px; +} + .MetadataTable-title { background-color: #FCFCFC; @@ -410,3 +417,33 @@ .SettingsPassword { width: 200px; } + +.UserRolePopover .ColumnarSelector-column { + min-height: 180px; +} + +.UserActionsSelect { + padding-top: 1em; + min-width: 180px; +} + +.UserActionsSelect li { + padding: var(--padding-1) var(--padding-2) var(--padding-1) var(--padding-2); +} + +.UserActionsSelect li:hover { + background-color: var(--brand-color); + color: #fff; +} + +.UserActionsSelect li.Remove { + margin-top: var(--padding-1); + padding: var(--padding-2); + border-top: 1px solid #EAEAEA; + color: var(--error-color); +} + +.UserActionsSelect li.Remove:hover { + background-color: var(--error-color); + color: #fff; +} diff --git a/resources/frontend_client/app/icon_paths.js b/resources/frontend_client/app/icon_paths.js index cac939dc6e6..e238f46fb20 100644 --- a/resources/frontend_client/app/icon_paths.js +++ b/resources/frontend_client/app/icon_paths.js @@ -48,6 +48,7 @@ export var ICON_PATHS = { path: 'M4,8 L4,0 L7,0 L7,8 L10,8 L5.5,13.25 L1,8 L4,8 Z M11,14 L0,14 L0,17 L11,17 L11,14 Z', attrs: { viewBox: '0 0 11 17' } }, + ellipsis: 'M26.1111111,19 C27.7066004,19 29,17.6568542 29,16 C29,14.3431458 27.7066004,13 26.1111111,13 C24.5156218,13 23.2222222,14.3431458 23.2222222,16 C23.2222222,17.6568542 24.5156218,19 26.1111111,19 Z M5.88888889,19 C7.48437817,19 8.77777778,17.6568542 8.77777778,16 C8.77777778,14.3431458 7.48437817,13 5.88888889,13 C4.29339961,13 3,14.3431458 3,16 C3,17.6568542 4.29339961,19 5.88888889,19 Z M16,19 C17.5954893,19 18.8888889,17.6568542 18.8888889,16 C18.8888889,14.3431458 17.5954893,13 16,13 C14.4045107,13 13.1111111,14.3431458 13.1111111,16 C13.1111111,17.6568542 14.4045107,19 16,19 Z', expand: 'M29,13.6720571 L29,8.26132482e-16 L15.3279429,8.64083276e-16 L20.3471502,5.01920738 L15.0015892,10.3647684 L18.6368207,14 L23.9823818,8.65443894 L29,13.6720571 Z M0.00158917013,15.3279429 L0.00158917013,29 L13.6736463,29 L8.65443894,23.9807926 L14,18.6352316 L10.3647684,15 L5.01920738,20.3455611 L0.00158917013,15.3279429 Z', explore: 'M16.4796545,16.298957 L16.4802727,23.0580389 L16.4802727,23.0580389 C17.3528782,23.2731238 18,24.0609902 18,25 C18,26.1045695 17.1045695,27 16,27 C14.8954305,27 14,26.1045695 14,25 C14,24.0751922 14.6276951,23.2969904 15.4802906,23.0681896 L15.4796772,16.3617812 L15.4796772,16.3617812 L9.42693239,19.2936488 C9.54250354,19.9090101 9.36818637,20.5691625 8.90013616,21.0538426 C8.13283771,21.8484034 6.86670062,21.8705039 6.07213982,21.1032055 C5.27757902,20.335907 5.25547851,19.06977 6.02277696,18.2752092 C6.79007541,17.4806484 8.0562125,17.4585478 8.8507733,18.2258463 C8.90464955,18.277874 8.95497425,18.3321952 9.00174214,18.3885073 L14.8957415,15.5335339 L8.95698016,12.663638 C8.54316409,13.1288103 7.91883307,13.3945629 7.25239963,13.3245179 C6.15388108,13.2090589 5.35695382,12.2249357 5.47241277,11.1264172 C5.58787172,10.0278986 6.57199493,9.23097136 7.67051349,9.34643031 C8.76903204,9.46188927 9.5659593,10.4460125 9.45050035,11.544531 C9.44231425,11.6224166 9.42976147,11.6987861 9.41311084,11.7734218 L15.4795257,14.705006 L15.4789062,7.93143834 C14.6270158,7.70216703 14,6.9243072 14,6 C14,4.8954305 14.8954305,4 16,4 C17.1045695,4 18,4.8954305 18,6 C18,6.93950562 17.3521946,7.72770818 16.4788902,7.94230133 L16.4795143,14.7663758 L22.5940736,11.8045661 C22.4397082,11.1620316 22.6068068,10.4567329 23.0998638,9.94615736 C23.8671623,9.15159656 25.1332994,9.12949606 25.9278602,9.8967945 C26.722421,10.664093 26.7445215,11.93023 25.977223,12.7247908 C25.2099246,13.5193516 23.9437875,13.5414522 23.1492267,12.7741537 C23.120046,12.7459743 23.0919072,12.717122 23.0648111,12.687645 L17.1917924,15.5324558 L23.0283963,18.3529842 C23.4420438,17.8775358 24.073269,17.604607 24.7476004,17.6754821 C25.8461189,17.7909411 26.6430462,18.7750643 26.5275872,19.8735828 C26.4121283,20.9721014 25.4280051,21.7690286 24.3294865,21.6535697 C23.230968,21.5381107 22.4340407,20.5539875 22.5494996,19.455469 C22.5569037,19.3850239 22.56788,19.315819 22.5822296,19.2480155 L16.4796545,16.298957 Z M16.0651172,6.99791382 C16.5870517,6.96436642 17,6.53040783 17,6 C17,5.44771525 16.5522847,5 16,5 C15.4477153,5 15,5.44771525 15,6 C15,6.53446591 15.4192913,6.9710011 15.9468816,6.99861337 L16.0651172,6.99791382 L16.0651172,6.99791382 Z M16,26 C16.5522847,26 17,25.5522847 17,25 C17,24.4477153 16.5522847,24 16,24 C15.4477153,24 15,24.4477153 15,25 C15,25.5522847 15.4477153,26 16,26 Z M6.56266251,20.102897 C6.80476821,20.5992873 7.40343746,20.8054256 7.89982771,20.5633199 C8.39621795,20.3212142 8.60235631,19.722545 8.36025061,19.2261547 C8.11814491,18.7297645 7.51947566,18.5236261 7.02308541,18.7657318 C6.52669517,19.0078375 6.32055681,19.6065068 6.56266251,20.102897 Z M23.6397494,11.7738453 C23.8818551,12.2702355 24.4805243,12.4763739 24.9769146,12.2342682 C25.4733048,11.9921625 25.6794432,11.3934932 25.4373375,10.897103 C25.1952318,10.4007127 24.5965625,10.1945744 24.1001723,10.4366801 C23.603782,10.6787858 23.3976437,11.277455 23.6397494,11.7738453 Z M25.4373375,20.102897 C25.6794432,19.6065068 25.4733048,19.0078375 24.9769146,18.7657318 C24.4805243,18.5236261 23.8818551,18.7297645 23.6397494,19.2261547 C23.3976437,19.722545 23.603782,20.3212142 24.1001723,20.5633199 C24.5965625,20.8054256 25.1952318,20.5992873 25.4373375,20.102897 Z M8.36025061,11.7738453 C8.60235631,11.277455 8.39621795,10.6787858 7.89982771,10.4366801 C7.40343746,10.1945744 6.80476821,10.4007127 6.56266251,10.897103 C6.32055681,11.3934932 6.52669517,11.9921625 7.02308541,12.2342682 C7.51947566,12.4763739 8.11814491,12.2702355 8.36025061,11.7738453 Z', filter: 'M6.57883011,7.57952565 L1.18660637e-12,-4.86721774e-13 L16,-4.92050845e-13 L9.42116989,7.57952565 L9.42116989,13.5542169 L6.57883011,15 L6.57883011,7.57952565 Z', diff --git a/resources/frontend_client/app/lib/core.js b/resources/frontend_client/app/lib/core.js index ecdfd704286..fa3557d853e 100644 --- a/resources/frontend_client/app/lib/core.js +++ b/resources/frontend_client/app/lib/core.js @@ -5,6 +5,16 @@ import _ from "underscore"; (function() { + this.user_roles = [{ + 'id': 'user', + 'name': 'User', + 'description': 'Can do everything except access the Admin Panel.' + }, { + 'id': 'admin', + 'name': 'Admin', + 'description': "Can access the Admin Panel to add or remove users and modify database settings." + }]; + this.perms = [{ 'id': 0, 'name': 'Private' -- GitLab