Explorar o código

Add users and project management

Sergiu Miclea %!s(int64=8) %!d(string=hai) anos
pai
achega
fcc5460eef
Modificáronse 65 ficheiros con 3755 adicións e 232 borrados
  1. 1 1
      README.md
  2. 15 5
      src/components/App.jsx
  3. 4 1
      src/components/atoms/AutocompleteInput/index.jsx
  4. 21 3
      src/components/atoms/Button/index.jsx
  5. 4 0
      src/components/atoms/DropdownButton/index.jsx
  6. 0 16
      src/components/atoms/TextInput/images/star.svg
  7. 2 20
      src/components/atoms/TextInput/index.jsx
  8. 7 0
      src/components/molecules/AutocompleteDropdown/index.jsx
  9. 14 0
      src/components/molecules/Dropdown/images/checkmark.js
  10. 38 6
      src/components/molecules/Dropdown/index.jsx
  11. 51 1
      src/components/molecules/Dropdown/story.jsx
  12. 0 3
      src/components/molecules/DropdownInput/index.jsx
  13. 57 19
      src/components/molecules/DropdownLink/index.jsx
  14. 46 12
      src/components/molecules/DropdownLink/story.jsx
  15. 22 0
      src/components/molecules/EndpointField/images/asterisk.svg
  16. 59 18
      src/components/molecules/EndpointField/index.jsx
  17. 8 1
      src/components/molecules/Modal/index.jsx
  18. 25 0
      src/components/molecules/NewItemDropdown/images/project.svg
  19. 26 0
      src/components/molecules/NewItemDropdown/images/user.svg
  20. 43 18
      src/components/molecules/NewItemDropdown/index.jsx
  21. 29 0
      src/components/molecules/ProjectListItem/images/project.svg
  22. 146 0
      src/components/molecules/ProjectListItem/index.jsx
  23. 1 1
      src/components/molecules/Table/index.jsx
  24. 15 4
      src/components/molecules/UserDropdown/index.jsx
  25. 22 0
      src/components/molecules/UserListItem/images/user.svg
  26. 136 0
      src/components/molecules/UserListItem/index.jsx
  27. 1 1
      src/components/organisms/DetailsContentHeader/index.jsx
  28. 9 9
      src/components/organisms/FilterList/index.jsx
  29. 2 1
      src/components/organisms/LoginForm/index.jsx
  30. 5 5
      src/components/organisms/MainList/index.jsx
  31. 8 2
      src/components/organisms/Navigation/index.jsx
  32. 72 4
      src/components/organisms/PageHeader/index.jsx
  33. 341 0
      src/components/organisms/ProjectDetailsContent/index.jsx
  34. 31 0
      src/components/organisms/ProjectMemberModal/images/user.svg
  35. 383 0
      src/components/organisms/ProjectMemberModal/index.jsx
  36. 19 0
      src/components/organisms/ProjectModal/images/project.svg
  37. 190 0
      src/components/organisms/ProjectModal/index.jsx
  38. 236 0
      src/components/organisms/UserDetailsContent/index.jsx
  39. 31 0
      src/components/organisms/UserModal/images/user.svg
  40. 285 0
      src/components/organisms/UserModal/index.jsx
  41. 1 1
      src/components/organisms/WizardPageContent/index.jsx
  42. 1 1
      src/components/pages/AssessmentDetailsPage/index.jsx
  43. 3 3
      src/components/pages/AssessmentsPage/index.jsx
  44. 1 1
      src/components/pages/EndpointDetailsPage/index.jsx
  45. 3 3
      src/components/pages/EndpointsPage/index.jsx
  46. 1 1
      src/components/pages/LoginPage/index.jsx
  47. 1 1
      src/components/pages/MigrationDetailsPage/index.jsx
  48. 22 0
      src/components/pages/ProjectDetailsPage/images/project.svg
  49. 233 0
      src/components/pages/ProjectDetailsPage/index.jsx
  50. 145 0
      src/components/pages/ProjectsPage/index.jsx
  51. 1 1
      src/components/pages/ReplicaDetailsPage/index.jsx
  52. 24 0
      src/components/pages/UserDetailsPage/images/user.svg
  53. 151 0
      src/components/pages/UserDetailsPage/index.jsx
  54. 148 0
      src/components/pages/UsersPage/index.jsx
  55. 21 5
      src/components/pages/WizardPage/index.jsx
  56. 6 0
      src/config.js
  57. 112 8
      src/sources/ProjectSource.js
  58. 172 6
      src/sources/UserSource.js
  59. 1 0
      src/stores/EndpointStore.js
  60. 95 4
      src/stores/ProjectStore.js
  61. 167 43
      src/stores/UserStore.js
  62. 26 0
      src/types/Project.js
  63. 5 0
      src/types/User.js
  64. 1 1
      src/types/WizardData.js
  65. 9 2
      src/utils/ApiCaller.js

+ 1 - 1
README.md

@@ -38,7 +38,7 @@ You can view some of the UIs components in the [Storybook](https://github.com/st
 
 ```
 Header always set Access-Control-Allow-Origin "*"
-Header always set Access-Control-Allow-Methods "POST, GET, OPTIONS, DELETE, PUT"
+Header always set Access-Control-Allow-Methods "POST, GET, OPTIONS, DELETE, PUT, PATCH"
 Header always set Access-Control-Max-Age "1000"
 Header always set Access-Control-Allow-Headers "x-requested-with, X-Auth-Token, X-Subject-Token, Content-Type, origin, authorization, accept, client-security-token"
 Header always set Access-Control-Allow-Credentials "true"

+ 15 - 5
src/components/App.jsx

@@ -32,6 +32,10 @@ import WizardPage from './pages/WizardPage'
 import userStore from '../stores/UserStore'
 import AssessmentsPage from './pages/AssessmentsPage'
 import AssessmentDetailsPage from './pages/AssessmentDetailsPage'
+import UsersPage from './pages/UsersPage'
+import UserDetailsPage from './pages/UserDetailsPage'
+import ProjectsPage from './pages/ProjectsPage'
+import ProjectDetailsPage from './pages/ProjectDetailsPage'
 
 import { navigationMenu } from '../config'
 import Palette from './styleUtils/Palette'
@@ -58,9 +62,11 @@ class App extends React.Component<{}> {
   }
 
   render() {
-    let renderPlanningPage = () => {
-      if (navigationMenu.find(m => m.value === 'planning' && !m.disabled)) {
-        return <Route path="/planning" component={AssessmentsPage} />
+    let renderOptionalPage = (name: string, component: any, path?: string, exact?: boolean) => {
+      const isAdmin = userStore.loggedUser ? userStore.loggedUser.isAdmin : true
+      // $FlowIgnore
+      if (navigationMenu.find(m => m.value === name && !m.disabled && (!m.requiresAdmin || isAdmin))) {
+        return <Route path={`${path || `/${name}`}`} component={component} exact={exact} />
       }
       return null
     }
@@ -78,9 +84,13 @@ class App extends React.Component<{}> {
           <Route path="/migration/:page/:id" component={MigrationDetailsPage} />
           <Route path="/endpoints" component={EndpointsPage} />
           <Route path="/endpoint/:id" component={EndpointDetailsPage} />
-          {renderPlanningPage()}
-          <Route path="/assessment/:info" component={AssessmentDetailsPage} />
           <Route path="/wizard/:type" component={WizardPage} />
+          {renderOptionalPage('planning', AssessmentsPage)}
+          {renderOptionalPage('planning', AssessmentDetailsPage, '/assessment/:info')}
+          {renderOptionalPage('users', UsersPage)}
+          {renderOptionalPage('users', UserDetailsPage, '/user/:id', true)}
+          {renderOptionalPage('projects', ProjectsPage)}
+          {renderOptionalPage('projects', ProjectDetailsPage, '/project/:id', true)}
           <Route component={NotFoundPage} />
         </Switch>
         <Notifications />

+ 4 - 1
src/components/atoms/AutocompleteInput/index.jsx

@@ -42,7 +42,7 @@ const Wrapper = styled.div`
   width: ${props => getWidth(props)}px;
   height: ${props => props.large ? StyleProps.inputSizes.large.height - 2
     : StyleProps.inputSizes.regular.height - 2}px;
-  border: 1px solid ${props => props.disabled ? Palette.grayscale[0] : Palette.grayscale[3]};
+  border: 1px solid ${props => props.highlight ? Palette.alert : props.disabled ? Palette.grayscale[0] : Palette.grayscale[3]};
   border-radius: ${StyleProps.borderRadius};
   cursor: ${props => props.disabled ? 'default' : 'pointer'};
   transition: all ${StyleProps.animations.swift};
@@ -71,6 +71,7 @@ type Props = {
   width?: number,
   large?: boolean,
   onFocus?: () => void,
+  highlight?: boolean,
 }
 type State = {
   textInputFocus: boolean,
@@ -86,6 +87,8 @@ class AutocompleteInput extends React.Component<Props, State> {
         large={this.props.large}
         width={this.props.width}
         focus={this.state.textInputFocus}
+        highlight={this.props.highlight}
+        disabled={this.props.disabled}
         innerRef={e => {
           if (this.props.customRef) {
             this.props.customRef(e)

+ 21 - 3
src/components/atoms/Button/index.jsx

@@ -32,8 +32,18 @@ const backgroundColor = (props) => {
   }
   return Palette.primary
 }
+const disabledBackgroundColor = props => {
+  if (props.secondary && props.hollow) {
+    return Palette.grayscale[7]
+  }
+  return backgroundColor(props)
+}
 
 const hoverBackgroundColor = (props) => {
+  if (props.disabled && props.secondary && props.hollow) {
+    return Palette.grayscale[7]
+  }
+
   if (props.hoverPrimary) {
     return Palette.primary
   }
@@ -58,11 +68,17 @@ const border = (props) => {
   }
   return ''
 }
+const disabledBorder = props => {
+  if (props.secondary && props.hollow) {
+    return 'border: none;'
+  }
+  return border(props)
+}
 
 const color = (props) => {
   if (props.hollow) {
     if (props.secondary) {
-      return Palette.black
+      return props.disabled ? Palette.grayscale[3] : Palette.black
     }
     if (props.alert) {
       return Palette.alert
@@ -95,11 +111,13 @@ const StyledButton = styled.button`
   font-size: inherit;
   transition: background-color ${StyleProps.animations.swift}, opacity ${StyleProps.animations.swift};
   &:disabled {
-    opacity: 0.7;
+    opacity: ${props => props.secondary ? 1 : 0.7};
     cursor: not-allowed;
+    background-color: ${props => disabledBackgroundColor(props)};
+    ${props => disabledBorder(props)}
   }
   &:hover {
-    ${props => props.hollow ? 'color: white;' : ''}
+    ${props => props.hollow ? `color: ${props.disabled ? Palette.grayscale[3] : 'white'};` : ''}
     background-color: ${props => hoverBackgroundColor(props)};
   }
   &:focus {

+ 4 - 0
src/components/atoms/DropdownButton/index.jsx

@@ -77,6 +77,9 @@ const getWidth = props => {
   return StyleProps.inputSizes.regular.width - 2
 }
 const borderColor = props => {
+  if (props.highlight) {
+    return Palette.alert
+  }
   if (props.disabled) {
     return Palette.grayscale[0]
   }
@@ -139,6 +142,7 @@ type Props = {
   'data-test-id'?: string,
   embedded?: boolean,
   required?: boolean,
+  highlight?: boolean,
 }
 class DropdownButton extends React.Component<Props> {
   render() {

+ 0 - 16
src/components/atoms/TextInput/images/star.svg

@@ -1,16 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<svg width="6px" height="8px" viewBox="0 0 6 8" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
-    <!-- Generator: Sketch 47.1 (45422) - http://www.bohemiancoding.com/sketch -->
-    <title>Icon-Star</title>
-    <desc>Created with Sketch.</desc>
-    <defs></defs>
-    <g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round">
-        <g id="Forms/Input/Required" transform="translate(-173.000000, -12.000000)" stroke="#616870">
-            <g id="Icon/Asterisk/Grey" transform="translate(172.000000, 12.000000)">
-                <path d="M4,0.666666667 L4,7.33333333" id="Line"></path>
-                <path d="M1.11324865,2.33333333 L6.88675135,5.66666667" id="Line"></path>
-                <path d="M1.11324865,2.33333333 L6.88675135,5.66666667" id="Line" transform="translate(4.000000, 4.000000) scale(-1, 1) translate(-4.000000, -4.000000) "></path>
-            </g>
-        </g>
-    </g>
-</svg>

+ 2 - 20
src/components/atoms/TextInput/index.jsx

@@ -19,7 +19,6 @@ import styled, { css } from 'styled-components'
 import Palette from '../../styleUtils/Palette'
 import StyleProps from '../../styleUtils/StyleProps'
 
-import starImage from './images/star.svg'
 import closeImage from './images/close.svg'
 
 const Wrapper = styled.div`
@@ -49,7 +48,7 @@ const Input = styled.input`
   border-bottom-left-radius: ${props => props.embedded ? 0 : StyleProps.borderRadius};
   border-bottom-right-radius: ${StyleProps.borderRadius};
   color: ${Palette.black};
-  padding: 0 ${props => props.customRequired ? '29px' : '8px'} 0 ${props => props.embedded ? 0 : '16px'};
+  padding: 0 8px 0 ${props => props.embedded ? 0 : '16px'};
   font-size: inherit;
   transition: all ${StyleProps.animations.swift};
   box-sizing: border-box;
@@ -69,15 +68,6 @@ const Input = styled.input`
     color: ${Palette.grayscale[3]};
   }
 `
-const Required = styled.div`
-  display: ${props => props.show ? 'block' : 'none'};
-  position: absolute;
-  right: 12px;
-  top: 13px;
-  width: 8px;
-  height: 8px;
-  background: url('${starImage}') center no-repeat;
-`
 const Close = styled.div`
   display: ${props => props.show ? 'block' : 'none'};
   width: 16px;
@@ -91,7 +81,6 @@ const Close = styled.div`
 
 type Props = {
   _ref?: (ref: HTMLElement) => void,
-  required?: boolean,
   disabled?: boolean,
   highlight?: boolean,
   large?: boolean,
@@ -102,29 +91,22 @@ type Props = {
   showClose?: boolean,
   onCloseClick?: () => void,
   embedded?: boolean,
-  requiredStyle?: any,
   height?: string,
   'data-test-id'?: string,
 }
 const TextInput = (props: Props) => {
-  const { _ref, required, value, onChange, showClose, onCloseClick } = props
+  const { _ref, value, onChange, showClose, onCloseClick } = props
   let input
   return (
     <Wrapper data-test-id={props['data-test-id'] || 'textInput'}>
       <Input
         innerRef={ref => { input = ref; if (_ref) _ref(ref) }}
         type="text"
-        customRequired={required}
         value={value}
         onChange={onChange}
         data-test-id="textInput-input"
         {...props}
       />
-      <Required
-        show={required}
-        data-test-id="textInput-required"
-        style={props.requiredStyle}
-      />
       <Close
         data-test-id="textInput-close"
         show={showClose && value !== '' && value !== undefined}

+ 7 - 0
src/components/molecules/AutocompleteDropdown/index.jsx

@@ -121,6 +121,7 @@ type Props = {
   disabled?: boolean,
   width?: number,
   dimNullValue?: boolean,
+  highlight?: boolean,
 }
 type State = {
   showDropdownList: boolean,
@@ -212,6 +213,10 @@ class AutocompleteDropdown extends React.Component<Props, State> {
       return null
     }
 
+    if (typeof item === 'string') {
+      return item
+    }
+
     return (item[valueField] !== null && item[valueField] !== undefined && item[valueField].toString()) || null
   }
 
@@ -463,6 +468,8 @@ class AutocompleteDropdown extends React.Component<Props, State> {
           onClick={() => this.handleButtonClick()}
           onChange={searchValue => { this.handleSearchInputChange(searchValue) }}
           onFocus={() => { this.handleSearchInputChange(this.state.searchValue, true) }}
+          highlight={this.props.highlight}
+          disabled={this.props.disabled}
         />
         {this.renderList()}
       </Wrapper>

+ 14 - 0
src/components/molecules/Dropdown/images/checkmark.js

@@ -0,0 +1,14 @@
+export default `<?xml version="1.0" encoding="UTF-8"?>
+<svg width="10px" height="7px" viewBox="0 0 10 7" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 47.1 (45422) - http://www.bohemiancoding.com/sketch -->
+    <title>Icon-Check</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="Symbols" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="symbol" transform="translate(-11.000000, -53.000000)" stroke-width="1.5" stroke="#0044CA">
+            <g id="Icon/Check" transform="translate(8.000000, 48.000000)">
+                <polyline id="Stroke-3" points="12.1224889 5.68533333 7.20337778 10.6044444 4.11893333 7.65688889"></polyline>
+            </g>
+        </g>
+    </g>
+</svg>`

+ 38 - 6
src/components/molecules/Dropdown/index.jsx

@@ -25,6 +25,8 @@ import Palette from '../../styleUtils/Palette'
 import DomUtils from '../../../utils/DomUtils'
 import StyleProps from '../../styleUtils/StyleProps'
 
+import checkmarkImage from './images/checkmark'
+
 const getWidth = props => {
   if (props.large) {
     return StyleProps.inputSizes.large.width - 2
@@ -68,13 +70,27 @@ const Tip = styled.div`
   z-index: 11;
   transition: all ${StyleProps.animations.swift};
 `
+const Checkmark = styled.div`
+  ${StyleProps.exactWidth('16px')}
+  height: 16px;
+  margin-right: 8px;
+  margin-top: 1px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  #symbol {
+    transition: all ${StyleProps.animations.swift};
+  }
+`
 const ListItem = styled.div`
   position: relative;
+  display: flex;
   color: ${props => props.selected ? 'white' : props.dim ? Palette.grayscale[3] : Palette.grayscale[4]};
   ${props => props.selected ? css`background: ${Palette.primary};` : ''}
   ${props => props.selected ? css`font-weight: ${StyleProps.fontWeights.medium};` : ''}
   padding: 8px 16px;
   transition: all ${StyleProps.animations.swift};
+  padding-left: ${props => props.paddingLeft}px;
 
   &:first-child {
     border-top-left-radius: ${StyleProps.borderRadius};
@@ -89,6 +105,9 @@ const ListItem = styled.div`
   &:hover {
     background: ${Palette.primary};
     color: white;
+    ${Checkmark} #symbol {
+      stroke: white;
+    }
   }
 `
 const DuplicatedLabel = styled.div`
@@ -106,6 +125,7 @@ const Separator = styled.div`
   margin: 8px 16px;
   background: ${Palette.grayscale[3]};
 `
+const Labels = styled.div``
 
 type Props = {
   selectedItem: any,
@@ -120,8 +140,10 @@ type Props = {
   width: number,
   'data-test-id'?: string,
   embedded?: boolean,
-  required?: boolean,
   dimFirstItem?: boolean,
+  multipleSelection?: boolean,
+  selectedItems?: string[],
+  highlight?: boolean,
 }
 type State = {
   showDropdownList: boolean,
@@ -187,7 +209,7 @@ class Dropdown extends React.Component<Props, State> {
       return this.props.noSelectionMessage
     }
 
-    return (item[labelField] !== null && item[labelField] !== undefined && item[labelField].toString()) || item.toString()
+    return (item[labelField] !== null && item[labelField] !== undefined && item[labelField].toString()) || (item.value && item.value.toString()) || item.toString()
   }
 
   getValue(item: any) {
@@ -228,7 +250,9 @@ class Dropdown extends React.Component<Props, State> {
   }
 
   handleItemClick(item: any) {
-    this.setState({ showDropdownList: false, firstItemHover: false })
+    if (!this.props.multipleSelection) {
+      this.setState({ showDropdownList: false, firstItemHover: false })
+    }
 
     if (this.props.onChange) {
       this.props.onChange(item)
@@ -318,6 +342,7 @@ class Dropdown extends React.Component<Props, State> {
             let label = this.getLabel(item)
             let value = this.getValue(item)
             let duplicatedLabel = duplicatedLabels.find(l => l === label)
+            let multipleSelected = this.props.selectedItems && this.props.selectedItems.find(i => i === value)
             let listItem = (
               <ListItem
                 data-test-id="dropdownListItem"
@@ -327,11 +352,15 @@ class Dropdown extends React.Component<Props, State> {
                 onMouseEnter={() => { this.handleItemMouseEnter(i) }}
                 onMouseLeave={() => { this.handleItemMouseLeave(i) }}
                 onClick={() => { this.handleItemClick(item) }}
-                selected={value === selectedValue}
+                selected={!this.props.multipleSelection && value === selectedValue}
                 dim={this.props.dimFirstItem && i === 0}
+                paddingLeft={this.props.multipleSelection ? 8 : 16}
               >
-                {label}
-                {duplicatedLabel ? <DuplicatedLabel> (<span>{value || ''}</span>)</DuplicatedLabel> : ''}
+                {this.props.multipleSelection ? <Checkmark dangerouslySetInnerHTML={{ __html: multipleSelected ? checkmarkImage : '' }} /> : null}
+                <Labels>
+                  {label}
+                  {duplicatedLabel ? <DuplicatedLabel> (<span>{value || ''}</span>)</DuplicatedLabel> : ''}
+                </Labels>
               </ListItem>
             )
 
@@ -347,6 +376,9 @@ class Dropdown extends React.Component<Props, State> {
   render() {
     let buttonValue = () => {
       if (this.props.items && this.props.items.length) {
+        if (this.props.multipleSelection && this.props.selectedItems && this.props.selectedItems.length > 0) {
+          return this.props.selectedItems.map(i => this.getLabel(this.props.items.find(item => this.getValue(item) === i))).join(', ')
+        }
         return this.getLabel(this.props.selectedItem)
       }
 

+ 51 - 1
src/components/molecules/Dropdown/story.jsx

@@ -12,6 +12,8 @@ You should have received a copy of the GNU Affero General Public License
 along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
 
+// @flow
+
 import React from 'react'
 import { storiesOf } from '@storybook/react'
 import Dropdown from '.'
@@ -23,7 +25,7 @@ const items = [
   { label: 'Item 3', value: 'item-3-duplicated' },
 ]
 
-class Wrapper extends React.Component {
+class Wrapper extends React.Component<any, any> {
   constructor() {
     super()
     this.state = {
@@ -47,6 +49,44 @@ class Wrapper extends React.Component {
   }
 }
 
+type Props = {
+  items: any[],
+}
+type State = {
+  selectedItems: string[],
+}
+
+/* eslint react/no-multi-comp: off */
+class MultipleSelectionWrapper extends React.Component<Props, State> {
+  state = {
+    selectedItems: [],
+  }
+
+  render() {
+    return (
+      <Dropdown
+        multipleSelection
+        selectedItems={this.state.selectedItems}
+        onChange={item => {
+          console.log('state', this.state)
+          let itemIndex = this.state.selectedItems.findIndex(i => i === item.value)
+          if (itemIndex > -1) {
+            this.setState({
+              selectedItems: this.state.selectedItems.filter(i => i !== item.value),
+            })
+          } else {
+            this.setState({
+              selectedItems: [...this.state.selectedItems, item.value],
+            })
+          }
+        }}
+        items={this.props.items}
+      />
+    )
+  }
+}
+
+
 storiesOf('Dropdown', module)
   .add('default', () => (
     <Wrapper />
@@ -84,3 +124,13 @@ storiesOf('Dropdown', module)
       ]}
     />
   ))
+  .add('multiple selection', () => (
+    <MultipleSelectionWrapper
+      items={[
+        { value: 'owner' },
+        { value: 'admin' },
+        { value: 'member_1', label: 'member' },
+        { value: 'member_2', label: 'member' },
+      ]}
+    />
+  ))

+ 0 - 3
src/components/molecules/DropdownInput/index.jsx

@@ -56,7 +56,6 @@ type Props = {
   inputValue: string,
   onInputChange: (value: string) => void,
   placeholder?: string,
-  required?: boolean,
   highlight?: boolean,
   disabled?: boolean,
 }
@@ -80,12 +79,10 @@ class DropdownInput extends React.Component<Props, State> {
           embedded
           width="146px"
           style={{ paddingLeft: '8px', height: '30px' }}
-          required={this.props.required}
           value={this.props.inputValue}
           onChange={e => { this.props.onInputChange(e.target.value) }}
           placeholder={this.props.placeholder}
           disabled={this.props.disabled}
-          requiredStyle={{ top: '12px' }}
         />
       </Wrapper>
     )

+ 57 - 19
src/components/molecules/DropdownLink/index.jsx

@@ -40,7 +40,7 @@ const LinkButton = styled.div`
 const List = styled.div`
   position: absolute;
   z-index: 1001;
-  padding: 8px 16px 8px 8px;
+  padding: 8px;
   background: ${Palette.grayscale[1]};
   border-radius: 4px;
   border: 1px solid ${Palette.grayscale[0]};
@@ -72,6 +72,7 @@ const ListItem = styled.div`
   color: ${props => props.selected ? Palette.primary : Palette.grayscale[4]};
   cursor: pointer;
   display: flex;
+  align-items: center;
 
   &:first-child {
     padding-top: 0;
@@ -81,6 +82,8 @@ const ListItemLabel = styled.div`
   word-break: break-all;
   word-break: break-word;
   ${props => props.highlighted ? `font-weight: ${StyleProps.fontWeights.medium};` : ''}
+  ${props => props.addMargin ? css`margin-left: ${props.addMargin}px;` : ''}
+  ${props => props.customStyle}
 `
 const Checkmark = styled.div`
   ${StyleProps.exactWidth('16px')}
@@ -98,7 +101,11 @@ const Arrow = styled.div`
   width: 16px;
   height: 16px;
   margin-left: 4px;
-  margin-top: -3px;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  ${props => props.orientation === 'right' ? css`transform: rotate(-90deg);` : ''}
+  ${props => props.orientation === 'left' ? css`transform: rotate(90deg);` : ''}
 `
 const EmptySearch = styled.div`
   margin-top: 8px;
@@ -122,9 +129,16 @@ type Props = {
   searchable?: boolean,
   disabled?: boolean,
   secondary?: boolean,
+  multipleSelection?: boolean,
+  selectedItems?: string[],
   'data-test-id'?: string,
   linkButtonStyle?: any,
   arrowImage?: (color: string) => string,
+  noCheckmark?: boolean,
+  itemStyle?: (item: ItemType) => string,
+  style?: { [mixed]: any },
+  labelStyle?: any,
+  getLabel?: () => string
 }
 type State = {
   showDropdownList: boolean,
@@ -192,7 +206,9 @@ class DropdownLink extends React.Component<Props, State> {
   }
 
   getFilteredItems() {
-    return this.props.items.filter(item =>
+    let items = this.props.items
+
+    return items.filter(item =>
       item.value.toLowerCase().indexOf(this.state.searchText.toLowerCase()) > -1 ||
       item.label.toLowerCase().indexOf(this.state.searchText.toLowerCase()) > -1
     )
@@ -215,7 +231,9 @@ class DropdownLink extends React.Component<Props, State> {
   }
 
   handleItemClick(item: ItemType) {
-    this.setState({ showDropdownList: false })
+    if (!this.props.multipleSelection) {
+      this.setState({ showDropdownList: false })
+    }
 
     if (this.props.onChange) {
       this.props.onChange(item)
@@ -291,6 +309,35 @@ class DropdownLink extends React.Component<Props, State> {
     return <EmptySearch>No items found</EmptySearch>
   }
 
+  renderItem(item: ItemType) {
+    let highlighted = item.value !== this.props.selectedItem ? item.value === this.props.highlightedItem : false
+    let label = item.label || item.value.toString().charAt(0).toUpperCase() + item.value.toString().substr(1)
+    let selected
+
+    if (this.props.multipleSelection && this.props.selectedItems) {
+      selected = Boolean(this.props.selectedItems.find(i => i === item.value))
+    } else {
+      selected = item.value === this.props.selectedItem
+    }
+
+    return (
+      <ListItem
+        key={item.label || item.value}
+        onMouseDown={() => { this.itemMouseDown = true }}
+        onMouseUp={() => { this.itemMouseDown = false }}
+        onClick={() => { this.handleItemClick(item) }}
+        selected={selected}
+      >
+        {!this.props.noCheckmark ? <Checkmark show={selected} /> : null}
+        <ListItemLabel
+          highlighted={highlighted}
+          addMargin={this.props.noCheckmark ? 8 : 0}
+          customStyle={this.props.itemStyle ? this.props.itemStyle(item) : ''}
+        >{label}</ListItemLabel>
+      </ListItem>
+    )
+  }
+
   renderListItems() {
     if (this.state.searchText && this.getFilteredItems().length === 0) {
       return null
@@ -299,21 +346,7 @@ class DropdownLink extends React.Component<Props, State> {
     return (
       <ListItems innerRef={ref => { this.listItemsRef = ref }} searchable={this.props.searchable}>
         {this.getFilteredItems().map((item) => {
-          let highlighted = item.value !== this.props.selectedItem ? item.value === this.props.highlightedItem : false
-          let listItem = (
-            <ListItem
-              key={item.label}
-              onMouseDown={() => { this.itemMouseDown = true }}
-              onMouseUp={() => { this.itemMouseDown = false }}
-              onClick={() => { this.handleItemClick(item) }}
-              selected={item.value === this.props.selectedItem}
-            >
-              <Checkmark show={item.value === this.props.selectedItem} />
-              <ListItemLabel highlighted={highlighted}>{item.label}</ListItemLabel>
-            </ListItem>
-          )
-
-          return listItem
+          return this.renderItem(item)
         })}
       </ListItems>
     )
@@ -337,6 +370,9 @@ class DropdownLink extends React.Component<Props, State> {
 
   render() {
     let renderLabel = () => {
+      if (this.props.getLabel) {
+        return this.props.getLabel()
+      }
       if (this.props.items && this.props.items.length && this.props.selectedItem) {
         let item = this.props.items.find(i => i.value === this.props.selectedItem)
         if (item && item.label) {
@@ -357,6 +393,7 @@ class DropdownLink extends React.Component<Props, State> {
         onMouseDown={() => { this.itemMouseDown = true }}
         onMouseUp={() => { this.itemMouseDown = false }}
         data-test-id={this.props['data-test-id'] || 'dropdownLink'}
+        style={this.props.style}
       >
         <LinkButton
           onClick={() => this.handleButtonClick()}
@@ -367,6 +404,7 @@ class DropdownLink extends React.Component<Props, State> {
             secondary={this.props.secondary}
             innerRef={label => { this.labelRef = label }}
             data-test-id="dropdownLink-label"
+            style={this.props.labelStyle}
           >{renderLabel()}</Label>
           <Arrow
             innerRef={arrow => { this.arrowRef = arrow }}

+ 46 - 12
src/components/molecules/DropdownLink/story.jsx

@@ -17,22 +17,39 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 import React from 'react'
 import { storiesOf } from '@storybook/react'
 import DropdownLink from '.'
+import Palette from '../../styleUtils/Palette'
 
-class Wrapper extends React.Component<any, any> {
-  constructor() {
-    super()
-    this.state = {
-      items: [
-        { label: 'Item 1', value: 'item-1' },
-        { label: 'Item 2', value: 'item-2' },
-        { label: 'Item 3', value: 'item-3' },
-      ],
-      selectedItem: 'item-1',
-    }
+type Props = {
+  multipleSelection?: boolean,
+  getLLabel?: (items: string[]) => string,
+}
+type State = {
+  items: { label: string, value: string }[],
+  selectedItem: string,
+  selectedItems: string[],
+}
+class Wrapper extends React.Component<Props, State> {
+  state = {
+    items: [
+      { label: 'Item 1', value: 'item-1' },
+      { label: 'Item 2', value: 'item-2' },
+      { label: 'Item 3', value: 'item-3' },
+    ],
+    selectedItem: 'item-1',
+    selectedItems: [],
   }
 
   handleItemChange(selectedItem) {
-    this.setState({ selectedItem })
+    if (this.props.multipleSelection) {
+      let selectedItems = this.state.selectedItems
+      if (selectedItems.find(i => i === selectedItem)) {
+        this.setState({ selectedItems: selectedItems.filter(i => i !== selectedItem) })
+      } else {
+        this.setState({ selectedItems: [...selectedItems, selectedItem] })
+      }
+    } else {
+      this.setState({ selectedItem })
+    }
   }
 
   render() {
@@ -41,7 +58,9 @@ class Wrapper extends React.Component<any, any> {
         <DropdownLink
           items={this.state.items}
           selectedItem={this.state.selectedItem}
+          selectedItems={this.state.selectedItems}
           onChange={item => { this.handleItemChange(item.value) }}
+          getLabel={this.props.getLLabel ? () => this.props.getLLabel ? this.props.getLLabel(this.state.selectedItems) : '' : undefined}
           {...this.props}
         />
       </div>
@@ -58,3 +77,18 @@ storiesOf('DropdownLink', module)
       searchable
     />
   ))
+  .add('multiple selection', () => (
+    <Wrapper
+      width="200px"
+      getLLabel={items => items.length > 0 ? items.join(', ') : 'Choose something'}
+      listWidth="120px"
+      itemStyle={item => `color: ${item.value === 'remove' ? Palette.alert : Palette.black};`}
+      multipleSelection
+      items={[
+        { value: 'owner' },
+        { value: 'admin' },
+        { value: 'member', label: 'member' },
+      ]}
+      labelStyle={{ color: Palette.black }}
+    />
+  ))

+ 22 - 0
src/components/molecules/EndpointField/images/asterisk.svg

@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="10px" height="10px" viewBox="0 0 10 10" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 50.2 (55047) - http://www.bohemiancoding.com/sketch -->
+    <title>Icon-Info Copy 8</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="Coriolis" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="square">
+        <g id="Wizard/05-Options/2-Search-Dropdown_Erorr" transform="translate(-618.000000, -586.000000)" stroke="#616870" stroke-width="1.5">
+            <g id="Forms/Items/Input-Copy" transform="translate(528.000000, 583.000000)">
+                <g id="Group">
+                    <g id="Icon-Info-Copy-8" transform="translate(90.000000, 3.000000)">
+                        <g id="Icon/Asterisk/Grey">
+                            <path d="M5,0.833333333 L5,9.16666667" id="Line"></path>
+                            <path d="M1.39156082,2.91666667 L8.60843918,7.08333333" id="Line"></path>
+                            <path d="M1.39156082,2.91666667 L8.60843918,7.08333333" id="Line" transform="translate(5.000000, 5.000000) scale(-1, 1) translate(-5.000000, -5.000000) "></path>
+                        </g>
+                    </g>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 59 - 18
src/components/molecules/EndpointField/index.jsx

@@ -25,23 +25,34 @@ import InfoIcon from '../../atoms/InfoIcon'
 import Dropdown from '../../molecules/Dropdown'
 import DropdownInput from '../../molecules/DropdownInput'
 import TextArea from '../../atoms/TextArea'
-import type { Field as FieldType } from '../../../types/Field'
 
 import LabelDictionary from '../../../utils/LabelDictionary'
 import StyleProps from '../../styleUtils/StyleProps'
 import Palette from '../../styleUtils/Palette'
 
+import asteriskImage from './images/asterisk.svg'
+
 const Wrapper = styled.div``
 const Label = styled.div`
   font-size: 10px;
   font-weight: ${StyleProps.fontWeights.medium};
   color: ${Palette.grayscale[3]};
   text-transform: uppercase;
-  margin-bottom: 4px;
+  margin-bottom: 2px;
+  display: flex;
+  align-items: center;
 `
 const LabelText = styled.span`
   margin-right: 24px;
 `
+export const Asterisk = styled.div`
+  ${StyleProps.exactSize('12px')}
+  display: inline-block;
+  background: url('${asteriskImage}') center no-repeat;
+  margin-bottom: 2px;
+  margin-left: ${props => props.marginLeft || '0px'};
+  opacity: 0.8;
+`
 
 type Props = {
   name: string,
@@ -51,16 +62,20 @@ type Props = {
   getFieldValue?: (fieldName: string) => string,
   onFieldChange?: (fieldName: string, fieldValue: string) => void,
   className?: string,
-  minimum: number,
-  maximum: number,
-  password: boolean,
-  required: boolean,
-  large: boolean,
-  highlight: boolean,
-  disabled: boolean,
-  enum?: string[],
-  items?: FieldType[],
+  minimum?: number,
+  maximum?: number,
+  password?: boolean,
+  required?: boolean,
+  large?: boolean,
+  highlight?: boolean,
+  disabled?: boolean,
+  // $FlowIssue
+  enum?: string[] | { label: string, value: string }[],
+  items?: any[],
   useTextArea?: boolean,
+  noSelectionMessage?: string,
+  noItemsMessage?: string,
+  selectedItems?: string[],
 }
 @observer
 class Field extends React.Component<Props> {
@@ -79,7 +94,6 @@ class Field extends React.Component<Props> {
     return (
       <TextInput
         data-test-id={`endpointField-textInput-${this.props.name}`}
-        required={this.props.required}
         highlight={this.props.highlight}
         type={this.props.password ? 'password' : 'text'}
         large={this.props.large}
@@ -95,7 +109,6 @@ class Field extends React.Component<Props> {
     return (
       <TextArea
         style={{ width: '100%' }}
-        required={this.props.required}
         highlight={this.props.highlight}
         value={this.props.value}
         onChange={e => { if (this.props.onChange) this.props.onChange(e.target.value) }}
@@ -111,10 +124,13 @@ class Field extends React.Component<Props> {
     }
 
     let items = this.props.enum.map(e => {
-      return {
-        label: LabelDictionary.get(e),
-        value: e,
+      if (typeof e === 'string') {
+        return {
+          label: LabelDictionary.get(e),
+          value: e,
+        }
       }
+      return e
     })
     let selectedItem = items.find(i => i.value === this.props.value)
 
@@ -123,15 +139,37 @@ class Field extends React.Component<Props> {
         data-test-id={`endpointField-dropdown-${this.props.name}`}
         large={this.props.large}
         selectedItem={selectedItem}
+        noSelectionMessage={this.props.noSelectionMessage}
+        noItemsMessage={this.props.noItemsMessage}
         items={items}
         onChange={item => { if (this.props.onChange) this.props.onChange(item.value) }}
         disabled={this.props.disabled}
-        required={this.props.required}
+        highlight={this.props.highlight}
+      />
+    )
+  }
+
+  renderArrayDropdown() {
+    return (
+      <Dropdown
+        multipleSelection
+        large={this.props.large}
+        disabled={this.props.disabled}
+        noSelectionMessage={this.props.noSelectionMessage}
+        noItemsMessage={this.props.noItemsMessage}
+        items={this.props.items}
+        selectedItems={this.props.selectedItems}
+        onChange={item => { if (this.props.onChange) this.props.onChange(item.value) }}
+        highlight={this.props.highlight}
       />
     )
   }
 
   renderIntDropdown() {
+    if (!this.props.minimum || !this.props.maximum) {
+      return null
+    }
+
     let items = []
 
     for (let i = this.props.minimum; i <= this.props.maximum; i += 1) {
@@ -149,6 +187,7 @@ class Field extends React.Component<Props> {
         items={items}
         onChange={item => { if (this.props.onChange) this.props.onChange(item.value) }}
         disabled={this.props.disabled}
+        highlight={this.props.highlight}
       />
     )
   }
@@ -186,7 +225,6 @@ class Field extends React.Component<Props> {
         inputValue={this.props.getFieldValue ? this.props.getFieldValue(fieldName) : ''}
         onInputChange={value => { if (this.props.onFieldChange) this.props.onFieldChange(fieldName, value) }}
         placeholder={LabelDictionary.get(fieldName)}
-        required={this.props.required}
         highlight={this.props.highlight}
         disabled={this.props.disabled}
       />
@@ -214,6 +252,8 @@ class Field extends React.Component<Props> {
         return this.renderTextInput()
       case 'radio':
         return this.renderRadioInput()
+      case 'array':
+        return this.renderArrayDropdown()
       default:
         return null
     }
@@ -234,6 +274,7 @@ class Field extends React.Component<Props> {
       <Label>
         <LabelText data-test-id="endpointField-label">{LabelDictionary.get(this.props.name)}</LabelText>
         {infoIcon}
+        {this.props.required ? <Asterisk marginLeft={description ? '4px' : '-16px'} /> : null}
       </Label>
     )
   }

+ 8 - 1
src/components/molecules/Modal/index.jsx

@@ -40,6 +40,7 @@ type Props = {
   contentStyle: { [string]: mixed },
   topBottomMargin: number,
   title: string,
+  componentRef?: (ref: any) => void,
 }
 @observer
 class NewModal extends React.Component<Props> {
@@ -54,10 +55,16 @@ class NewModal extends React.Component<Props> {
   constructor() {
     super()
 
-    const self :any = this
+    const self: any = this
     self.positionModal = this.positionModal.bind(this)
   }
 
+  componentWillMount() {
+    if (this.props.componentRef) {
+      this.props.componentRef(this)
+    }
+  }
+
   componentDidMount() {
     window.addEventListener('resize', this.positionModal, true)
     setTimeout(() => { this.positionModal(0) }, 100)

+ 25 - 0
src/components/molecules/NewItemDropdown/images/project.svg

@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="36px" height="45px" viewBox="0 0 36 45" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 49.3 (51167) - http://www.bohemiancoding.com/sketch -->
+    <title>Icon-Info</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="Coriolis" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="square">
+        <g id="Replica/List/Loading" transform="translate(-1062.000000, -424.000000)" stroke="#0044CA">
+            <g id="Nav/Project-New-Bell-User" transform="translate(848.000000, 48.000000)">
+                <g id="Nav/NewButton/MenuNewButton" transform="translate(192.000000, 32.000000)">
+                    <g id="Notification-Menu-Item-Copy-4" transform="translate(0.000000, 328.000000)">
+                        <g id="Icon/Project/48" transform="translate(16.000000, 16.000000)">
+                            <g id="Group" transform="translate(6.000000, 0.000000)">
+                                <path d="M18,7.98986486 L18,0.929054054" id="Line-Copy" stroke-width="1.5"></path>
+                                <path d="M2.325,43.72629 L14.6539577,20.1065305 M21.286909,19.9932432 L33.675,43.72629" id="Triangle-Copy" stroke-width="1.5"></path>
+                                <path d="M0.9,31.2905405 L35.1,31.2905405" id="Line-Copy-2" stroke-width="1.5"></path>
+                                <ellipse id="Oval-Copy" stroke-width="1.5" cx="18" cy="14.3445946" rx="5.7" ry="5.64864865"></ellipse>
+                            </g>
+                        </g>
+                    </g>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 26 - 0
src/components/molecules/NewItemDropdown/images/user.svg

@@ -0,0 +1,26 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="48px" height="48px" viewBox="0 0 48 48" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 49.3 (51167) - http://www.bohemiancoding.com/sketch -->
+    <title>Icon-Info</title>
+    <desc>Created with Sketch.</desc>
+    <defs>
+        <path d="M24,48 C37.254834,48 48,37.254834 48,24 C48,10.745166 37.254834,0 24,0 C10.745166,0 0,10.745166 0,24 C0,37.254834 10.745166,48 24,48 Z" id="path-1"></path>
+    </defs>
+    <g id="Coriolis" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Replica/List/Loading" transform="translate(-1056.000000, -344.000000)">
+            <g id="Nav/Project-New-Bell-User" transform="translate(848.000000, 48.000000)">
+                <g id="Nav/NewButton/MenuNewButton" transform="translate(192.000000, 32.000000)">
+                    <g id="Notification-Menu-Item-Copy-3" transform="translate(0.000000, 248.000000)">
+                        <g id="Icon/User/48" transform="translate(16.000000, 16.000000)">
+                            <mask id="mask-2" fill="white">
+                                <use xlink:href="#path-1"></use>
+                            </mask>
+                            <path stroke="#0044CA" stroke-width="1.5" d="M24,47.25 C36.8406204,47.25 47.25,36.8406204 47.25,24 C47.25,11.1593796 36.8406204,0.75 24,0.75 C11.1593796,0.75 0.75,11.1593796 0.75,24 C0.75,36.8406204 11.1593796,47.25 24,47.25 Z"></path>
+                            <path d="M33.7740409,38.0628286 C30.5982144,36.7839655 29.6,36.2542491 29.6,35.1744186 L29.6,30.5066817 L29.9026116,30.2817711 C32.151222,28.610532 33.5,25.9724927 33.5,23.255814 L33.5,17.5348837 C33.5,12.7087025 29.4519853,8.75 24.5,8.75 C19.5480147,8.75 15.5,12.7087025 15.5,17.5348837 L15.5,23.255814 C15.5,26.0049523 16.8733664,28.7155027 19.0817885,30.2704847 L19.4,30.494542 L19.4,35.1744186 C19.4,36.0349478 18.7082643,36.4600118 16.538098,37.4641039 C16.3986307,37.5286326 16.252693,37.5955062 16.0715009,37.6780524 C15.8644645,37.7722622 15.8644645,37.7722622 15.6596988,37.8653721 C15.4985721,37.9386988 15.3757777,37.9948053 15.2268269,38.0624768 C8.59973358,40.7496703 5.75,43.1090972 5.75,48.0465116 L5.75,48.25 L43.25,48.25 L43.25,48.0465116 C43.25,43.1090352 40.3999124,40.7489696 33.7740409,38.0628286 Z" id="Fill-1" stroke="#0044CA" stroke-width="1.5" mask="url(#mask-2)"></path>
+                        </g>
+                    </g>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 43 - 18
src/components/molecules/NewItemDropdown/index.jsx

@@ -22,10 +22,15 @@ import DropdownButton from '../../atoms/DropdownButton'
 
 import Palette from '../../styleUtils/Palette'
 import StyleProps from '../../styleUtils/StyleProps'
+import userStore from '../../../stores/UserStore'
 
 import migrationImage from './images/migration.svg'
 import replicaImage from './images/replica.svg'
 import endpointImage from './images/endpoint.svg'
+import userImage from './images/user.svg'
+import projectImage from './images/project.svg'
+
+import { navigationMenu } from '../../../config'
 
 const Wrapper = styled.div`
   position: relative;
@@ -86,6 +91,12 @@ const getIcon = props => {
   if (props.replica) {
     return replicaImage
   }
+  if (props.user) {
+    return userImage
+  }
+  if (props.project) {
+    return projectImage
+  }
   return endpointImage
 }
 
@@ -166,6 +177,7 @@ class NewItemDropdown extends React.Component<Props, State> {
       return null
     }
 
+    const isAdmin = userStore.loggedUser ? userStore.loggedUser.isAdmin : false
     let items: ItemType[] = [{
       title: 'Migration',
       href: '/#/wizard/migration',
@@ -181,28 +193,41 @@ class NewItemDropdown extends React.Component<Props, State> {
       value: 'endpoint',
       description: 'Add connection information for a cloud',
       icon: { endpoint: true },
+    }, {
+      title: 'User',
+      value: 'user',
+      description: 'Create a new Coriolis user',
+      icon: { user: true },
+      disabled: navigationMenu.find(i => i.value === 'users' && (i.disabled || (i.requiresAdmin && !isAdmin))),
+    }, {
+      title: 'Project',
+      value: 'project',
+      description: 'Create a new Coriolis project',
+      icon: { project: true },
+      disabled: navigationMenu.find(i => i.value === 'projects' && (i.disabled || (i.requiresAdmin && !isAdmin))),
     }]
 
     let list = (
       <List>
-        {items.map(item => {
-          return (
-            <ListItem
-              data-test-id={`newItemDropdown-listItem-${item.title}`}
-              key={item.title}
-              onMouseDown={() => { this.itemMouseDown = true }}
-              onMouseUp={() => { this.itemMouseDown = false }}
-              href={item.href}
-              onClick={() => { this.handleItemClick(item) }}
-            >
-              <Icon {...item.icon} />
-              <Content>
-                <Title>{item.title}</Title>
-                <Description>{item.description}</Description>
-              </Content>
-            </ListItem>
-          )
-        })}
+        {
+          items.filter(i => i.disabled ? !i.disabled : i.requiresAdmin ? isAdmin : true).map(item => {
+            return (
+              <ListItem
+                data-test-id={`newItemDropdown-listItem-${item.title}`}
+                key={item.title}
+                onMouseDown={() => { this.itemMouseDown = true }}
+                onMouseUp={() => { this.itemMouseDown = false }}
+                href={item.href}
+                onClick={() => { this.handleItemClick(item) }}
+              >
+                <Icon {...item.icon} />
+                <Content>
+                  <Title>{item.title}</Title>
+                  <Description>{item.description}</Description>
+                </Content>
+              </ListItem>
+            )
+          })}
       </List>
     )
 

+ 29 - 0
src/components/molecules/ProjectListItem/images/project.svg

@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="48px" height="48px" viewBox="0 0 48 48" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 49.3 (51167) - http://www.bohemiancoding.com/sketch -->
+    <title>Icon-Replica-White</title>
+    <desc>Created with Sketch.</desc>
+    <defs>
+        <path d="M24,48 C37.254834,48 48,37.254834 48,24 C48,10.745166 37.254834,0 24,0 C10.745166,0 0,10.745166 0,24 C0,37.254834 10.745166,48 24,48 Z" id="path-1"></path>
+    </defs>
+    <g id="Coriolis" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Project/List" transform="translate(-400.000000, -200.000000)">
+            <g id="Item-Connection-Copy" transform="translate(352.000000, 192.000000)">
+                <g id="Icon/Project/ProjectListItem" transform="translate(48.000000, 8.000000)">
+                    <mask id="mask-2" fill="white">
+                        <use xlink:href="#path-1"></use>
+                    </mask>
+                    <use id="Pat-Benetar" fill="#C8CCD7" fill-rule="evenodd" xlink:href="#path-1"></use>
+                    <g id="Group" stroke-width="1" fill-rule="evenodd" mask="url(#mask-2)" stroke="#0044CA">
+                        <g transform="translate(12.000000, 8.000000)" stroke-width="1.5">
+                            <path d="M12,5.5 L12,0.5" id="Line-Copy" stroke-linecap="square"></path>
+                            <path d="M1,30.8061767 L9.65190013,14.0802226 M14.3066028,14 L23,30.8061767" id="Triangle-Copy"></path>
+                            <path d="M0,22 L24,22" id="Line-Copy-2"></path>
+                            <circle id="Oval-Copy" cx="12" cy="10" r="4"></circle>
+                        </g>
+                    </g>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 146 - 0
src/components/molecules/ProjectListItem/index.jsx

@@ -0,0 +1,146 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import React from 'react'
+import styled from 'styled-components'
+import { observer } from 'mobx-react'
+
+import Button from '../../atoms/Button'
+import type { Project } from '../../../types/Project'
+import Palette from '../../styleUtils/Palette'
+import StyleProps from '../../styleUtils/StyleProps'
+
+import projectImage from './images/project.svg'
+
+const Content = styled.div`
+  display: flex;
+  align-items: center;
+  border-top: 1px solid ${Palette.grayscale[1]};
+  padding: 8px 16px;
+  cursor: pointer;
+  flex-grow: 1;
+  transition: all ${StyleProps.animations.swift};
+  min-width: 785px;
+
+  &:hover {
+    background: ${Palette.grayscale[1]};
+  }
+`
+const Wrapper = styled.div`
+  display: flex;
+  align-items: center;
+
+  &:last-child ${Content} {
+    border-bottom: 1px solid ${Palette.grayscale[1]};
+  }
+`
+const Image = styled.div`
+  min-width: 48px;
+  height: 48px;
+  background: url('${projectImage}') no-repeat center;
+  margin-right: 16px;
+`
+const Title = styled.div`
+  flex-grow: 1;
+  overflow: hidden;
+  margin-right: 48px;
+  min-width: 100px;
+`
+const TitleLabel = styled.div`
+  font-size: 16px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`
+const Subtitle = styled.div`
+  color: ${Palette.grayscale[4]};
+  margin-top: 3px;
+`
+const ItemLabel = styled.div`
+  color: ${Palette.grayscale[4]};
+`
+const ItemValue = styled.div`
+  color: ${Palette.primary};
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+`
+const bodyWidth = 620
+const Body = styled.div`
+  ${StyleProps.exactWidth(`${bodyWidth}px`)}
+  display: flex;
+`
+const Data = styled.div`
+  ${props => StyleProps.exactWidth(`${Math.floor(bodyWidth / (100 / props.percentage)) - 68}px`)}
+  margin: 0 32px;
+
+  &:last-child {
+    margin-right: 0;
+  }
+`
+
+type Props = {
+  item: Project,
+  onClick: () => void,
+  getMembers: (projectId: string) => number,
+  isCurrentProject: (projectId: string) => boolean,
+  onSwitchProjectClick: (projectId: string) => void,
+}
+@observer
+class ProjectListItem extends React.Component<Props> {
+  render() {
+    const isCurrentProject = this.props.isCurrentProject(this.props.item.id)
+
+    return (
+      <Wrapper>
+        <Content onClick={this.props.onClick}>
+          <Image />
+          <Title>
+            <TitleLabel>{this.props.item.name}</TitleLabel>
+            <Subtitle>{this.props.item.description}</Subtitle>
+          </Title>
+          <Body>
+            <Data percentage={33}>
+              <ItemLabel>Members</ItemLabel>
+              <ItemValue>
+                {this.props.getMembers(this.props.item.id)}
+              </ItemValue>
+            </Data>
+            <Data percentage={33}>
+              <ItemLabel>Enabled</ItemLabel>
+              <ItemValue>
+                {this.props.item.enabled ? 'Yes' : 'No'}
+              </ItemValue>
+            </Data>
+            <Data percentage={34}>
+              <Button
+                width="160px"
+                secondary
+                hollow
+                onMouseDown={e => { e.stopPropagation() }}
+                onMouseUp={e => { e.stopPropagation() }}
+                onClick={e => { e.stopPropagation(); this.props.onSwitchProjectClick(this.props.item.id) }}
+                disabled={isCurrentProject}
+              >{isCurrentProject ? 'Current' : 'Switch'}</Button>
+            </Data>
+          </Body>
+        </Content>
+      </Wrapper>
+    )
+  }
+}
+
+export default ProjectListItem

+ 1 - 1
src/components/molecules/Table/index.jsx

@@ -66,7 +66,7 @@ const HeaderData = styled.div`
 const Body = styled.div`
   display: flex;
   flex-direction: column;
-  max-height: 225px;
+  max-height: 238px;
   overflow: auto;
   ${props => props.customStyle}
 `

+ 15 - 4
src/components/molecules/UserDropdown/index.jsx

@@ -86,8 +86,13 @@ const ListHeader = styled.div`
     transition: all ${StyleProps.animations.swift};
   }
 `
-const Username = styled.div`
+const Username = styled.a`
   font-size: 16px;
+  color: ${Palette.black};
+  text-decoration: none;
+  &:hover {
+    color: ${Palette.primary};
+  }
 `
 const Email = styled.div`
   font-size: 10px;
@@ -97,7 +102,7 @@ const Email = styled.div`
   border-bottom: 1px solid ${Palette.grayscale[3]};
 `
 
-type User = { name: string, email: string }
+type User = { name: string, email: string, id: string }
 type DictItem = { label: string, value: string }
 type Props = {
   onItemClick: (item: DictItem) => void,
@@ -161,8 +166,14 @@ class UserDropdown extends React.Component<Props, State> {
       return null
     }
     return (
-      <ListHeader>
-        <Username data-test-id="userDropdown-username">{this.props.user.name}</Username>
+      <ListHeader
+        onMouseDown={() => { this.itemMouseDown = true }}
+        onMouseUp={() => { this.itemMouseDown = false }}
+      >
+        <Username
+          data-test-id="userDropdown-username"
+          href={`#/user/${this.props.user.id}`}
+        >{this.props.user.name}</Username>
         <Email>{this.props.user.email}</Email>
       </ListHeader>
     )

+ 22 - 0
src/components/molecules/UserListItem/images/user.svg

@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="48px" height="48px" viewBox="0 0 48 48" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 49.3 (51167) - http://www.bohemiancoding.com/sketch -->
+    <title>Icon-Replica-White</title>
+    <desc>Created with Sketch.</desc>
+    <defs>
+        <path d="M24,48 C37.254834,48 48,37.254834 48,24 C48,10.745166 37.254834,0 24,0 C10.745166,0 0,10.745166 0,24 C0,37.254834 10.745166,48 24,48 Z" id="path-1"></path>
+    </defs>
+    <g id="Coriolis" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="User/List" transform="translate(-400.000000, -200.000000)">
+            <g id="Item-Connection-Copy" transform="translate(352.000000, 192.000000)">
+                <g id="Icon/User/Item-48" transform="translate(48.000000, 8.000000)">
+                    <mask id="mask-2" fill="white">
+                        <use xlink:href="#path-1"></use>
+                    </mask>
+                    <use id="Pat-Benetar" fill="#C8CCD7" fill-rule="evenodd" xlink:href="#path-1"></use>
+                    <path d="M33.555,38.5387597 C31.8975,37.8333333 29.85,36.9253488 29.85,36.2209302 L29.85,31.6860465 C32.2875,29.7713178 33.75,26.748062 33.75,23.624031 L33.75,17.5775194 C33.75,12.0348837 29.3625,7.5 24,7.5 C18.6375,7.5 14.25,12.0348837 14.25,17.5775194 L14.25,23.624031 C14.25,26.748062 15.7125,29.872093 18.15,31.6860465 L18.15,36.2209302 C18.15,36.8255814 16.1025,37.7325581 14.445,38.5387597 C10.4475,40.251938 4.5,42.872093 4.5,49.8255814 L4.5,50.8333333 L43.5,50.8333333 L43.5,49.8255814 C43.5,42.872093 37.553475,40.251938 33.555,38.5387597" id="Fill-1" stroke="#0044CA" stroke-width="1.5" stroke-linecap="round" mask="url(#mask-2)"></path>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 136 - 0
src/components/molecules/UserListItem/index.jsx

@@ -0,0 +1,136 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import React from 'react'
+import styled from 'styled-components'
+import { observer } from 'mobx-react'
+
+import type { User } from '../../../types/User'
+import Palette from '../../styleUtils/Palette'
+import StyleProps from '../../styleUtils/StyleProps'
+
+import userImage from './images/user.svg'
+
+const Content = styled.div`
+  display: flex;
+  align-items: center;
+  border-top: 1px solid ${Palette.grayscale[1]};
+  padding: 8px 16px;
+  cursor: pointer;
+  flex-grow: 1;
+  transition: all ${StyleProps.animations.swift};
+  min-width: 785px;
+
+  &:hover {
+    background: ${Palette.grayscale[1]};
+  }
+`
+const Wrapper = styled.div`
+  display: flex;
+  align-items: center;
+
+  &:last-child ${Content} {
+    border-bottom: 1px solid ${Palette.grayscale[1]};
+  }
+`
+const Image = styled.div`
+  min-width: 48px;
+  height: 48px;
+  background: url('${userImage}') no-repeat center;
+  margin-right: 16px;
+`
+const Title = styled.div`
+  flex-grow: 1;
+  overflow: hidden;
+  margin-right: 48px;
+  min-width: 100px;
+`
+const TitleLabel = styled.div`
+  font-size: 16px;
+  overflow: hidden;
+  white-space: nowrap;
+  text-overflow: ellipsis;
+`
+const Subtitle = styled.div`
+  color: ${Palette.grayscale[4]};
+  margin-top: 3px;
+`
+const ItemLabel = styled.div`
+  color: ${Palette.grayscale[4]};
+`
+const ItemValue = styled.div`
+  color: ${Palette.primary};
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+`
+const bodyWidth = 620
+const Body = styled.div`
+  ${StyleProps.exactWidth(`${bodyWidth}px`)}
+  display: flex;
+`
+const Data = styled.div`
+  ${props => StyleProps.exactWidth(`${Math.floor(bodyWidth / (100 / props.percentage)) - 68}px`)}
+  margin: 0 32px;
+
+  &:last-child {
+    margin-right: 0;
+  }
+`
+
+type Props = {
+  item: User,
+  onClick: () => void,
+  getProjectName: (projectId: ?string) => string,
+}
+@observer
+class EndpointListItem extends React.Component<Props> {
+  render() {
+    return (
+      <Wrapper>
+        <Content onClick={this.props.onClick}>
+          <Image />
+          <Title>
+            <TitleLabel>{this.props.item.name}</TitleLabel>
+            <Subtitle>{this.props.item.description}</Subtitle>
+          </Title>
+          <Body>
+            <Data percentage={45}>
+              <ItemLabel>Email</ItemLabel>
+              <ItemValue>
+                {this.props.item.email || '-'}
+              </ItemValue>
+            </Data>
+            <Data percentage={35}>
+              <ItemLabel>Primary Project</ItemLabel>
+              <ItemValue>
+                {this.props.getProjectName(this.props.item.project_id)}
+              </ItemValue>
+            </Data>
+            <Data percentage={20}>
+              <ItemLabel>Enabled</ItemLabel>
+              <ItemValue>
+                {this.props.item.enabled ? 'Yes' : 'No'}
+              </ItemValue>
+            </Data>
+          </Body>
+        </Content>
+      </Wrapper>
+    )
+  }
+}
+
+export default EndpointListItem

+ 1 - 1
src/components/organisms/DetailsContentHeader/index.jsx

@@ -85,7 +85,7 @@ type Props = {
   typeImage?: string,
   buttonLabel?: string,
   statusLabel?: string,
-  item: ?MainItem,
+  item: ?any,
   alertInfoPill?: boolean,
   primaryInfoPill?: boolean,
   alertButton?: boolean,

+ 9 - 9
src/components/organisms/FilterList/index.jsx

@@ -27,28 +27,28 @@ const Wrapper = styled.div``
 
 type DictItem = { value: string, label: string }
 type Props = {
-  items: MainItem[],
+  items: any[],
   actions?: DictItem[],
   loading: boolean,
   onReloadButtonClick: () => void,
-  onItemClick: (item: MainItem) => void,
-  onActionChange?: (selectedItems: MainItem[], actionValue: string) => void,
+  onItemClick: (item: any) => void,
+  onActionChange?: (selectedItems: any[], actionValue: string) => void,
   selectionLabel: string,
   renderItemComponent: (componentProps: ItemComponentProps) => React.Node,
-  itemFilterFunction: (item: MainItem, filterStatus?: ?string, filterState?: string) => boolean,
+  itemFilterFunction: (item: any, filterStatus?: ?string, filterState?: string) => boolean,
   filterItems: DictItem[],
-  emptyListImage: ?string,
-  emptyListMessage: string,
-  emptyListExtraMessage: string,
+  emptyListImage?: ?string,
+  emptyListMessage?: string,
+  emptyListExtraMessage?: string,
   emptyListButtonLabel?: string,
   onEmptyListButtonClick?: () => void,
   customFilterComponent?: React.Node,
 }
 type State = {
-  items: MainItem[],
+  items: any[],
   filterStatus: string,
   filterText: string,
-  selectedItems: MainItem[],
+  selectedItems: any[],
   selectAllSelected?: boolean,
 }
 @observer

+ 2 - 1
src/components/organisms/LoginForm/index.jsx

@@ -86,7 +86,7 @@ const LoginErrorText = styled.div`
 type Props = {
   className: string,
   loading: boolean,
-  loginFailedResponse: { status: string },
+  loginFailedResponse: { status: string, message?: string },
   onFormSubmit: (credentials: { username: string, password: string }) => void,
 }
 type State = {
@@ -139,6 +139,7 @@ class LoginForm extends React.Component<Props, State> {
           errorMessage = 'The username or password did not match. Please try again.'
           break
         default:
+          errorMessage = this.props.loginFailedResponse.message || errorMessage
       }
     }
 

+ 5 - 5
src/components/organisms/MainList/index.jsx

@@ -69,22 +69,22 @@ const EmptyListExtraMessage = styled.div`
 `
 export type ItemComponentProps = {
   key: string,
-  item: MainItem,
+  item: any,
   selected: boolean,
   onClick: () => void,
   onSelectedChange: (checked: boolean) => void
 }
 type Props = {
-  items: MainItem[],
-  selectedItems: MainItem[],
+  items: any[],
+  selectedItems: any[],
   loading: boolean,
   onSelectedChange: (item: MainItem, checked: boolean) => void,
   onItemClick: (item: MainItem) => void,
   renderItemComponent: (componentProps: ItemComponentProps) => React.Node,
   showEmptyList: boolean,
   emptyListImage: ?string,
-  emptyListMessage: string,
-  emptyListExtraMessage: string,
+  emptyListMessage?: string,
+  emptyListExtraMessage?: string,
   emptyListButtonLabel?: string,
   onEmptyListButtonClick?: () => void,
 }

+ 8 - 2
src/components/organisms/Navigation/index.jsx

@@ -19,6 +19,7 @@ import { observer } from 'mobx-react'
 import styled from 'styled-components'
 
 import Logo from '../../atoms/Logo'
+import userStore from '../../../stores/UserStore'
 
 import { navigationMenu } from '../../../config'
 import backgroundImage from './images/star-bg.jpg'
@@ -35,7 +36,11 @@ const LogoStyled = styled(Logo) `
   cursor: pointer;
 `
 
-const Menu = styled.div`margin-top:32px;`
+const Menu = styled.div`
+  display: flex;
+  flex-direction: column;
+  margin-top:32px;
+`
 
 const MenuItem = styled.a`
   font-size: 18px;
@@ -51,9 +56,10 @@ const Footer = styled.div``
 @observer
 class Navigation extends React.Component<{ currentPage: string }> {
   renderMenu() {
+    const isAdmin = userStore.loggedUser ? userStore.loggedUser.isAdmin : false
     return (
       <Menu>
-        {navigationMenu.filter(i => i.disabled ? !i.disabled : true).map(item => {
+        {navigationMenu.filter(i => i.disabled ? !i.disabled : i.requiresAdmin ? isAdmin : true).map(item => {
           return (
             <MenuItem
               key={item.value}

+ 72 - 4
src/components/organisms/PageHeader/index.jsx

@@ -18,6 +18,8 @@ import React from 'react'
 import styled from 'styled-components'
 import { observer } from 'mobx-react'
 
+import type { User } from '../../../types/User'
+import type { Project } from '../../../types/Project'
 import Dropdown from '../../molecules/Dropdown'
 import NewItemDropdown from '../../molecules/NewItemDropdown'
 import type { ItemType } from '../../molecules/NewItemDropdown'
@@ -26,6 +28,8 @@ import UserDropdown from '../../molecules/UserDropdown'
 import Modal from '../../molecules/Modal'
 import ChooseProvider from '../../organisms/ChooseProvider'
 import Endpoint from '../../organisms/Endpoint'
+import UserModal from '../../organisms/UserModal'
+import ProjectModal from '../../organisms/ProjectModal'
 
 import projectStore from '../../../stores/ProjectStore'
 import userStore from '../../../stores/UserStore'
@@ -34,8 +38,6 @@ import providerStore from '../../../stores/ProviderStore'
 import Palette from '../../styleUtils/Palette'
 import StyleProps from '../../styleUtils/StyleProps'
 
-import type { Project } from '../../../types/Project'
-
 const Wrapper = styled.div`
   display: flex;
   margin: 48px 0;
@@ -67,6 +69,8 @@ type Props = {
 type State = {
   showChooseProviderModal: boolean,
   showEndpointModal: boolean,
+  showUserModal: boolean,
+  showProjectModal: boolean,
   providerType?: string,
 }
 @observer
@@ -77,6 +81,8 @@ class PageHeader extends React.Component<Props, State> {
     this.state = {
       showChooseProviderModal: false,
       showEndpointModal: false,
+      showUserModal: false,
+      showProjectModal: false,
     }
   }
 
@@ -85,7 +91,7 @@ class PageHeader extends React.Component<Props, State> {
   }
 
   getCurrentProject() {
-    let project = userStore.user && userStore.user.project ? userStore.user.project : null
+    let project = userStore.loggedUser && userStore.loggedUser.project ? userStore.loggedUser.project : null
     if (project) {
       return projectStore.projects.find(p => p.id === project.id)
     }
@@ -114,6 +120,19 @@ class PageHeader extends React.Component<Props, State> {
         }
         this.setState({ showChooseProviderModal: true })
         break
+      case 'user':
+        projectStore.getProjects()
+        if (this.props.onModalOpen) {
+          this.props.onModalOpen()
+        }
+        this.setState({ showUserModal: true })
+        break
+      case 'project':
+        if (this.props.onModalOpen) {
+          this.props.onModalOpen()
+        }
+        this.setState({ showProjectModal: true })
+        break
       default:
     }
   }
@@ -158,6 +177,38 @@ class PageHeader extends React.Component<Props, State> {
     })
   }
 
+  handleUserModalClose() {
+    if (this.props.onModalClose) {
+      this.props.onModalClose()
+    }
+    this.setState({ showUserModal: false })
+  }
+
+  handleUserUpdateClick(user: User) {
+    userStore.add(user).then(() => {
+      if (this.props.onModalClose) {
+        this.props.onModalClose()
+      }
+      this.setState({ showUserModal: false })
+    })
+  }
+
+  handleProjectModalClose() {
+    if (this.props.onModalClose) {
+      this.props.onModalClose()
+    }
+    this.setState({ showProjectModal: false })
+  }
+
+  handleProjectModalUpdateClick(project: Project) {
+    projectStore.add(project).then(() => {
+      if (this.props.onModalClose) {
+        this.props.onModalClose()
+      }
+      this.setState({ showProjectModal: false })
+    })
+  }
+
   render() {
     return (
       <Wrapper>
@@ -172,7 +223,7 @@ class PageHeader extends React.Component<Props, State> {
           />
           <NewItemDropdown onChange={item => { this.handleNewItem(item) }} />
           <NotificationDropdown items={notificationStore.persistedNotifications} onClose={() => this.handleNotificationsClose()} />
-          <UserDropdown user={userStore.user} onItemClick={item => { this.handleUserItemClick(item) }} />
+          <UserDropdown user={userStore.loggedUser} onItemClick={item => { this.handleUserItemClick(item) }} />
         </Controls>
         <Modal
           isOpen={this.state.showChooseProviderModal}
@@ -198,6 +249,23 @@ class PageHeader extends React.Component<Props, State> {
             onCancelClick={options => { this.handleBackEndpointModal(options) }}
           />
         </Modal>
+        {this.state.showUserModal ? (
+          <UserModal
+            isNewUser
+            loading={userStore.updating}
+            projects={projectStore.projects}
+            onRequestClose={() => { this.handleUserModalClose() }}
+            onUpdateClick={user => { this.handleUserUpdateClick(user) }}
+          />
+        ) : null}
+        {this.state.showProjectModal ? (
+          <ProjectModal
+            isNewProject
+            loading={projectStore.updating}
+            onRequestClose={() => { this.handleProjectModalClose() }}
+            onUpdateClick={project => { this.handleProjectModalUpdateClick(project) }}
+          />
+        ) : null}
       </Wrapper>
     )
   }

+ 341 - 0
src/components/organisms/ProjectDetailsContent/index.jsx

@@ -0,0 +1,341 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import React from 'react'
+import { observer } from 'mobx-react'
+import styled, { css } from 'styled-components'
+
+import AlertModal from '../../organisms/AlertModal'
+import Table from '../../molecules/Table'
+import CopyValue from '../../atoms/CopyValue'
+import CopyMultilineValue from '../../atoms/CopyMultilineValue'
+import StatusImage from '../../atoms/StatusImage'
+import DropdownLink from '../../molecules/DropdownLink'
+import Button from '../../atoms/Button'
+
+import type { Project, RoleAssignment, Role } from '../../../types/Project'
+import type { User } from '../../../types/User'
+import StyleProps from '../../styleUtils/StyleProps'
+import Palette from '../../styleUtils/Palette'
+
+const Wrapper = styled.div`
+  ${StyleProps.exactWidth(StyleProps.contentWidth)}
+  margin: 0 auto;
+  padding-left: 126px;
+`
+const Info = styled.div`
+  display: flex;
+  flex-wrap: wrap;
+  margin-top: 32px;
+  margin-left: -32px;  
+`
+const Field = styled.div`
+  ${StyleProps.exactWidth('calc(50% - 32px)')}
+  margin-bottom: 32px;
+  margin-left: 32px;
+`
+const Value = styled.div``
+const Label = styled.div`
+  font-size: 10px;
+  font-weight: ${StyleProps.fontWeights.medium};
+  color: ${Palette.grayscale[3]};
+  text-transform: uppercase;
+  margin-bottom: 3px;
+`
+const LoadingWrapper = styled.div`
+  display: flex;
+  justify-content: center;
+  width: 100%;
+  margin: 32px 0 64px 0;
+`
+const TableStyled = styled(Table) `
+  margin-top: 42px;
+  margin-bottom: 32px;
+`
+const Buttons = styled.div`
+  margin-top: 32px;
+  display: flex;
+  justify-content: space-between;
+  margin-top: 32px;
+`
+const UserColumn = styled.div`
+  ${props => props.disabled ? css`color: ${Palette.grayscale[3]};` : ''}
+`
+const UserName = styled.a`
+  ${props => props.disabled ? css`opacity: 0.7;` : ''}
+  color: ${Palette.primary};
+  text-decoration: none;
+`
+const ButtonsColumn = styled.div`
+  display: flex;
+  flex-direction: column;
+  button {
+    margin-bottom: 16px;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+`
+
+type Props = {
+  project: ?Project,
+  loading: boolean,
+  users: User[],
+  usersLoading: boolean,
+  deleteDisabled: boolean,
+  roleAssignments: RoleAssignment[],
+  roles: Role[],
+  loggedUserId: string,
+  onEnableUser: (user: User) => void,
+  onRemoveUser: (user: User) => void,
+  onUserRoleChange: (user: User, roleId: string, toggled: boolean) => void,
+  onEditProjectClick: () => void,
+  onDeleteConfirmation: () => void,
+  onAddMemberClick: () => void,
+}
+type State = {
+  showRemoveUserAlert: boolean,
+  showDeleteProjectAlert: boolean,
+}
+@observer
+class ProjectDetailsContent extends React.Component<Props, State> {
+  selectedUser: ?User
+
+  state = {
+    showRemoveUserAlert: false,
+    showDeleteProjectAlert: false,
+  }
+
+  handleRemoveUserAction(user: User) {
+    this.selectedUser = user
+    this.setState({ showRemoveUserAlert: true })
+  }
+
+  handleUserAction(user: User, item: { label: string, value: string }) {
+    switch (item.value) {
+      case 'enable':
+        this.props.onEnableUser(user)
+        break
+      case 'remove':
+        this.handleRemoveUserAction(user)
+        break
+      default:
+        break
+    }
+  }
+
+  handleRemoveUserConfirmation() {
+    if (this.selectedUser) {
+      this.props.onRemoveUser(this.selectedUser)
+    }
+
+    this.setState({ showRemoveUserAlert: false })
+  }
+
+  handleCloseRemoveUserConfirmation() {
+    this.setState({ showRemoveUserAlert: false })
+  }
+
+  handleDeleteConfirmation() {
+    this.setState({ showDeleteProjectAlert: false })
+    this.props.onDeleteConfirmation()
+  }
+
+  handleCloseDeleteConfirmation() {
+    this.setState({ showDeleteProjectAlert: false })
+  }
+
+  renderLoading() {
+    return (
+      <LoadingWrapper>
+        <StatusImage />
+      </LoadingWrapper>
+    )
+  }
+
+  renderButtons() {
+    if (this.props.loading) return null
+
+    return (
+      <Buttons>
+        <ButtonsColumn>
+          <Button
+            secondary
+            onClick={this.props.onEditProjectClick}
+          >Edit Project</Button>
+          <Button
+            onClick={this.props.onAddMemberClick}
+          >Add Member</Button>
+        </ButtonsColumn>
+        <ButtonsColumn>
+          <Button
+            alert
+            hollow
+            onClick={() => { this.setState({ showDeleteProjectAlert: true }) }}
+          >Delete Project</Button>
+        </ButtonsColumn>
+      </Buttons>
+    )
+  }
+
+  renderInfo() {
+    if (this.props.loading || !this.props.project) {
+      return null
+    }
+    const project = this.props.project
+
+    return (
+      <Info>
+        <Field>
+          <Label>Name</Label>
+          {this.renderValue(project.name)}
+        </Field>
+        <Field>
+          <Label>Description</Label>
+          {project.description ? <CopyMultilineValue value={project.description} /> : <Value>-</Value>}
+        </Field>
+        <Field>
+          <Label>ID</Label>
+          {this.renderValue(project.id)}
+        </Field>
+        <Field>
+          <Label>Enabled</Label>
+          <Value>{project.enabled ? 'Yes' : 'No'}</Value>
+        </Field>
+      </Info>
+    )
+  }
+
+  renderUsers() {
+    if (this.props.usersLoading || this.props.loading) {
+      return null
+    }
+    const rows = []
+    const actions = user => [
+      {
+        label: `${user.enabled ? 'Disable' : 'Enable'} User`,
+        value: 'enable',
+      }, {
+        label: 'Remove',
+        value: 'remove',
+      },
+    ]
+    let getUserRoles = user => {
+      let projectId = this.props.project ? this.props.project.id : ''
+      let roles = this.props.roleAssignments
+        .filter(a => a.scope.project.id === projectId)
+        .filter(a => a.user.id === user.id)
+        .map(a => { return { value: a.role.id, label: a.role.name } })
+      return roles
+    }
+    let allRoles = this.props.roles
+      .filter(r => r.name !== 'key-manager:service-admin')
+      .map(r => { return { value: r.id, label: r.name } })
+
+    this.props.users.forEach(user => {
+      let userActions = actions(user)
+      let userRoles = getUserRoles(user)
+      const columns = [
+        <UserName disabled={!user.enabled} href={`#/user/${user.id}`}>{user.name}</UserName>,
+        <DropdownLink
+          width="214px"
+          getLabel={() => userRoles.length > 0 ? userRoles.map(r => r.label).join(', ') : 'No roles'}
+          selectedItems={userRoles.map(r => r.value)}
+          listWidth="120px"
+          multipleSelection
+          items={allRoles}
+          labelStyle={{ color: Palette.grayscale[4] }}
+          disabled={!user.enabled}
+          style={{ opacity: user.enabled ? 1 : 0.7 }}
+          onChange={item => {
+            this.props.onUserRoleChange(user, item.value, !userRoles.find(i => i.value === item.value))
+          }}
+        />,
+        <UserColumn disabled={!user.enabled}>{user.enabled ? 'Enabled' : 'Disabled'}</UserColumn>,
+        <DropdownLink
+          noCheckmark
+          width="82px"
+          items={userActions}
+          selectedItem=""
+          selectItemLabel="Actions"
+          listWidth="120px"
+          onChange={item => { this.handleUserAction(user, item) }}
+          disabled={user.id === this.props.loggedUserId}
+          style={{ opacity: user.id === this.props.loggedUserId ? 0.7 : 1 }}
+          itemStyle={item => `color: ${item.value === 'remove' ? Palette.alert : Palette.black};`}
+        />,
+      ]
+      rows.push(columns)
+    })
+
+    return (
+      <TableStyled
+        header={['Member', 'Roles', 'Status', '']}
+        items={rows}
+        noItemsLabel="No members available!"
+        columnsStyle={[css`color: ${Palette.black};`]}
+      />
+    )
+  }
+
+  renderValue(value: string) {
+    return value !== '-' ? <CopyValue value={value} maxWidth="90%" /> : <Value>{value}</Value>
+  }
+
+  render() {
+    return (
+      <Wrapper>
+        {this.renderInfo()}
+        {this.props.loading ? this.renderLoading() : null}
+        {this.renderUsers()}
+        {!this.props.loading && this.props.usersLoading ? this.renderLoading() : null}
+        {this.renderButtons()}
+        {this.state.showRemoveUserAlert ? (
+          <AlertModal
+            isOpen
+            title="Remove User?"
+            message="Are you sure you want to remove this user from the project?"
+            extraMessage=" "
+            onConfirmation={() => { this.handleRemoveUserConfirmation() }}
+            onRequestClose={() => { this.handleCloseRemoveUserConfirmation() }}
+          />
+        ) : null}
+        {this.state.showDeleteProjectAlert && !this.props.deleteDisabled ? (
+          <AlertModal
+            isOpen
+            title="Delete Project?"
+            message="Are you sure you want to delete this project?"
+            extraMessage="Deleting a Coriolis Project is permanent!"
+            onConfirmation={() => { this.handleDeleteConfirmation() }}
+            onRequestClose={() => { this.handleCloseDeleteConfirmation() }}
+          />
+        ) : this.state.showDeleteProjectAlert && this.props.deleteDisabled ? (
+          <AlertModal
+            isOpen
+            type="error"
+            title="Error deleting project"
+            message="The project can't be deleted"
+            extraMessage="You can't delete the last project since you'll no longer be able to log in"
+            onRequestClose={() => { this.handleCloseDeleteConfirmation() }}
+          />
+        ) : null}
+      </Wrapper>
+    )
+  }
+}
+
+export default ProjectDetailsContent

+ 31 - 0
src/components/organisms/ProjectMemberModal/images/user.svg

@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="96px" height="96px" viewBox="0 0 96 96" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 49.3 (51167) - http://www.bohemiancoding.com/sketch -->
+    <title>User-VL Copy</title>
+    <desc>Created with Sketch.</desc>
+    <defs>
+        <path d="M48,96 C74.509668,96 96,74.509668 96,48 C96,21.490332 74.509668,0 48,0 C21.490332,0 0,21.490332 0,48 C0,74.509668 21.490332,96 48,96 Z" id="path-1"></path>
+    </defs>
+    <g id="Coriolis" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Project/Add-Member/Existing" transform="translate(-240.000000, -96.000000)">
+            <g id="Modal-Connection">
+                <g id="Icon/User/96" transform="translate(240.000000, 96.000000)">
+                    <g id="Group">
+                        <mask id="mask-2" fill="white">
+                            <use xlink:href="#path-1"></use>
+                        </mask>
+                        <g id="Pat-Benetar">
+                            <use fill="#FFFFFF" fill-rule="evenodd" xlink:href="#path-1"></use>
+                            <path stroke="#A4AAB5" stroke-width="1.5" d="M48,95.25 C74.0954544,95.25 95.25,74.0954544 95.25,48 C95.25,21.9045456 74.0954544,0.75 48,0.75 C21.9045456,0.75 0.75,21.9045456 0.75,48 C0.75,74.0954544 21.9045456,95.25 48,95.25 Z"></path>
+                        </g>
+                        <g id="Page-1" mask="url(#mask-2)" stroke="#A4AAB5" stroke-width="1.5">
+                            <g transform="translate(9.000000, 15.000000)" id="Fill-1">
+                                <path d="M58.11,58.0186047 C54.795,56.7 50.7,55.0027674 50.7,53.6860465 L50.7,45.2093023 C55.575,41.6302326 58.5,35.9790698 58.5,30.1395349 L58.5,18.8372093 C58.5,8.47674419 49.725,0 39,0 C28.275,0 19.5,8.47674419 19.5,18.8372093 L19.5,30.1395349 C19.5,35.9790698 22.425,41.8186047 27.3,45.2093023 L27.3,53.6860465 C27.3,54.8162791 23.205,56.5116279 19.89,58.0186047 C11.895,61.2209302 0,66.1186047 0,79.1162791 L0,81 L78,81 L78,79.1162791 C78,66.1186047 66.10695,61.2209302 58.11,58.0186047"></path>
+                            </g>
+                        </g>
+                    </g>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 383 - 0
src/components/organisms/ProjectMemberModal/index.jsx

@@ -0,0 +1,383 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import React from 'react'
+import { observer } from 'mobx-react'
+import styled from 'styled-components'
+
+import type { Field as FieldType } from '../../../types/Field'
+import type { User } from '../../../types/User'
+import type { Project, Role } from '../../../types/Project'
+import Button from '../../atoms/Button'
+import Modal from '../../molecules/Modal'
+import Field, { Asterisk } from '../../molecules/EndpointField'
+import ToggleButtonBar from '../../atoms/ToggleButtonBar'
+import AutocompleteDropdown from '../../molecules/AutocompleteDropdown'
+import StyleProps from '../../styleUtils/StyleProps'
+import Palette from '../../styleUtils/Palette'
+
+import userImage from './images/user.svg'
+import KeyboardManager from '../../../utils/KeyboardManager'
+
+const Wrapper = styled.div`
+  padding: 48px 32px 32px 32px;
+  display: flex;
+  flex-direction: column;
+`
+const Image = styled.div`
+  width: 96px;
+  height: 96px;
+  background: url('${userImage}') center no-repeat;
+  margin: 0 auto;
+`
+const ToggleButtonBarStyled = styled(ToggleButtonBar)`
+  margin-top: 48px;
+`
+const Form = styled.div`
+  display: flex;
+  justify-content: space-between;
+  flex-wrap: wrap;
+  margin-top: 32px;
+  overflow: auto;
+
+  > div {
+    margin-top: 16px;
+  }
+`
+const FieldStyled = styled(Field)`
+  ${StyleProps.exactWidth('224px')}
+`
+const FormField = styled.div``
+const FormLabel = styled.div`
+  font-size: 10px;
+  font-weight: ${StyleProps.fontWeights.medium};
+  color: ${Palette.grayscale[3]};
+  text-transform: uppercase;
+  margin-bottom: 2px;
+  display: flex;
+  align-items: center;
+`
+const Buttons = styled.div`
+  margin-top: 32px;
+  display: flex;
+  justify-content: space-between;
+`
+
+type Props = {
+  loading: boolean,
+  users: User[],
+  projects: Project[],
+  onRequestClose: () => void,
+  onAddClick: (user: User, isNew: boolean, roles: Role[]) => void,
+  roles: Role[],
+}
+
+type State = {
+  isNew: boolean,
+  selectedUser: ?User,
+  username: string,
+  description: string,
+  email: string,
+  projectId: string,
+  password: string,
+  confirmPassword: string,
+  enabled: boolean,
+  highlightFieldNames: string[],
+  selectedRolesExisting: string[],
+  selectedRolesNew: string[],
+}
+
+@observer
+class ProjectMemberModal extends React.Component<Props, State> {
+  state = {
+    isNew: false,
+    selectedUser: null,
+    username: '',
+    description: '',
+    email: '',
+    projectId: '',
+    password: '',
+    confirmPassword: '',
+    enabled: true,
+    highlightFieldNames: [],
+    selectedRolesExisting: [],
+    selectedRolesNew: [],
+  }
+
+  componentDidMount() {
+    KeyboardManager.onEnter('projectMemberModal', () => {
+      this.handleAddClick()
+    })
+  }
+
+  componentWillUnmount() {
+    KeyboardManager.removeKeyDown('projectMemberModal')
+  }
+
+  handleAddClick() {
+    if (this.highlightFields()) {
+      return
+    }
+    let user: User
+    let roles = []
+
+    if (this.state.isNew) {
+      user = {
+        id: '',
+        project: { id: '', name: '' },
+        project_id: this.state.projectId,
+        email: this.state.email,
+        name: this.state.username,
+        description: this.state.description,
+        password: this.state.password,
+        enabled: this.state.enabled,
+      }
+      roles = this.state.selectedRolesNew
+    } else if (this.state.selectedUser) {
+      user = this.state.selectedUser
+      roles = this.state.selectedRolesExisting
+    } else {
+      return
+    }
+
+    roles = roles.map(id => this.props.roles.find(r => r.id === id) || { id: 'undefined', name: '' })
+    this.props.onAddClick(user, this.state.isNew, roles)
+  }
+
+  highlightFields(): boolean {
+    const highlightFieldNames = []
+    if (!this.state.isNew) {
+      if (!this.state.selectedUser) {
+        highlightFieldNames.push('selectedUser')
+      }
+      if (this.state.selectedRolesExisting.length === 0) {
+        highlightFieldNames.push('rolesExisting')
+      }
+      if (highlightFieldNames.length > 0) {
+        this.setState({ highlightFieldNames })
+        return true
+      }
+      this.setState({ highlightFieldNames: [] })
+      return false
+    }
+
+    if (!this.state.username) {
+      highlightFieldNames.push('username')
+    }
+    if (!this.state.password) {
+      highlightFieldNames.push('password')
+    }
+    if (this.state.password && this.state.password !== this.state.confirmPassword) {
+      highlightFieldNames.push('confirm_password')
+    }
+    if (this.state.selectedRolesNew.length === 0) {
+      highlightFieldNames.push('rolesNew')
+    }
+    if (highlightFieldNames.length > 0) {
+      this.setState({ highlightFieldNames })
+      return true
+    }
+    this.setState({ highlightFieldNames: [] })
+    return false
+  }
+
+  renderToggleButton() {
+    const items = [{
+      value: 'existing',
+      label: 'Existing',
+    }, {
+      value: 'new',
+      label: 'New',
+    }]
+    return (
+      <ToggleButtonBarStyled
+        items={items}
+        selectedValue={this.state.isNew ? 'new' : 'existing'}
+        onChange={item => { this.setState({ isNew: item.value === 'new' }) }}
+      />
+    )
+  }
+
+  renderRolesField() {
+    let selectedRoles = this.state.isNew ? this.state.selectedRolesNew : this.state.selectedRolesExisting
+    let setSelectedRoles = (roles: string[]) => {
+      if (this.state.isNew) {
+        this.setState({ selectedRolesNew: roles })
+      } else {
+        this.setState({ selectedRolesExisting: roles })
+      }
+    }
+    let highlighFieldName = this.state.isNew ? 'rolesNew' : 'rolesExisting'
+
+    return (
+      <Field
+        name="role(s)"
+        type="array"
+        onChange={roleId => {
+          if (selectedRoles.find(id => id === roleId)) {
+            setSelectedRoles(selectedRoles.filter(r => r !== roleId))
+          } else {
+            setSelectedRoles([...selectedRoles, roleId])
+          }
+        }}
+        selectedItems={selectedRoles}
+        value={null}
+        large
+        disabled={this.props.loading}
+        items={this.props.roles.filter(r => r.name !== 'key-manager:service-admin').map(r => { return { label: r.name, value: r.id } })}
+        required
+        highlight={Boolean(this.state.highlightFieldNames.find(n => n === highlighFieldName))}
+        noSelectionMessage="Choose role(s)"
+        noItemsMessage="No available roles"
+      />
+    )
+  }
+
+  renderField(field: FieldType, value: any, onChange: (value: any) => void) {
+    return (
+      <FieldStyled
+        key={field.name}
+        name={field.name}
+        type={field.type || 'string'}
+        value={value}
+        onChange={onChange}
+        large
+        disabled={this.props.loading}
+        enum={field.enum}
+        password={field.name === 'password' || field.name === 'confirm_password'}
+        // $FlowIssue
+        required={field.required}
+        highlight={Boolean(this.state.highlightFieldNames.find(n => n === field.name))}
+        noSelectionMessage="Choose a project"
+        noItemsMessage="No available members"
+      />
+    )
+  }
+
+  renderNewForm() {
+    const userProjects = this.props.projects.map(p => { return { label: p.name, value: p.id } })
+    const fields = [
+      this.renderField(
+        { name: 'username', required: true },
+        this.state.username,
+        username => { this.setState({ username }) }
+      ),
+      this.renderField(
+        { name: 'description' },
+        this.state.description,
+        description => { this.setState({ description }) }
+      ),
+      this.renderField(
+        {
+          name: 'Primary Project',
+          // $FlowIssue
+          enum: [{ label: 'Choose a project', value: null }].concat(userProjects),
+        },
+        this.state.projectId,
+        projectId => { this.setState({ projectId }) },
+      ),
+      this.renderRolesField(),
+      this.renderField(
+        { name: 'password', required: true },
+        this.state.password,
+        password => { this.setState({ password }) }
+      ),
+      this.renderField(
+        { name: 'confirm_password', required: true },
+        this.state.confirmPassword,
+        confirmPassword => { this.setState({ confirmPassword }) }
+      ),
+      this.renderField(
+        { name: 'Email' },
+        this.state.email,
+        email => { this.setState({ email }) }
+      ),
+      this.renderField(
+        { name: 'Enabled', type: 'boolean' },
+        this.state.enabled,
+        enabled => { this.setState({ enabled }) }
+      ),
+    ]
+
+    return (
+      <Form>
+        {fields}
+      </Form>
+    )
+  }
+
+  renderExistingForm() {
+    const users = this.props.users.map(u => { return { label: u.name, value: u.id } })
+
+    return (
+      <Form style={{ marginBottom: '80px' }}>
+        <FormField>
+          <FormLabel>
+            Username
+            <Asterisk marginLeft="8px" />
+          </FormLabel>
+          <AutocompleteDropdown
+            items={users}
+            disabled={this.props.loading}
+            selectedItem={this.state.selectedUser ? this.state.selectedUser.id : ''}
+            highlight={Boolean(this.state.highlightFieldNames.find(n => n === 'selectedUser'))}
+            onChange={item => {
+              this.setState({ selectedUser: this.props.users.find(u => u.id === item.value) })
+            }}
+          />
+        </FormField>
+        {this.renderRolesField()}
+      </Form>
+    )
+  }
+
+  renderForm() {
+    if (this.state.isNew) {
+      return this.renderNewForm()
+    }
+    return this.renderExistingForm()
+  }
+
+  render() {
+    return (
+      <Modal
+        isOpen
+        title="Add Project Member"
+        onRequestClose={this.props.onRequestClose}
+      >
+        <Wrapper>
+          <Image />
+          {this.renderToggleButton()}
+          {this.renderForm()}
+          <Buttons>
+            <Button
+              secondary
+              large
+              onClick={this.props.onRequestClose}
+            >Cancel</Button>
+            <Button
+              large
+              disabled={this.props.loading}
+              onClick={() => { this.handleAddClick() }}
+            >Add Member</Button>
+          </Buttons>
+        </Wrapper>
+      </Modal>
+    )
+  }
+}
+
+export default ProjectMemberModal

+ 19 - 0
src/components/organisms/ProjectModal/images/project.svg

@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="76px" height="97px" viewBox="0 0 76 97" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 49.3 (51167) - http://www.bohemiancoding.com/sketch -->
+    <title>User-VL Copy</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="Coriolis" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Project/New-Project" transform="translate(-250.000000, -96.000000)" stroke="#A4AAB5" stroke-width="1.5">
+            <g id="Modal-Connection">
+                <g id="Icon/Project/96" transform="translate(240.000000, 96.000000)">
+                    <path d="M47.7375,16.875 L47.7375,0" id="Line"></path>
+                    <polyline id="Triangle" points="13.2 96 48 26.4 48 26.4 82.8 96"></polyline>
+                    <circle id="Oval" fill="#FFFFFF" fill-rule="evenodd" stroke-linecap="round" cx="48" cy="30" r="13.2"></circle>
+                    <path d="M10.5,67.2375 L85.5,67.2375" id="Line"></path>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 190 - 0
src/components/organisms/ProjectModal/index.jsx

@@ -0,0 +1,190 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import React from 'react'
+import { observer } from 'mobx-react'
+import styled from 'styled-components'
+
+import type { Project } from '../../../types/Project'
+import type { Field as FieldType } from '../../../types/Field'
+import Button from '../../atoms/Button'
+import Modal from '../../molecules/Modal'
+import Field from '../../molecules/EndpointField'
+
+import projectImage from './images/project.svg'
+import KeyboardManager from '../../../utils/KeyboardManager'
+
+const Wrapper = styled.div`
+  padding: 48px 32px 32px 32px;
+`
+const Image = styled.div`
+  width: 96px;
+  height: 96px;
+  background: url('${projectImage}') center no-repeat;
+  margin: 0 auto;
+`
+const Form = styled.div`
+  display: flex;
+  justify-content: space-between;
+  flex-wrap: wrap;
+  margin-top: 64px;
+
+  > div {
+    margin-top: 16px;
+  }
+`
+const Buttons = styled.div`
+  margin-top: 32px;
+  display: flex;
+  justify-content: space-between;
+`
+
+type Props = {
+  project?: ?Project,
+  isNewProject?: boolean,
+  loading: boolean,
+  onRequestClose: () => void,
+  onUpdateClick: (project: Project) => void,
+}
+
+type State = {
+  name: string,
+  enabled: boolean,
+  highlightFieldNames: string[],
+  description: string,
+}
+
+@observer
+class ProjectModal extends React.Component<Props, State> {
+  componentWillMount() {
+    this.setState({
+      name: this.props.project ? this.props.project.name : '',
+      description: this.props.project ? this.props.project.description : '',
+      enabled: this.props.project ? this.props.project.enabled : true,
+      highlightFieldNames: [],
+    })
+  }
+
+  componentDidMount() {
+    KeyboardManager.onEnter('projectModal', () => {
+      this.handleUpdateClick()
+    }, 2)
+  }
+
+  componentWillUnmount() {
+    KeyboardManager.removeKeyDown('projectModal')
+  }
+
+  handleUpdateClick() {
+    if (this.highlightFields()) {
+      return
+    }
+
+    this.props.onUpdateClick({
+      id: '',
+      name: this.state.name,
+      description: this.state.description,
+      enabled: this.state.enabled,
+    })
+  }
+
+  highlightFields() {
+    const highlightFieldNames = []
+    if (!this.state.name) {
+      highlightFieldNames.push('project_name')
+    }
+    if (highlightFieldNames.length > 0) {
+      this.setState({ highlightFieldNames })
+      return true
+    }
+    this.setState({ highlightFieldNames: [] })
+    return false
+  }
+
+  renderField(field: FieldType, value: any, onChange: (value: any) => void) {
+    return (
+      <Field
+        key={field.name}
+        name={field.name}
+        type={field.type || 'string'}
+        value={value}
+        onChange={onChange}
+        large
+        disabled={this.props.loading}
+        // $FlowIssue
+        required={field.required}
+        highlight={Boolean(this.state.highlightFieldNames.find(n => n === field.name))}
+      />
+    )
+  }
+
+  renderForm() {
+    let fields = [
+      this.renderField(
+        { name: 'project_name', required: true },
+        this.state.name,
+        name => { this.setState({ name }) }
+      ),
+      this.renderField(
+        { name: 'description' },
+        this.state.description,
+        description => { this.setState({ description }) }
+      ),
+      this.renderField(
+        { name: 'Enabled', type: 'boolean' },
+        this.state.enabled,
+        enabled => { this.setState({ enabled }) }
+      ),
+    ]
+
+    return (
+      <Form>
+        {fields}
+      </Form>
+    )
+  }
+
+  render() {
+    const label = this.props.isNewProject ? 'New Project' : 'Update Project'
+
+    return (
+      <Modal
+        isOpen
+        title={label}
+        onRequestClose={this.props.onRequestClose}
+      >
+        <Wrapper>
+          <Image />
+          {this.renderForm()}
+          <Buttons>
+            <Button
+              secondary
+              large
+              onClick={this.props.onRequestClose}
+            >Cancel</Button>
+            <Button
+              large
+              disabled={this.props.loading}
+              onClick={() => { this.handleUpdateClick() }}
+            >{label}</Button>
+          </Buttons>
+        </Wrapper>
+      </Modal>
+    )
+  }
+}
+
+export default ProjectModal

+ 236 - 0
src/components/organisms/UserDetailsContent/index.jsx

@@ -0,0 +1,236 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import React from 'react'
+import { observer } from 'mobx-react'
+import styled from 'styled-components'
+
+import CopyValue from '../../atoms/CopyValue'
+import CopyMultilineValue from '../../atoms/CopyMultilineValue'
+import StatusImage from '../../atoms/StatusImage'
+import Button from '../../atoms/Button'
+import AlertModal from '../../organisms/AlertModal'
+
+import type { User } from '../../../types/User'
+import type { Project } from '../../../types/Project'
+import StyleProps from '../../styleUtils/StyleProps'
+import Palette from '../../styleUtils/Palette'
+
+const Wrapper = styled.div`
+  ${StyleProps.exactWidth(StyleProps.contentWidth)}
+  margin: 0 auto;
+  padding-left: 126px;
+`
+const Info = styled.div`
+  display: flex;
+  flex-wrap: wrap;
+  margin-top: 32px;
+  margin-left: -32px;  
+`
+const Link = styled.a`
+  color: ${Palette.primary};
+  text-decoration: none;
+`
+const Field = styled.div`
+  ${StyleProps.exactWidth('calc(50% - 32px)')}
+  margin-bottom: 32px;
+  margin-left: 32px;
+`
+const Value = styled.div``
+const Label = styled.div`
+  font-size: 10px;
+  font-weight: ${StyleProps.fontWeights.medium};
+  color: ${Palette.grayscale[3]};
+  text-transform: uppercase;
+  margin-bottom: 3px;
+`
+const LoadingWrapper = styled.div`
+  display: flex;
+  justify-content: center;
+  width: 100%;
+  margin: 32px 0 64px 0;
+`
+const Buttons = styled.div`
+  margin-top: 32px;
+  display: flex;
+  justify-content: space-between;
+  margin-top: 32px;
+`
+const ButtonsColumn = styled.div`
+  display: flex;
+  flex-direction: column;
+  button {
+    margin-bottom: 16px;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+`
+
+type Props = {
+  user: ?User,
+  loading: boolean,
+  projects: Project[],
+  userProjects: Project[],
+  isLoggedUser: boolean,
+  onEditClick: () => void,
+  onUpdatePasswordClick: () => void,
+  onDeleteConfirmation: () => void,
+}
+type State = {
+  showDeleteConfirmation: boolean,
+}
+@observer
+class UserDetailsContent extends React.Component<Props, State> {
+  state = {
+    showDeleteConfirmation: false,
+  }
+
+  handleDeleteConfirmation() {
+    this.setState({ showDeleteConfirmation: false })
+    this.props.onDeleteConfirmation()
+  }
+
+  handleCloseDeleteConfirmation() {
+    this.setState({ showDeleteConfirmation: false })
+  }
+
+  renderLoading() {
+    if (!this.props.loading) {
+      return null
+    }
+
+    return (
+      <LoadingWrapper>
+        <StatusImage />
+      </LoadingWrapper>
+    )
+  }
+
+  renderButtons() {
+    if (this.props.loading) return null
+
+    return (
+      <Buttons>
+        <ButtonsColumn>
+          <Button secondary onClick={this.props.onEditClick}>Edit user</Button>
+          <Button hollow onClick={this.props.onUpdatePasswordClick}>Change password</Button>
+        </ButtonsColumn>
+        <ButtonsColumn>
+          <Button
+            alert
+            hollow
+            onClick={() => { this.setState({ showDeleteConfirmation: true }) }}
+            disabled={this.props.isLoggedUser}
+          >Delete user</Button>
+        </ButtonsColumn>
+      </Buttons>
+    )
+  }
+
+  renderUserProjects(projects: { label: string, id: string }[]) {
+    return projects.map((project, i) => (
+      <span>
+        {project.label ? (
+          <Link
+            key={project.id}
+            href={`#/project/${project.id}`}
+          >
+            {project.label}
+          </Link>
+        ) : project.id}
+        {i < projects.length - 1 ? ', ' : ''}
+      </span>
+    ))
+  }
+
+  renderInfo() {
+    if (this.props.loading || !this.props.user) {
+      return null
+    }
+
+    let user = this.props.user
+    let primaryProject = this.props.projects.find(p => user.project_id === p.id)
+    let primaryProjectName = primaryProject ? primaryProject.name : user.project_id
+    let userProjects: { label: string, id: string }[] = this.props.userProjects.map(up => {
+      let projectInfo = this.props.projects.find(p => p.id === up.id)
+      if (projectInfo) {
+        return { label: projectInfo.name, id: up.id }
+      }
+      return { label: '', id: up.id }
+    })
+
+    return (
+      <Info>
+        <Field>
+          <Label>Name</Label>
+          {this.renderValue(user.name)}
+        </Field>
+        <Field>
+          <Label>Description</Label>
+          {user.description ? <CopyMultilineValue value={user.description} /> : <Value>-</Value>}
+        </Field>
+        <Field>
+          <Label>ID</Label>
+          {this.renderValue(user.id)}
+        </Field>
+        <Field>
+          <Label>Email</Label>
+          {this.renderValue(user.email || '-')}
+        </Field>
+        <Field>
+          <Label>Primary Project</Label>
+          {this.renderValue(primaryProjectName || '-')}
+        </Field>
+        <Field>
+          <Label>Project Membership</Label>
+          {userProjects.length > 0 ? this.renderUserProjects(userProjects) : <Value>-</Value>}
+        </Field>
+        <Field>
+          <Label>Enabled</Label>
+          <Value>{user.enabled ? 'Yes' : 'No'}</Value>
+        </Field>
+      </Info>
+    )
+  }
+
+  renderValue(value: string) {
+    return value !== '-' ? <CopyValue value={value} maxWidth="90%" /> : <Value>{value}</Value>
+  }
+
+  render() {
+    return (
+      <Wrapper>
+        {this.renderInfo()}
+        {this.renderLoading()}
+        {this.renderButtons()}
+        {this.state.showDeleteConfirmation ? (
+          <AlertModal
+            isOpen
+            title="Delete User?"
+            message="Are you sure you want to delete this user?"
+            extraMessage="Deleting a Coriolis User is permanent!"
+            onConfirmation={() => { this.handleDeleteConfirmation() }}
+            onRequestClose={() => { this.handleCloseDeleteConfirmation() }}
+          />
+        ) : null}
+      </Wrapper>
+    )
+  }
+}
+
+export default UserDetailsContent

+ 31 - 0
src/components/organisms/UserModal/images/user.svg

@@ -0,0 +1,31 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="96px" height="96px" viewBox="0 0 96 96" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 49.3 (51167) - http://www.bohemiancoding.com/sketch -->
+    <title>User-VL Copy</title>
+    <desc>Created with Sketch.</desc>
+    <defs>
+        <path d="M48,96 C74.509668,96 96,74.509668 96,48 C96,21.490332 74.509668,0 48,0 C21.490332,0 0,21.490332 0,48 C0,74.509668 21.490332,96 48,96 Z" id="path-1"></path>
+    </defs>
+    <g id="Coriolis" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="User/Edit/General" transform="translate(-240.000000, -96.000000)">
+            <g id="Modal-Connection">
+                <g id="Icon/User/96" transform="translate(240.000000, 96.000000)">
+                    <g id="Group">
+                        <mask id="mask-2" fill="white">
+                            <use xlink:href="#path-1"></use>
+                        </mask>
+                        <g id="Pat-Benetar">
+                            <use fill="#FFFFFF" fill-rule="evenodd" xlink:href="#path-1"></use>
+                            <path stroke="#A4AAB5" stroke-width="1.5" d="M48,95.25 C74.0954544,95.25 95.25,74.0954544 95.25,48 C95.25,21.9045456 74.0954544,0.75 48,0.75 C21.9045456,0.75 0.75,21.9045456 0.75,48 C0.75,74.0954544 21.9045456,95.25 48,95.25 Z"></path>
+                        </g>
+                        <g id="Page-1" mask="url(#mask-2)" stroke="#A4AAB5" stroke-width="1.5">
+                            <g transform="translate(9.000000, 15.000000)" id="Fill-1">
+                                <path d="M58.11,58.0186047 C54.795,56.7 50.7,55.0027674 50.7,53.6860465 L50.7,45.2093023 C55.575,41.6302326 58.5,35.9790698 58.5,30.1395349 L58.5,18.8372093 C58.5,8.47674419 49.725,0 39,0 C28.275,0 19.5,8.47674419 19.5,18.8372093 L19.5,30.1395349 C19.5,35.9790698 22.425,41.8186047 27.3,45.2093023 L27.3,53.6860465 C27.3,54.8162791 23.205,56.5116279 19.89,58.0186047 C11.895,61.2209302 0,66.1186047 0,79.1162791 L0,81 L78,81 L78,79.1162791 C78,66.1186047 66.10695,61.2209302 58.11,58.0186047"></path>
+                            </g>
+                        </g>
+                    </g>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 285 - 0
src/components/organisms/UserModal/index.jsx

@@ -0,0 +1,285 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import React from 'react'
+import { observer } from 'mobx-react'
+import styled from 'styled-components'
+
+import type { User } from '../../../types/User'
+import type { Project } from '../../../types/Project'
+import type { Field as FieldType } from '../../../types/Field'
+import Button from '../../atoms/Button'
+import Modal from '../../molecules/Modal'
+import Field from '../../molecules/EndpointField'
+
+import userImage from './images/user.svg'
+import KeyboardManager from '../../../utils/KeyboardManager'
+
+const Wrapper = styled.div`
+  padding: 48px 32px 32px 32px;
+  display: flex;
+  flex-direction: column;
+  min-height: 0;
+`
+const Image = styled.div`
+  width: 96px;
+  height: 96px;
+  background: url('${userImage}') center no-repeat;
+  margin: 0 auto;
+`
+const Form = styled.div`
+  display: flex;
+  justify-content: space-between;
+  flex-wrap: wrap;
+  margin-top: 64px;
+  overflow: auto;
+
+  > div {
+    margin-top: 16px;
+  }
+`
+const Buttons = styled.div`
+  margin-top: 32px;
+  display: flex;
+  justify-content: space-between;
+`
+
+type Props = {
+  user?: User,
+  isLoggedUser?: boolean,
+  loading: boolean,
+  isNewUser?: boolean,
+  projects: Project[],
+  editPassword?: boolean,
+  onRequestClose: () => void,
+  onUpdateClick: (user: User) => void,
+}
+
+type State = {
+  name: string,
+  email: string,
+  enabled: boolean,
+  projectId: string,
+  highlightFieldNames: string[],
+  password: string,
+  confirmPassword: string,
+  description: string,
+}
+
+@observer
+class UserModal extends React.Component<Props, State> {
+  componentWillMount() {
+    this.setState({
+      name: this.props.user ? this.props.user.name : '',
+      email: this.props.user ? this.props.user.email : '',
+      description: this.props.user ? this.props.user.description : '',
+      projectId: this.props.user ? this.props.user.project_id : '',
+      enabled: this.props.user ? this.props.user.enabled : true,
+      highlightFieldNames: [],
+      password: '',
+      confirmPassword: '',
+    })
+  }
+
+  componentDidMount() {
+    KeyboardManager.onEnter('editUserModal', () => {
+      this.handleUpdateClick()
+    }, 2)
+  }
+
+  componentWillUnmount() {
+    KeyboardManager.removeKeyDown('editUserModal')
+  }
+
+  handleUpdateClick() {
+    if (
+      (this.props.isNewUser && this.highlightAllFields()) ||
+      (!this.props.isNewUser && !this.props.editPassword && this.highlightDetailsFields()) ||
+      (!this.props.isNewUser && this.props.editPassword && this.highlightPasswordFields())
+    ) {
+      return
+    }
+
+    this.props.onUpdateClick({
+      id: '',
+      project: { id: '', name: '' },
+      project_id: this.state.projectId,
+      name: this.state.name,
+      email: this.state.email,
+      description: this.state.description,
+      password: this.state.password,
+      enabled: this.state.enabled,
+    })
+  }
+
+  highlightAllFields() {
+    const highlightFieldNames = []
+    if (!this.state.name) {
+      highlightFieldNames.push('username')
+    }
+    if (!this.state.password) {
+      highlightFieldNames.push('new_password')
+    }
+    if (this.state.password && this.state.password !== this.state.confirmPassword) {
+      highlightFieldNames.push('confirm_password')
+    }
+    if (highlightFieldNames.length > 0) {
+      this.setState({ highlightFieldNames })
+      return true
+    }
+    this.setState({ highlightFieldNames: [] })
+    return false
+  }
+
+  highlightDetailsFields(): boolean {
+    if (!this.state.name) {
+      this.setState({ highlightFieldNames: ['username'] })
+      return true
+    }
+
+    this.setState({ highlightFieldNames: [] })
+    return false
+  }
+
+  highlightPasswordFields(): boolean {
+    if (!this.state.password) {
+      this.setState({ highlightFieldNames: ['new_password'] })
+      return true
+    }
+    if (this.state.password !== this.state.confirmPassword) {
+      this.setState({ highlightFieldNames: ['confirm_password'] })
+      return true
+    }
+    this.setState({ highlightFieldNames: [] })
+    return false
+  }
+
+  renderField(field: FieldType, value: any, onChange: (value: any) => void) {
+    let disabled = this.props.loading || (this.props.isLoggedUser && field.name === 'enabled')
+
+    return (
+      <Field
+        key={field.name}
+        name={field.name}
+        type={field.type || 'string'}
+        value={value}
+        onChange={onChange}
+        large
+        disabled={disabled}
+        enum={field.enum}
+        password={field.name === 'new_password' || field.name === 'confirm_password'}
+        // $FlowIssue
+        required={field.required}
+        highlight={Boolean(this.state.highlightFieldNames.find(n => n === field.name))}
+        noSelectionMessage="Choose a project"
+      />
+    )
+  }
+
+  renderForm() {
+    let fields
+    const userProjects = this.props.projects.map(p => { return { label: p.name, value: p.id } })
+
+    const passwordFields = [
+      this.renderField(
+        { name: 'new_password', required: true },
+        this.state.password,
+        password => { this.setState({ password }) }
+      ),
+      this.renderField(
+        { name: 'confirm_password', required: true },
+        this.state.confirmPassword,
+        confirmPassword => { this.setState({ confirmPassword }) }
+      ),
+    ]
+    const detailsFields = [
+      this.renderField(
+        { name: 'username', required: true },
+        this.state.name,
+        name => { this.setState({ name }) }
+      ),
+      this.renderField(
+        { name: 'description' },
+        this.state.description,
+        description => { this.setState({ description }) }
+      ),
+      this.renderField(
+        { name: 'Email' },
+        this.state.email,
+        email => { this.setState({ email }) }
+      ),
+      this.renderField(
+        {
+          name: 'Primary Project',
+          // $FlowIssue
+          enum: [{ label: 'Choose a project', value: null }].concat(userProjects),
+        },
+        this.state.projectId,
+        projectId => { this.setState({ projectId }) },
+      ),
+    ]
+    const enabledField = this.renderField(
+      { name: 'enabled', type: 'boolean' },
+      this.state.enabled,
+      enabled => { this.setState({ enabled }) }
+    )
+
+    if (this.props.isNewUser) {
+      fields = detailsFields.concat(passwordFields).concat(enabledField)
+    } else if (this.props.editPassword) {
+      fields = passwordFields
+    } else {
+      fields = detailsFields.concat(enabledField)
+    }
+
+    return (
+      <Form>
+        {fields}
+      </Form>
+    )
+  }
+
+  render() {
+    const label = this.props.isNewUser ? 'New User' : this.props.editPassword ? 'Change Password' : 'Update User'
+
+    return (
+      <Modal
+        isOpen
+        title={label}
+        onRequestClose={this.props.onRequestClose}
+      >
+        <Wrapper>
+          <Image />
+          {this.renderForm()}
+          <Buttons>
+            <Button
+              secondary
+              large
+              onClick={this.props.onRequestClose}
+            >Cancel</Button>
+            <Button
+              large
+              disabled={this.props.loading}
+              onClick={() => { this.handleUpdateClick() }}
+            >{label}</Button>
+          </Buttons>
+        </Wrapper>
+      </Modal>
+    )
+  }
+}
+
+export default UserModal

+ 1 - 1
src/components/organisms/WizardPageContent/index.jsx

@@ -393,7 +393,7 @@ class WizardPageContent extends React.Component<Props, State> {
       <Navigation>
         <Button secondary onClick={this.props.onBackClick}>Back</Button>
         <IconRepresentation>
-          <EndpointLogos height={32} endpoint={sourceEndpoint} />
+          <EndpointLogos height={32} endpoint={sourceEndpoint || ''} />
           <WizardTypeIcon
             dangerouslySetInnerHTML={{
               __html: this.props.type === 'replica'

+ 1 - 1
src/components/pages/AssessmentDetailsPage/index.jsx

@@ -340,7 +340,7 @@ class AssessmentDetailsPage extends React.Component<Props, State> {
       <Wrapper>
         <DetailsTemplate
           pageHeaderComponent={<DetailsPageHeader
-            user={userStore.user}
+            user={userStore.loggedUser}
             onUserItemClick={item => { this.handleUserItemClick(item) }}
           />}
           contentHeaderComponent={<DetailsContentHeader

+ 3 - 3
src/components/pages/AssessmentsPage/index.jsx

@@ -121,7 +121,7 @@ class AssessmentsPage extends React.Component<Props, State> {
       // $FlowIgnore
       endpointStore.connectionInfo.subscription_id,
       assessmentStore.selectedResourceGroup ? assessmentStore.selectedResourceGroup.name : '',
-      userStore.user ? userStore.user.project.id : ''
+      userStore.loggedUser ? userStore.loggedUser.project.id : ''
     )
   }
 
@@ -184,7 +184,7 @@ class AssessmentsPage extends React.Component<Props, State> {
       // $FlowIgnore
       connectionInfo.subscription_id,
       selectedResourceGroup.name,
-      userStore.user ? userStore.user.project.id : '',
+      userStore.loggedUser ? userStore.loggedUser.project.id : '',
       { backgroundLoading: true }
     ).then(() => {
       this.pollTimeout = setTimeout(() => { this.pollData() }, requestPollTimeout)
@@ -201,7 +201,7 @@ class AssessmentsPage extends React.Component<Props, State> {
       // $FlowIssue
       endpointStore.connectionInfo.subscription_id,
       selectedResourceGroup.name,
-      userStore.user ? userStore.user.project.id : '',
+      userStore.loggedUser ? userStore.loggedUser.project.id : '',
     )
   }
 

+ 1 - 1
src/components/pages/EndpointDetailsPage/index.jsx

@@ -189,7 +189,7 @@ class EndpointDetailsPage extends React.Component<Props, State> {
       <Wrapper>
         <DetailsTemplate
           pageHeaderComponent={<DetailsPageHeader
-            user={userStore.user}
+            user={userStore.loggedUser}
             onUserItemClick={item => { this.handleUserItemClick(item) }}
           />}
           contentHeaderComponent={<DetailsContentHeader

+ 3 - 3
src/components/pages/EndpointsPage/index.jsx

@@ -37,8 +37,8 @@ import EndpointSource from '../../../sources/EndpointSource'
 import endpointStore from '../../../stores/EndpointStore'
 import migrationStore from '../../../stores/MigrationStore'
 import replicaStore from '../../../stores/ReplicaStore'
-import providerStore from '../../../stores/ProviderStore'
 import notificationStore from '../../../stores/NotificationStore'
+import providerStore from '../../../stores/ProviderStore'
 import LabelDictionary from '../../../utils/LabelDictionary'
 import { requestPollTimeout } from '../../../config.js'
 import EndpointDuplicateOptions from '../../organisms/EndpointDuplicateOptions'
@@ -166,7 +166,7 @@ class EndpointsPage extends React.Component<{}, State> {
   handleDuplicate(projectId: string) {
     this.setState({ modalIsOpen: false, duplicating: true })
 
-    let selectedProjectId = userStore.user ? userStore.user.project.id : ''
+    let selectedProjectId = userStore.loggedUser ? userStore.loggedUser.project.id : ''
     let switchProject = projectId !== selectedProjectId
 
     let endpoints = []
@@ -281,7 +281,7 @@ class EndpointsPage extends React.Component<{}, State> {
 
   render() {
     let items: any = endpointStore.endpoints
-    let selectedProjectId = userStore.user ? userStore.user.project.id : ''
+    let selectedProjectId = userStore.loggedUser ? userStore.loggedUser.project.id : ''
     return (
       <Wrapper>
         <MainTemplate

+ 1 - 1
src/components/pages/LoginPage/index.jsx

@@ -89,7 +89,7 @@ class LoginPage extends React.Component<{}> {
   }
 
   render() {
-    if (userStore.user) {
+    if (userStore.loggedIn) {
       window.location.href = '/#/replicas'
     }
 

+ 1 - 1
src/components/pages/MigrationDetailsPage/index.jsx

@@ -130,7 +130,7 @@ class MigrationDetailsPage extends React.Component<Props, State> {
       <Wrapper>
         <DetailsTemplate
           pageHeaderComponent={<DetailsPageHeader
-            user={userStore.user}
+            user={userStore.loggedUser}
             onUserItemClick={item => { this.handleUserItemClick(item) }}
           />}
           contentHeaderComponent={<DetailsContentHeader

+ 22 - 0
src/components/pages/ProjectDetailsPage/images/project.svg

@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="64px" height="64px" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 49.3 (51167) - http://www.bohemiancoding.com/sketch -->
+    <title>Icon-CentOS Copy</title>
+    <desc>Created with Sketch.</desc>
+    <defs></defs>
+    <g id="Coriolis" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="Project/Details" transform="translate(-192.000000, -80.000000)">
+            <g id="SubMenu-Copy" transform="translate(0.000000, 64.000000)">
+                <g id="Group-2" transform="translate(192.000000, 16.000000)">
+                    <g id="Icon/Project/64">
+                        <path d="M32,64 C49.673112,64 64,49.673112 64,32 C64,14.326888 49.673112,0 32,0 C14.326888,0 0,14.326888 0,32 C0,49.673112 14.326888,64 32,64 Z" id="Pat-Benetar" fill="#FFFFFF" fill-rule="evenodd"></path>
+                        <path d="M32,15.9898649 L32,8.92905405" id="Line-Copy" stroke="#0044CA" stroke-width="1.5" stroke-linecap="square"></path>
+                        <path d="M16.325,51.72629 L28.6539577,28.1065305 M35.286909,27.9932432 L47.675,51.72629" id="Triangle-Copy" stroke="#0044CA" stroke-width="1.5" stroke-linecap="round"></path>
+                        <path d="M14.9,39.2905405 L49.1,39.2905405" id="Line-Copy-2" stroke="#0044CA" stroke-width="1.5" stroke-linecap="round"></path>
+                        <ellipse id="Oval-Copy" stroke="#0044CA" stroke-width="1.5" stroke-linecap="round" cx="32" cy="22.3445946" rx="5.7" ry="5.64864865"></ellipse>
+                    </g>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 233 - 0
src/components/pages/ProjectDetailsPage/index.jsx

@@ -0,0 +1,233 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import React from 'react'
+import styled from 'styled-components'
+import { observer } from 'mobx-react'
+
+import type { User } from '../../../types/User'
+import type { Project, Role } from '../../../types/Project'
+import DetailsTemplate from '../../templates/DetailsTemplate'
+import { DetailsPageHeader } from '../../organisms/DetailsPageHeader'
+import DetailsContentHeader from '../../organisms/DetailsContentHeader'
+import ProjectDetailsContent from '../../organisms/ProjectDetailsContent'
+import ProjectModal from '../../organisms/ProjectModal'
+import ProjectMemberModal from '../../organisms/ProjectMemberModal'
+
+import projectStore from '../../../stores/ProjectStore'
+import userStore from '../../../stores/UserStore'
+import notificationStore from '../../../stores/NotificationStore'
+
+import projectImage from './images/project.svg'
+
+const Wrapper = styled.div``
+
+type Props = {
+  match: { params: { id: string } }
+}
+type State = {
+  showProjectModal: boolean,
+  showAddMemberModal: boolean,
+  addingMember: boolean,
+}
+@observer
+class ProjectDetailsPage extends React.Component<Props, State> {
+  state = {
+    showProjectModal: false,
+    showAddMemberModal: false,
+    addingMember: false,
+  }
+
+  componentDidMount() {
+    document.title = 'Project Details'
+
+    this.loadData()
+  }
+
+  componentWillUnmount() {
+    projectStore.clearProjectDetails()
+  }
+
+  handleUserItemClick(item: { value: string }) {
+    switch (item.value) {
+      case 'signout':
+        userStore.logout()
+        break
+      default:
+    }
+  }
+
+  handleBackButtonClick() {
+    window.location.href = '/#/projects'
+  }
+
+  handleEnableUser(user: User) {
+    let enabled = !user.enabled
+    // $FlowIgnore
+    userStore.update(user.id, { enabled }).then(() => {
+      projectStore.getUsers(this.props.match.params.id)
+    })
+  }
+
+  handleUserRoleChange(user: User, roleId: string, toggled: boolean) {
+    let projectId = this.props.match.params.id
+    let operation: Promise<void>
+    if (toggled) {
+      operation = projectStore.assignUserRole(projectId, user.id, roleId)
+    } else {
+      operation = projectStore.removeUserRole(projectId, user.id, roleId)
+    }
+    operation.then(() => {
+      projectStore.getRoleAssignments()
+    })
+  }
+
+  handleRemoveUser(user: User) {
+    let roles = projectStore.roleAssignments
+      .filter(a => a.scope.project.id === this.props.match.params.id)
+      .filter(a => a.user.id === user.id)
+    let oldRoleId = roles.length > 0 ? roles[0].role.id : ''
+    projectStore.removeUser(this.props.match.params.id, user.id, oldRoleId)
+  }
+
+  handleEditProjectClick() {
+    this.setState({ showProjectModal: true })
+  }
+
+  handleProjectModalClose() {
+    this.setState({ showProjectModal: false })
+  }
+
+  handleProjectUpdateClick(project: Project) {
+    projectStore.update(this.props.match.params.id, project).then(() => {
+      this.setState({ showProjectModal: false })
+    })
+  }
+
+  handleDeleteConfirmation() {
+    projectStore.delete(this.props.match.params.id).then(() => {
+      if (
+        userStore.loggedUser &&
+        this.props.match.params.id === userStore.loggedUser.project.id &&
+        projectStore.projects.length > 0
+      ) {
+        userStore.switchProject(projectStore.projects[0].id).then(() => {
+          projectStore.getProjects()
+          window.location.href = '#/projects'
+        })
+      } else {
+        window.location.href = '#/projects'
+      }
+    })
+  }
+
+  handleAddMemberClick() {
+    userStore.getAllUsers().then(() => {
+      this.setState({ showAddMemberModal: true })
+    })
+  }
+
+  handleAddMember(user: User, isNew: boolean, roles: Role[]) {
+    const assign = (userId: string) => {
+      Promise.all(roles.map(r => {
+        return userStore.assignUserToProjectWithRole(userId, this.props.match.params.id, r.id)
+      })).catch(e => {
+        notificationStore.notify('Error while assigning role to user', 'error')
+        console.error(e)
+      }).then(() => {
+        this.loadData()
+        this.setState({ addingMember: false, showAddMemberModal: false })
+      })
+    }
+
+    this.setState({ addingMember: true })
+
+    if (!isNew) {
+      assign(user.id)
+      return
+    }
+
+    userStore.add(user).then((addedUser: ?User) => {
+      if (addedUser) {
+        assign(addedUser.id)
+      }
+    })
+  }
+
+  loadData() {
+    const projectId = this.props.match.params.id
+    projectStore.getProjects()
+    projectStore.getProjectDetails(projectId)
+    projectStore.getUsers(projectId, true)
+    projectStore.getRoleAssignments()
+    projectStore.getRoles()
+  }
+
+  render() {
+    return (
+      <Wrapper>
+        <DetailsTemplate
+          pageHeaderComponent={<DetailsPageHeader
+            user={userStore.loggedUser}
+            onUserItemClick={item => { this.handleUserItemClick(item) }}
+          />}
+          contentHeaderComponent={<DetailsContentHeader
+            item={{ ...projectStore.projectDetails, description: '' }}
+            onBackButonClick={() => { this.handleBackButtonClick() }}
+            typeImage={projectImage}
+            description={''}
+          />}
+          contentComponent={<ProjectDetailsContent
+            project={projectStore.projectDetails}
+            loading={projectStore.loading}
+            users={projectStore.users}
+            usersLoading={projectStore.usersLoading}
+            roleAssignments={projectStore.roleAssignments}
+            roles={projectStore.roles}
+            deleteDisabled={projectStore.projects.length === 1}
+            loggedUserId={userStore.loggedUser ? userStore.loggedUser.id : ''}
+            onEnableUser={user => { this.handleEnableUser(user) }}
+            onRemoveUser={user => { this.handleRemoveUser(user) }}
+            onEditProjectClick={() => { this.handleEditProjectClick() }}
+            onDeleteConfirmation={() => { this.handleDeleteConfirmation() }}
+            onAddMemberClick={() => { this.handleAddMemberClick() }}
+            onUserRoleChange={(user, roleId, toggled) => { this.handleUserRoleChange(user, roleId, toggled) }}
+          />}
+        />
+        {this.state.showProjectModal ? (
+          <ProjectModal
+            loading={projectStore.updating}
+            project={projectStore.projectDetails}
+            onRequestClose={() => { this.handleProjectModalClose() }}
+            onUpdateClick={project => { this.handleProjectUpdateClick(project) }}
+          />
+        ) : null}
+        {this.state.showAddMemberModal ? (
+          <ProjectMemberModal
+            loading={this.state.addingMember}
+            roles={projectStore.roles}
+            projects={projectStore.projects}
+            users={userStore.users.filter(u => !projectStore.users.find(pu => pu.id === u.id))}
+            onAddClick={(user, isNew, roles) => { this.handleAddMember(user, isNew, roles) }}
+            onRequestClose={() => { this.setState({ showAddMemberModal: false }) }}
+          />
+        ) : null}
+      </Wrapper>
+    )
+  }
+}
+
+export default ProjectDetailsPage

+ 145 - 0
src/components/pages/ProjectsPage/index.jsx

@@ -0,0 +1,145 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import React from 'react'
+import styled from 'styled-components'
+import { observer } from 'mobx-react'
+
+import MainTemplate from '../../templates/MainTemplate'
+import Navigation from '../../organisms/Navigation'
+import FilterList from '../../organisms/FilterList'
+import PageHeader from '../../organisms/PageHeader'
+import ProjectListItem from '../../molecules/ProjectListItem'
+
+import type { Project } from '../../../types/Project'
+
+import projectStore from '../../../stores/ProjectStore'
+import userStore from '../../../stores/UserStore'
+import { requestPollTimeout } from '../../../config.js'
+
+const Wrapper = styled.div``
+
+type State = {
+  modalIsOpen: boolean,
+}
+@observer
+class ProjectsPage extends React.Component<{}, State> {
+  pollTimeout: TimeoutID
+  stopPolling: boolean
+
+  state = {
+    modalIsOpen: false,
+  }
+
+  componentDidMount() {
+    document.title = 'Projects'
+
+    this.stopPolling = false
+    this.pollData(true)
+  }
+
+  componentWillUnmount() {
+    clearTimeout(this.pollTimeout)
+    this.stopPolling = true
+  }
+
+  getMembers(projectId: string): number {
+    return projectStore.roleAssignments.filter(a => a.scope.project.id === projectId).length
+  }
+
+  isCurrentProject(projectId: string): boolean {
+    let project = userStore.loggedUser && userStore.loggedUser.project ? userStore.loggedUser.project : null
+    return project ? project.id === projectId : false
+  }
+
+  handleModalOpen() {
+    this.setState({ modalIsOpen: true })
+  }
+
+  handleModalClose() {
+    this.setState({ modalIsOpen: false }, () => {
+      this.pollData()
+    })
+  }
+
+  handleReloadButtonClick() {
+    projectStore.getProjects(true)
+    projectStore.getRoleAssignments()
+  }
+
+  handleSwitchProjectClick(projectId: string) {
+    userStore.switchProject(projectId).then(() => {
+      projectStore.getProjects()
+    })
+  }
+
+  pollData(showLoading?: boolean) {
+    if (this.state.modalIsOpen || this.stopPolling) {
+      return
+    }
+
+    Promise.all([projectStore.getProjects(showLoading), projectStore.getRoleAssignments()]).then(() => {
+      this.pollTimeout = setTimeout(() => { this.pollData() }, requestPollTimeout)
+    })
+  }
+
+  itemFilterFunction(item: Project, filterItem?: ?string, filterText?: string): boolean {
+    filterText = (filterText && filterText.toLowerCase()) || ''
+    return (
+      item.name.toLowerCase().indexOf(filterText) > -1 ||
+      (item.description ? item.description.toLowerCase().indexOf(filterText) > -1 : false)
+    )
+  }
+
+  render() {
+    return (
+      <Wrapper>
+        <MainTemplate
+          navigationComponent={<Navigation currentPage="projects" />}
+          listNoMargin
+          listComponent={
+            <FilterList
+              filterItems={[{ label: 'All', value: 'all' }]}
+              selectionLabel="user"
+              loading={projectStore.loading}
+              items={projectStore.projects}
+              onItemClick={(user: Project) => { window.location.href = `#/project/${user.id}` }}
+              onReloadButtonClick={() => { this.handleReloadButtonClick() }}
+              itemFilterFunction={(...args) => this.itemFilterFunction(...args)}
+              renderItemComponent={component => (
+                <ProjectListItem
+                  {...component}
+                  getMembers={projectId => this.getMembers(projectId)}
+                  isCurrentProject={projectId => this.isCurrentProject(projectId)}
+                  onSwitchProjectClick={projectId => this.handleSwitchProjectClick(projectId)}
+                />
+              )}
+            />
+          }
+          headerComponent={
+            <PageHeader
+              title="Projects"
+              onModalOpen={() => { this.handleModalOpen() }}
+              onModalClose={() => { this.handleModalClose() }}
+            />
+          }
+        />
+      </Wrapper>
+    )
+  }
+}
+
+export default ProjectsPage

+ 1 - 1
src/components/pages/ReplicaDetailsPage/index.jsx

@@ -242,7 +242,7 @@ class ReplicaDetailsPage extends React.Component<Props, State> {
       <Wrapper>
         <DetailsTemplate
           pageHeaderComponent={<DetailsPageHeader
-            user={userStore.user}
+            user={userStore.loggedUser}
             onUserItemClick={item => { this.handleUserItemClick(item) }}
           />}
           contentHeaderComponent={<DetailsContentHeader

+ 24 - 0
src/components/pages/UserDetailsPage/images/user.svg

@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg width="64px" height="64px" viewBox="0 0 64 64" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+    <!-- Generator: Sketch 49.3 (51167) - http://www.bohemiancoding.com/sketch -->
+    <title>Icon-CentOS Copy</title>
+    <desc>Created with Sketch.</desc>
+    <defs>
+        <path d="M32,64 C49.673112,64 64,49.673112 64,32 C64,14.326888 49.673112,0 32,0 C14.326888,0 0,14.326888 0,32 C0,49.673112 14.326888,64 32,64 Z" id="path-1"></path>
+    </defs>
+    <g id="Coriolis" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
+        <g id="User/Details" transform="translate(-192.000000, -80.000000)">
+            <g id="SubMenu-Copy" transform="translate(0.000000, 64.000000)">
+                <g id="Group-2" transform="translate(192.000000, 16.000000)">
+                    <g id="Icon/User/Item-64">
+                        <mask id="mask-2" fill="white">
+                            <use xlink:href="#path-1"></use>
+                        </mask>
+                        <use id="Pat-Benetar" fill="#FFFFFF" fill-rule="evenodd" xlink:href="#path-1"></use>
+                        <path d="M44.74,51.3850129 C42.53,50.4444444 39.8,49.2337984 39.8,48.2945736 L39.8,42.248062 C43.05,39.6950904 45,35.6640827 45,31.498708 L45,23.4366925 C45,16.0465116 39.15,10 32,10 C24.85,10 19,16.0465116 19,23.4366925 L19,31.498708 C19,35.6640827 20.95,39.8294574 24.2,42.248062 L24.2,48.2945736 C24.2,49.1007752 21.47,50.3100775 19.26,51.3850129 C13.93,53.6692506 6,57.1627907 6,66.4341085 L6,67.7777778 L58,67.7777778 L58,66.4341085 C58,57.1627907 50.0713,53.6692506 44.74,51.3850129" id="Fill-1" stroke="#0044CA" stroke-width="1.5" stroke-linecap="round" mask="url(#mask-2)"></path>
+                    </g>
+                </g>
+            </g>
+        </g>
+    </g>
+</svg>

+ 151 - 0
src/components/pages/UserDetailsPage/index.jsx

@@ -0,0 +1,151 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import React from 'react'
+import styled from 'styled-components'
+import { observer } from 'mobx-react'
+
+import type { User } from '../../../types/User'
+import DetailsTemplate from '../../templates/DetailsTemplate'
+import { DetailsPageHeader } from '../../organisms/DetailsPageHeader'
+import DetailsContentHeader from '../../organisms/DetailsContentHeader'
+import UserDetailsContent from '../../organisms/UserDetailsContent'
+import UserModal from '../../organisms/UserModal'
+
+import userStore from '../../../stores/UserStore'
+import projectStore from '../../../stores/ProjectStore'
+
+import userImage from './images/user.svg'
+
+const Wrapper = styled.div``
+
+type Props = {
+  match: { params: { id: string } }
+}
+type State = {
+  showUserModal: boolean,
+  editPassword: boolean,
+}
+@observer
+class UserDetailsPage extends React.Component<Props, State> {
+  state = {
+    showUserModal: false,
+    editPassword: false,
+  }
+
+  componentDidMount() {
+    document.title = 'User Details'
+
+    this.loadData()
+  }
+
+  componentWillReceiveProps(newProps: Props) {
+    if (newProps.match.params.id !== this.props.match.params.id) {
+      this.loadData(newProps.match.params.id)
+    }
+  }
+
+  componentWillUnmount() {
+    userStore.clearUserDetails()
+    userStore.clearProjects()
+  }
+
+  handleUserItemClick(item: { value: string }) {
+    switch (item.value) {
+      case 'signout':
+        userStore.logout()
+        break
+      default:
+    }
+  }
+
+  handleBackButtonClick() {
+    window.location.href = '/#/users'
+  }
+
+  handleEditClick() {
+    this.setState({ showUserModal: true })
+  }
+
+  handleDeleteConfirmation() {
+    userStore.delete(this.props.match.params.id).then(() => {
+      window.location.href = '/#/users'
+    })
+  }
+
+  handleUserEditModalClose() {
+    this.setState({ showUserModal: false, editPassword: false })
+  }
+
+  handleUserUpdateClick(user: User) {
+    userStore.update(this.props.match.params.id, user).then(() => {
+      userStore.getProjects(this.props.match.params.id)
+      this.setState({ showUserModal: false, editPassword: false })
+    })
+  }
+
+  handleUpdatePasswordClick() {
+    this.setState({ showUserModal: true, editPassword: true })
+  }
+
+  loadData(id?: string) {
+    projectStore.getProjects()
+    userStore.getProjects(id || this.props.match.params.id)
+    userStore.getUserInfo(id || this.props.match.params.id)
+  }
+
+  render() {
+    return (
+      <Wrapper>
+        <DetailsTemplate
+          pageHeaderComponent={<DetailsPageHeader
+            user={userStore.loggedUser}
+            onUserItemClick={item => { this.handleUserItemClick(item) }}
+          />}
+          contentHeaderComponent={<DetailsContentHeader
+            item={{ ...userStore.userDetails, description: '' }}
+            onBackButonClick={() => { this.handleBackButtonClick() }}
+            typeImage={userImage}
+            description={''}
+          />}
+          contentComponent={<UserDetailsContent
+            onDeleteConfirmation={() => { this.handleDeleteConfirmation() }}
+            user={userStore.userDetails}
+            isLoggedUser={userStore.loggedUser && userStore.userDetails ? userStore.loggedUser.id === userStore.userDetails.id : false}
+            loading={userStore.userDetailsLoading}
+            userProjects={userStore.projects}
+            projects={projectStore.projects}
+            onEditClick={() => { this.handleEditClick() }}
+            onUpdatePasswordClick={() => { this.handleUpdatePasswordClick() }}
+          />}
+        />
+        {this.state.showUserModal && userStore.userDetails ? (
+          <UserModal
+            user={userStore.userDetails}
+            isLoggedUser={userStore.loggedUser && userStore.userDetails ? userStore.loggedUser.id === userStore.userDetails.id : false}
+            loading={userStore.updating}
+            projects={projectStore.projects}
+            editPassword={this.state.editPassword}
+            onRequestClose={() => { this.handleUserEditModalClose() }}
+            onUpdateClick={user => { this.handleUserUpdateClick(user) }}
+          />
+        ) : null}
+      </Wrapper>
+    )
+  }
+}
+
+export default UserDetailsPage

+ 148 - 0
src/components/pages/UsersPage/index.jsx

@@ -0,0 +1,148 @@
+/*
+Copyright (C) 2017  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import React from 'react'
+import styled from 'styled-components'
+import { observer } from 'mobx-react'
+
+import MainTemplate from '../../templates/MainTemplate'
+import Navigation from '../../organisms/Navigation'
+import FilterList from '../../organisms/FilterList'
+import PageHeader from '../../organisms/PageHeader'
+import UserListItem from '../../molecules/UserListItem'
+
+import type { User } from '../../../types/User'
+
+import projectStore from '../../../stores/ProjectStore'
+import userStore from '../../../stores/UserStore'
+import { requestPollTimeout } from '../../../config.js'
+
+const Wrapper = styled.div``
+
+type State = {
+  modalIsOpen: boolean,
+}
+@observer
+class UsersPage extends React.Component<{}, State> {
+  pollTimeout: TimeoutID
+  stopPolling: boolean
+
+  state = {
+    modalIsOpen: false,
+  }
+
+  componentDidMount() {
+    document.title = 'Users'
+
+    projectStore.getProjects()
+    userStore.getAllUsers()
+
+    this.stopPolling = false
+    this.pollData(true)
+  }
+
+  componentWillUnmount() {
+    clearTimeout(this.pollTimeout)
+    this.stopPolling = true
+  }
+
+  getProjectName(projectId: ?string): string {
+    if (!projectId) {
+      return '-'
+    }
+    const project = projectStore.projects.find(p => p.id === projectId)
+    return project ? project.name : '-'
+  }
+
+  handleModalOpen() {
+    this.setState({ modalIsOpen: true })
+  }
+
+  handleModalClose() {
+    this.setState({ modalIsOpen: false }, () => {
+      this.pollData()
+    })
+  }
+
+  handleReloadButtonClick() {
+    projectStore.getProjects()
+    userStore.getAllUsers(true)
+  }
+
+  pollData(showLoading?: boolean) {
+    if (this.state.modalIsOpen || this.stopPolling) {
+      return
+    }
+
+    userStore.getAllUsers(showLoading).then(() => {
+      this.pollTimeout = setTimeout(() => { this.pollData() }, requestPollTimeout)
+    })
+  }
+
+  itemFilterFunction(item: User, filterItem?: ?string, filterText?: string): boolean {
+    filterText = (filterText && filterText.toLowerCase()) || ''
+    return (
+      (
+        filterItem === 'all' ||
+        item.project_id === filterItem
+      ) && (
+        item.name.toLowerCase().indexOf(filterText) > -1 ||
+        (item.description ? item.description.toLowerCase().indexOf(filterText) > -1 : false) ||
+        (item.email ? item.email.toLowerCase().indexOf(filterText) > -1 : false)
+      ))
+  }
+
+  render() {
+    let filterItems = projectStore.projects
+      .map(p => { return { label: p.name, value: p.id } })
+      .sort((a, b) => a.label.localeCompare(b.label))
+
+    return (
+      <Wrapper>
+        <MainTemplate
+          navigationComponent={<Navigation currentPage="users" />}
+          listNoMargin
+          listComponent={
+            <FilterList
+              filterItems={[{ label: 'All', value: 'all' }].concat(filterItems)}
+              selectionLabel="user"
+              loading={userStore.allUsersLoading}
+              items={userStore.users}
+              onItemClick={(user: User) => { window.location.href = `#/user/${user.id}` }}
+              onReloadButtonClick={() => { this.handleReloadButtonClick() }}
+              itemFilterFunction={(...args) => this.itemFilterFunction(...args)}
+              renderItemComponent={component => (
+                <UserListItem
+                  {...component}
+                  getProjectName={projectId => this.getProjectName(projectId)}
+                />
+              )}
+            />
+          }
+          headerComponent={
+            <PageHeader
+              title="Users"
+              onModalOpen={() => { this.handleModalOpen() }}
+              onModalClose={() => { this.handleModalClose() }}
+            />
+          }
+        />
+      </Wrapper>
+    )
+  }
+}
+
+export default UsersPage

+ 21 - 5
src/components/pages/WizardPage/index.jsx

@@ -171,11 +171,21 @@ class WizardPage extends React.Component<Props, State> {
     wizardStore.setCurrentPage(page)
   }
 
-  handleSourceEndpointChange(source: EndpointType) {
+  handleSourceEndpointChange(source: ?EndpointType) {
     wizardStore.updateData({ source, selectedInstances: null, networks: null })
     wizardStore.setPermalink(wizardStore.data)
-    // Preload instances for 'vms' page
-    instanceStore.loadInstances(source.id)
+
+    if (source) {
+      // Check if user has permission for this endpoint
+      endpointStore.getConnectionInfo(source).then(() => {
+        if (source) {
+          // Preload instances for 'vms' page
+          instanceStore.loadInstances(source.id)
+        }
+      }).catch(() => {
+        this.handleSourceEndpointChange(null)
+      })
+    }
   }
 
   handleTargetEndpointChange(target: EndpointType) {
@@ -311,7 +321,13 @@ class WizardPage extends React.Component<Props, State> {
         // Preload instances if data is set from 'Permalink'
         let source = wizardStore.data.source
         if (instanceStore.instances.length === 0 && source) {
-          instanceStore.loadInstances(source.id)
+          // Check if user has permission for this endpoint
+          endpointStore.getConnectionInfo(source).then(() => {
+            // Preload instances for 'vms' page
+            instanceStore.loadInstances(source.id)
+          }).catch(() => {
+            this.handleSourceEndpointChange(null)
+          })
         }
         break
       }
@@ -429,7 +445,7 @@ class WizardPage extends React.Component<Props, State> {
       <Wrapper>
         <WizardTemplate
           pageHeaderComponent={<DetailsPageHeader
-            user={userStore.user}
+            user={userStore.loggedUser}
             onUserItemClick={item => { this.handleUserItemClick(item) }}
           />}
           pageContentComponent={<WizardPageContent

+ 6 - 0
src/config.js

@@ -33,7 +33,13 @@ export const navigationMenu = [
   { label: 'Replicas', value: 'replicas' },
   { label: 'Migrations', value: 'migrations' },
   { label: 'Cloud Endpoints', value: 'endpoints' },
+
+  // Optional pages
   { label: 'Planning', value: 'planning', disabled: true },
+
+  // User management pages
+  { label: 'Projects', value: 'projects', disabled: true, requiresAdmin: true },
+  { label: 'Users', value: 'users', disabled: true, requiresAdmin: true },
 ]
 
 export const requestPollTimeout = 5000

+ 112 - 8
src/sources/ProjectSource.js

@@ -16,17 +16,121 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import Api from '../utils/ApiCaller'
 
-import { servicesUrl } from '../config'
-import type { Project } from '../types/Project'
+import UserSource from './UserSource'
+import ObjectUtils from '../utils/ObjectUtils'
+import { servicesUrl, coriolisUrl } from '../config'
+/* eslint import/no-duplicates: off */
+import type { Project, Role } from '../types/Project'
+import type { RoleAssignment } from '../types/Project'
+import type { User } from '../types/User'
 
 class ProjectsSource {
   static getProjects(): Promise<Project[]> {
-    return new Promise((resolve, reject) => {
-      Api.get(servicesUrl.projects).then((response) => {
-        if (response.data.projects) {
-          resolve(response.data.projects)
-        }
-      }, reject).catch(reject)
+    return Api.get(servicesUrl.projects).then((response) => {
+      if (response.data.projects) {
+        let projects: Project[] = response.data.projects
+        projects.sort((a, b) => a.name.localeCompare(b.name))
+        return projects
+      }
+      return []
+    })
+  }
+
+  static getProjectDetails(projectId: string): Promise<Project> {
+    return Api.get(`${coriolisUrl}/identity/projects/${projectId}`).then(response => {
+      return response.data.project
+    })
+  }
+
+  static getRoleAssignments(): Promise<RoleAssignment[]> {
+    return Api.get(`${coriolisUrl}identity/role_assignments?include_names`).then(response => {
+      let assignments: RoleAssignment[] = response.data.role_assignments
+      assignments.sort((a1, a2) => a1.role.name.localeCompare(a2.role.name))
+      return assignments
+    })
+  }
+
+  static getUsers(projectId: string): Promise<User[]> {
+    return this.getRoleAssignments().then(assignments => {
+      const userIds: string[] = assignments
+        .filter(a => a.scope.project.id === projectId)
+        .filter((a, i, arr) => arr.findIndex(e => a.user.id === e.user.id) === i)
+        .map(a => a.user.id)
+      return Promise.all(userIds.map(id => {
+        return UserSource.getUserInfo(id)
+      })).then((users: User[]) => {
+        users.sort((a, b) => a.name.localeCompare(b.name))
+        return users
+      })
+    })
+  }
+
+  static removeUser(projectId: string, userId: string, roleId: string): Promise<void> {
+    return Api.send({
+      url: `${coriolisUrl}identity/projects/${projectId}/users/${userId}/roles/${roleId}`,
+      method: 'DELETE',
+    }).then(() => { })
+  }
+
+  static assignUser(projectId: string, userId: string, roleId: string): Promise<void> {
+    return Api.send({
+      url: `${coriolisUrl}identity/projects/${projectId}/users/${userId}/roles/${roleId}`,
+      method: 'PUT',
+    }).then(() => { })
+  }
+
+  static getRoles(): Promise<Role[]> {
+    return UserSource.getRoles()
+  }
+
+  static update(projectId: string, project: Project): Promise<Project> {
+    let data = { project: {} }
+    if (ObjectUtils.isValid(project.name)) {
+      data.project.name = project.name
+    }
+    if (ObjectUtils.isValid(project.description)) {
+      data.project.description = project.description
+    }
+    if (ObjectUtils.isValid(project.enabled)) {
+      data.project.enabled = project.enabled
+    }
+
+    return Api.send({
+      url: `${coriolisUrl}identity/projects/${projectId}`,
+      method: 'PATCH',
+      data,
+    }).then(response => response.data.project)
+  }
+
+  static delete(projectId: string): Promise<void> {
+    return Api.send({
+      url: `${coriolisUrl}identity/projects/${projectId}`,
+      method: 'DELETE',
+    }).then(() => { })
+  }
+
+  static add(project: Project, userId: string): Promise<Project> {
+    let data = { project: {} }
+
+    data.project.name = project.name
+    if (ObjectUtils.isValid(project.enabled)) {
+      data.project.enabled = project.enabled
+    }
+    if (ObjectUtils.isValid(project.description)) {
+      data.project.description = project.description
+    }
+    let addedProject: Project
+    return Api.send({
+      url: `${coriolisUrl}identity/projects/`,
+      method: 'POST',
+      data,
+    }).then(response => {
+      addedProject = response.data.project
+      return UserSource.getAdminRoleId()
+    }).then(adminRoleId => {
+      return UserSource.assignUserToProjectWithRole(userId, addedProject.id, adminRoleId)
+    }).then(() => {
+      return addedProject
     })
   }
 }

+ 172 - 6
src/sources/UserSource.js

@@ -17,8 +17,9 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 import cookie from 'js-cookie'
 
 import Api from '../utils/ApiCaller'
-import { servicesUrl } from '../config'
+import { servicesUrl, coriolisUrl } from '../config'
 import type { Credentials, User } from '../types/User'
+import type { Role, Project, RoleAssignment } from '../types/Project'
 
 class UserModel {
   static parseUserData(data: any) {
@@ -174,11 +175,176 @@ class UserSource {
     })
   }
 
-  static getUserInfo(user: User): Promise<User> {
-    return new Promise((resolve, reject) => {
-      Api.get(`${servicesUrl.users}/${user.id}`).then((response) => {
-        resolve(response.data.user)
-      }, reject).catch(reject)
+  static getUserInfo(userId: string): Promise<User> {
+    return Api.get(`${servicesUrl.users}/${userId}`).then(response => {
+      return response.data.user
+    })
+  }
+
+  static getAllUsers(): Promise<User[]> {
+    return Api.get(`${servicesUrl.users}`).then(response => {
+      return response.data.users.sort((u1, u2) => u1.name.localeCompare(u2.name))
+    })
+  }
+
+  static update(userId: string, user: User, oldUser: ?User): Promise<User> {
+    const data = { user: {} }
+    let oldData = oldUser || {}
+
+    if (user.email || oldData.email) {
+      data.user.email = user.email
+    }
+    if (user.description || oldData.description) {
+      data.user.description = user.description
+    }
+    if (user.enabled !== undefined && user.enabled !== null) {
+      data.user.enabled = user.enabled
+    }
+    if (user.name) {
+      data.user.name = user.name
+    }
+    if (user.password) {
+      data.user.password = user.password
+    }
+    if (user.project_id || oldData.project_id) {
+      data.user.project_id = user.project_id
+    }
+    let updatedUser: User
+
+    return Api.send({
+      url: `${servicesUrl.users}/${userId}`,
+      method: 'PATCH',
+      data,
+    }).then(response => {
+      updatedUser = response.data.user
+      if (updatedUser.extra) {
+        updatedUser = {
+          ...updatedUser,
+          ...updatedUser.extra,
+        }
+      }
+      return updatedUser
+    }).then(() => {
+      // if project id was updated, assign him to that project, if his not already assigned
+      if (data.user.project_id) {
+        return this.getProjects(updatedUser.id).then((projects: Project[]) => {
+          if (projects.find(p => p.id === data.user.project_id)) {
+            return updatedUser
+          }
+
+          return this.assignUserToProject(updatedUser.id, updatedUser.project_id || 'undefined').then(() => {
+            return updatedUser
+          })
+        })
+      }
+
+      return updatedUser
+    })
+  }
+
+  static add(user: User): Promise<User> {
+    let data = { user: {} }
+    data.user.name = user.name
+    data.user.password = user.password || ''
+    data.user.enabled = user.enabled === null || user.enabled === undefined ? true : user.enabled
+
+    if (user.email) {
+      data.user.email = user.email
+    }
+    if (user.description) {
+      data.user.description = user.description
+    }
+    if (user.project_id) {
+      data.user.project_id = user.project_id
+    }
+    let addedUser: User
+    return Api.send({
+      url: `${servicesUrl.users}`,
+      method: 'POST',
+      data,
+    }).then(response => {
+      addedUser = response.data.user
+      if (addedUser.extra) {
+        addedUser = {
+          ...addedUser,
+          ...addedUser.extra,
+        }
+      }
+      return addedUser
+    }).then(() => {
+      // If the user has a project id set, assign him to that project with admin role
+      if (addedUser.project_id) {
+        return this.assignUserToProject(addedUser.id, addedUser.project_id || 'undefined').then(() => {
+          return addedUser
+        })
+      }
+      return addedUser
+    })
+  }
+
+  static delete(userId: string): Promise<void> {
+    return Api.send({
+      url: `${coriolisUrl}identity/users/${userId}`,
+      method: 'DELETE',
+    }).then(() => { })
+  }
+
+  static assignUserToProject(userId: string, projectId: string): Promise<void> {
+    return this.getMemberRoleId().then((roleId: string) => {
+      return this.assignUserToProjectWithRole(userId, projectId, roleId)
+    })
+  }
+
+  static assignUserToProjectWithRole(userId: string, projectId: string, roleId: string): Promise<void> {
+    return Api.send({
+      url: `${coriolisUrl}identity/projects/${projectId}/users/${userId}/roles/${roleId}`,
+      method: 'PUT',
+    }).then(() => { })
+  }
+
+  static getMemberRoleId(): Promise<string> {
+    return this.getRoles().then((roles: { id: string, name: string }[]) => {
+      const role = roles.find(r => r.name === '_member_')
+      const roleId = role ? role.id : ''
+      return roleId
+    })
+  }
+
+  static getAdminRoleId(): Promise<string> {
+    return this.getRoles().then((roles: { id: string, name: string }[]) => {
+      const role = roles.find(r => r.name === 'admin')
+      const roleId = role ? role.id : ''
+      return roleId
+    })
+  }
+
+  static getRoles(): Promise<Role[]> {
+    return Api.get(`${coriolisUrl}identity/roles`).then(response => {
+      let roles: Role[] = response.data.roles
+      roles.sort((r1, r2) => r1.name.localeCompare(r2.name))
+      return roles
+    })
+  }
+
+  static getProjects(userId: string): Promise<Project[]> {
+    return Api.get(`${coriolisUrl}identity/role_assignments?include_names`).then(response => {
+      let assignments: RoleAssignment[] = response.data.role_assignments
+      let projects: $Shape<Project>[] = assignments
+        .filter(a => a.user.id === userId)
+        .filter((a, i, arr) => arr.findIndex(e => e.scope.project.id === a.scope.project.id) === i)
+        .map(a => a.scope.project)
+
+      return projects
+    })
+  }
+
+  static isAdmin(userId: string): Promise<boolean> {
+    return Api.send({
+      url: `${coriolisUrl}identity/role_assignments?include_names`,
+      quietError: true,
+    }).then(response => {
+      let roleAssignments: RoleAssignment[] = response.data.role_assignments
+      return roleAssignments.filter(a => a.user.id === userId).filter(a => a.role.name === 'admin').length > 0
     })
   }
 }

+ 1 - 0
src/stores/EndpointStore.js

@@ -63,6 +63,7 @@ class EndpointStore {
       this.setConnectionInfo(connectionInfo)
     }).catch(() => {
       this.connectionInfoLoading = false
+      return Promise.reject()
     })
   }
 

+ 95 - 4
src/stores/ProjectStore.js

@@ -15,17 +15,24 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 // @flow
 
 import { observable, action } from 'mobx'
+import type { Project, RoleAssignment, Role } from '../types/Project'
+import type { User } from '../types/User'
 
-import type { Project } from '../types/Project'
 import ProjectSource from '../sources/ProjectSource'
-
+import userStore from '../stores/UserStore'
 
 class ProjectStore {
   @observable projects: Project[] = []
   @observable loading: boolean = false
+  @observable roleAssignments: RoleAssignment[] = []
+  @observable roles: Role[] = []
+  @observable projectDetails: ?Project = null
+  @observable users: User[] = []
+  @observable usersLoading: boolean = false
+  @observable updating: boolean = false
 
-  @action getProjects(): Promise<void> {
-    this.loading = true
+  @action getProjects(showLoading?: boolean): Promise<void> {
+    if (showLoading) this.loading = true
     return ProjectSource.getProjects().then((projects: Project[]) => {
       this.loading = false
       this.projects = projects
@@ -33,6 +40,90 @@ class ProjectStore {
       this.loading = false
     })
   }
+
+  @action getRoleAssignments(): Promise<void> {
+    return ProjectSource.getRoleAssignments().then((assignments: RoleAssignment[]) => {
+      this.roleAssignments = assignments
+    })
+  }
+
+  @action getRoles(): Promise<void> {
+    return ProjectSource.getRoles().then((roles: Role[]) => {
+      this.roles = roles
+    })
+  }
+
+  @action getProjectDetails(projectId: string): Promise<void> {
+    this.loading = true
+    return ProjectSource.getProjectDetails(projectId).then((project: Project) => {
+      this.projectDetails = project
+      this.loading = false
+    }).catch(() => {
+      this.loading = false
+    })
+  }
+
+  @action getUsers(projectId: string, showLoading?: boolean): Promise<void> {
+    if (showLoading) this.usersLoading = true
+    return ProjectSource.getUsers(projectId).then((users: User[]) => {
+      this.usersLoading = false
+      this.users = users
+    }).catch(() => {
+      this.usersLoading = false
+    })
+  }
+
+  @action clearProjectDetails() {
+    this.projectDetails = null
+    this.users = []
+  }
+
+  @action removeUser(projectId: string, userId: string, roleId: string): Promise<void> {
+    return ProjectSource.removeUser(projectId, userId, roleId).then(() => {
+      this.users = this.users.filter(u => u.id !== userId)
+    })
+  }
+
+  @action assignUserRole(projectId: string, userId: string, roleId: string): Promise<void> {
+    return ProjectSource.assignUser(projectId, userId, roleId)
+  }
+
+  @action removeUserRole(projectId: string, userId: string, roleId: string): Promise<void> {
+    return ProjectSource.removeUser(projectId, userId, roleId)
+  }
+
+  @action update(projectId: string, project: Project): Promise<void> {
+    this.updating = true
+    return ProjectSource.update(projectId, project).then((project: Project) => {
+      this.projectDetails = project
+      this.updating = false
+    }).catch(() => {
+      this.updating = false
+    })
+  }
+
+  @action delete(projectId: string): Promise<void> {
+    return ProjectSource.delete(projectId)
+  }
+
+  @action add(project: Project): Promise<void> {
+    this.updating = true
+    let userId = userStore.loggedUser ? userStore.loggedUser.id : 'undefined'
+    return ProjectSource.add(project, userId).then((addedProject: Project) => {
+      if (!this.projects.find(p => p.id === addedProject.id)) {
+        let projects = this.projects
+        projects = [
+          ...projects,
+          addedProject,
+        ]
+        projects.sort((a, b) => a.name.localeCompare(b.name))
+        this.projects = [...projects]
+      }
+      this.updating = false
+    }).catch(() => {
+      this.updating = false
+    })
+  }
 }
 
 export default new ProjectStore()

+ 167 - 43
src/stores/UserStore.js

@@ -16,6 +16,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 import { observable, action } from 'mobx'
 import type { User, Credentials } from '../types/User'
+import type { Project } from '../types/Project'
 import UserSource from '../sources/UserSource'
 import projectStore from './ProjectStore'
 import notificationStore from '../stores/NotificationStore'
@@ -23,70 +24,89 @@ import notificationStore from '../stores/NotificationStore'
 /**
  * This is the authentication / authorization flow:
  * 1. Post username and password unscoped login. Set unscoped token in cookies.
- * 2. Post unscoped token with project id. Set scoped token and project id in cookies.
- * 3. Get token login on subsequent app reloads to retrieve the user info.
+ * 2. Get user details with unscoped token to see if he has project id
+ * 3. Post unscoped token with project id (either from his details or from cookies). Set scoped token and project id in cookies.
+ * 4. Get token login on subsequent app reloads to retrieve the user info.
  * 
  * After token expiration, the app is redirected to login page.
  */
 class UserStore {
-  @observable user: ?User = null
+  @observable loggedUser: ?User = null
+  @observable users: User[] = []
   @observable loading: boolean = false
   @observable loginFailedResponse: any = null
+  @observable userDetails: ?User = null
+  @observable userDetailsLoading: boolean = false
+  @observable updating: boolean = false
+  @observable loggedIn: boolean = false
+  @observable projects: Project[] = []
+  @observable allUsersLoading: boolean = false
 
   @action login(creds: Credentials): Promise<void> {
     this.loading = true
-    this.user = null
+    this.loggedUser = null
     this.loginFailedResponse = null
 
-    return UserSource.login(creds).then(() => {
-      return this.loginScoped()
-    }).then((user: User) => {
+    return UserSource.login(creds).then((auth: any) => {
+      this.loggedUser = { id: auth.token.user.id, email: '', name: '', project: { id: '', name: '' } }
+      return this.getLoggedUserInfo()
+    }).then(() => {
+      return this.loginScoped(this.loggedUser ? this.loggedUser.project_id : '', true)
+    }).then(() => {
+      return this.isAdmin()
+    }).then(() => {
       this.loading = false
+      this.loggedIn = true
       notificationStore.notify('Signed in', 'success')
-      this.user = user
-      this.getUserInfo(user)
     }).catch((reason) => {
       this.loading = false
       this.loginFailedResponse = reason
     })
   }
 
-  @action loginScoped(projectId?: string): Promise<User> {
-    return new Promise((resolve) => {
-      const sourceLoginScoped = () => {
-        UserSource.loginScoped(projectId || projectStore.projects[0].id).then((user: User) => {
-          this.user = { ...user, scoped: true }
-          resolve(user)
-        })
+  @action loginScoped(projectId?: string, skipProjectCookie?: boolean): Promise<User> {
+    return projectStore.getProjects().then(() => {
+      let projects = projectStore.projects.filter(p => p.enabled)
+      if (projects.length === 0) {
+        return Promise.reject({ status: 500, message: 'There are no projects assigned to user.' })
       }
-      if (projectStore.projects && projectStore.projects.length) {
-        sourceLoginScoped()
-      } else {
-        projectStore.getProjects().then(() => {
-          sourceLoginScoped()
-        })
+
+      let project = projects.find(p => p.id === projectId)
+      let id = (project && project.id) || projects[0].id
+      return UserSource.loginScoped(id, Boolean(id && skipProjectCookie))
+    }).then((user: User) => {
+      if (!this.loggedUser) {
+        return Promise.reject('No Logged in user')
       }
+      this.loggedUser.scoped = true
+      this.loggedUser.project = user.project
+      return this.loggedUser
     })
   }
 
-  @action getUserInfo(user: User): Promise<void> {
-    return UserSource.getUserInfo(user).then((userData: User) => {
-      this.user = { ...this.user, ...userData }
-    }).catch(reason => {
-      console.error('Error while getting user data', reason)
-      notificationStore.notify('Error while getting user data', 'error')
+  @action getLoggedUserInfo(): Promise<void> {
+    if (!this.loggedUser) {
+      return Promise.reject('No logged-in user')
+    }
+
+    return UserSource.getUserInfo(this.loggedUser.id).then((userData: User) => {
+      this.loggedUser = { ...this.loggedUser, ...userData, isAdmin: false }
     })
   }
 
   @action tokenLogin(): Promise<void> {
-    this.user = null
+    this.loggedUser = null
     this.loading = true
 
     return UserSource.tokenLogin().then(user => {
-      this.loading = false
-      this.user = { ...this.user, ...user }
+      this.loggedUser = { ...this.loggedUser, ...user }
       notificationStore.notify('Signed in', 'success')
-      this.getUserInfo(user)
+      return this.getLoggedUserInfo()
+    }).then(() => {
+      return this.isAdmin()
+    }).then(() => {
+      this.loading = false
+      this.loggedIn = true
     }).catch(() => {
       this.loading = false
     })
@@ -94,26 +114,130 @@ class UserStore {
 
   @action switchProject(projectId: string): Promise<void> {
     notificationStore.notify('Switching projects')
-    return new Promise((resolve, reject) => {
-      UserSource.switchProject().then(() => {
-        return this.loginScoped(projectId)
-      }).then(() => {
-        resolve()
-      }).catch(reason => {
-        console.error('Error switching projects', reason)
-        notificationStore.notify('Error switching projects')
-        this.logout()
-        reject()
-      })
+    return UserSource.switchProject().then(() => {
+      return this.loginScoped(projectId)
+    }).then(() => {
+      return this.isAdmin()
+    }).catch(reason => {
+      console.error('Error switching projects', reason)
+      notificationStore.notify('Error switching projects')
+      this.logout()
     })
   }
 
   @action logout(): Promise<void> {
+    this.loggedIn = false
+
     return UserSource.logout().catch(reason => {
       console.log('Error logging out', reason)
       notificationStore.notify('Error logging out')
     })
   }
+
+  @action getAllUsers(showLoading?: boolean): Promise<void> {
+    if (showLoading) this.allUsersLoading = true
+
+    return UserSource.getAllUsers().then(users => {
+      this.users = users
+      this.allUsersLoading = false
+    }).catch(() => {
+      this.allUsersLoading = false
+    })
+  }
+
+  @action getUserInfo(userId: string): Promise<void> {
+    this.userDetailsLoading = true
+
+    return UserSource.getUserInfo(userId).then(user => {
+      this.userDetails = user
+      this.userDetailsLoading = false
+    }).catch(() => {
+      this.userDetailsLoading = false
+    })
+  }
+
+  @action isAdmin(): Promise<void> {
+    if (!this.loggedUser) {
+      return Promise.resolve()
+    }
+    this.loggedUser.isAdmin = false
+    return UserSource.isAdmin(this.loggedUser.id).then(isAdmin => {
+      if (this.loggedUser) {
+        this.loggedUser.isAdmin = isAdmin
+      }
+    }).catch(() => {
+      if (window.location.href.indexOf('#/project') > -1 || window.location.href.indexOf('#/user') > -1) {
+        window.location.href = '#/'
+      }
+    })
+  }
+
+  @action clearUserDetails() {
+    this.userDetailsLoading = false
+    this.userDetails = null
+  }
+
+  @action update(userId: string, user: User): Promise<void> {
+    this.updating = true
+
+    return UserSource.update(userId, user, this.userDetails).then((user: User) => {
+      this.userDetails = user
+      this.updating = false
+      if (this.loggedUser && this.loggedUser.id === userId) {
+        this.loggedUser.name = user.name
+      }
+    }).catch(() => {
+      this.updating = false
+    })
+  }
+
+  @action assignUserToProject(userId: string, projectId: string): Promise<void> {
+    this.updating = true
+
+    return UserSource.assignUserToProject(userId, projectId).then(() => {
+      this.updating = false
+    }).catch(() => { this.updating = false })
+  }
+
+  @action assignUserToProjectWithRole(userId: string, projectId: string, roleId: string): Promise<void> {
+    return UserSource.assignUserToProjectWithRole(userId, projectId, roleId)
+  }
+
+  @action add(user: User): Promise<?User> {
+    this.updating = true
+
+    return UserSource.add(user).then((user: User) => {
+      if (!this.users.find(u => u.id === user.id)) {
+        this.users = [
+          ...this.users,
+          user,
+        ]
+        this.users.sort((a, b) => a.name.localeCompare(b.name))
+      }
+      this.updating = false
+      return user
+    }).catch((ex) => {
+      console.error(ex)
+      this.updating = false
+      return null
+    })
+  }
+
+  @action delete(userId: string): Promise<void> {
+    return UserSource.delete(userId).then(() => {
+      this.users = this.users.filter(u => u.id === userId)
+    })
+  }
+
+  @action getProjects(userId: string): Promise<void> {
+    return UserSource.getProjects(userId).then((projects: Project[]) => {
+      this.projects = projects
+    })
+  }
+
+  @action clearProjects() {
+    this.projects = []
+  }
 }
 
 export default new UserStore()

+ 26 - 0
src/types/Project.js

@@ -16,4 +16,30 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 export type Project = {
   id: string,
+  name: string,
+  enabled?: boolean,
+  description?: string,
+  enabled?: boolean,
+}
+
+export type Role = {
+  id: string,
+  name: string,
+}
+
+export type RoleAssignment = {
+  scope: {
+    project: {
+      id: string,
+      name: string,
+    },
+  },
+  role: {
+    id: string,
+    name: string,
+  },
+  user: {
+    id: string,
+    name: string,
+  },
 }

+ 5 - 0
src/types/User.js

@@ -22,6 +22,11 @@ export type User = {
   email: string,
   name: string,
   id: string,
+  description?: string,
+  enabled?: boolean,
+  project_id?: string,
+  domain_id?: string,
+  isAdmin?: boolean,
 }
 
 export type Credentials = {

+ 1 - 1
src/types/WizardData.js

@@ -24,7 +24,7 @@ export type WizardData = {
   schedules?: Schedule[],
   selectedInstances?: ?Instance[],
   networks?: ?NetworkMap[],
-  source?: Endpoint,
+  source?: ?Endpoint,
   target?: Endpoint,
 }
 

+ 9 - 2
src/utils/ApiCaller.js

@@ -90,8 +90,15 @@ class ApiCaller {
         if (error.response) {
           // The request was made and the server responded with a status code
           // that falls out of the range of 2xx
-          if ((error.response.status !== 401 || window.location.hash !== loginUrl) && !options.quietError && error.response.data.error) {
-            notificationStore.notify(error.response.data.error.message, 'error')
+          if (
+            (error.response.status !== 401 || window.location.hash !== loginUrl) &&
+            !options.quietError &&
+            error.response.data) {
+            let data = error.response.data
+            let message = (data.error && data.error.message) || data.description
+            if (message) {
+              notificationStore.notify(message, 'error')
+            }
           }
 
           if (error.response.status === 401 && window.location.hash !== loginUrl) {