changeset 585:ef307bd6b5d8

refac: restructured client application To make the application more accessible for developers, the structure was reorganized. Instead of sticking to technical terminology, the application terminology is according to the domain: I.e. "map" contains everything regarding map (including store).
author Thomas Junk <thomas.junk@intevation.de>
date Fri, 07 Sep 2018 11:13:56 +0200
parents 8b66a10aaf8a
children 2821e087a973
files client/src/App.vue client/src/application/Sidebar.vue client/src/application/Topbar.vue client/src/application/User.vue client/src/application/assets/application.scss client/src/application/assets/logo.png client/src/application/assets/tooltip.scss client/src/application/assets/user.png client/src/application/lib/errors.js client/src/application/lib/http.js client/src/application/lib/session.js client/src/application/stores/application.js client/src/application/stores/user.js client/src/fairway/Fairwayprofile.vue client/src/layers/Layers.vue client/src/layers/Layerselect.vue client/src/login/Login.vue client/src/map/Maplayer.vue client/src/map/Mapview.vue client/src/map/store.js client/src/router.js client/src/store.js client/src/usermanagement/Passwordfield.vue client/src/usermanagement/Userdetail.vue client/src/usermanagement/Usermanagement.vue client/src/usermanagement/Users.vue client/src/usermanagement/store.js
diffstat 27 files changed, 2133 insertions(+), 10 deletions(-) [+]
line wrap: on
line diff
--- a/client/src/App.vue	Fri Sep 07 09:13:03 2018 +0200
+++ b/client/src/App.vue	Fri Sep 07 11:13:56 2018 +0200
@@ -42,8 +42,8 @@
 </style>
 
 <script>
-import Sidebar from "./components/Sidebar";
-import Topbar from "./components/Topbar";
+import Sidebar from "./application/Sidebar";
+import Topbar from "./application/Topbar";
 import { mapGetters } from "vuex";
 
 export default {
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/application/Sidebar.vue	Fri Sep 07 11:13:56 2018 +0200
@@ -0,0 +1,89 @@
+<template>
+  <div :class="sidebarStyle">
+    <div :class="menuStyle">
+      <router-link to="/" class="text-body d-flex flex-row nav-link">
+        <i class="fa fa-map-o align-self-center navicon"></i>Riverbed Morphology</router-link>
+      <div v-if="isSysAdmin">
+        <hr/>
+        <div class="nav-link d-flex menupadding text-muted">Administration</div>
+        <router-link class="text-body d-flex flex-row nav-link" to="usermanagement">
+          <i class="fa fa-address-card-o align-self-center navicon"></i>Users
+        </router-link>
+      </div>
+    </div>
+    <User></User>
+  </div>
+</template>
+
+<script>
+import { mapGetters } from "vuex";
+import User from "./User";
+
+export default {
+  name: "sidebar",
+  components: {
+    User: User
+  },
+  computed: {
+    ...mapGetters("user", ["isSysAdmin"]),
+    ...mapGetters("application", ["sidebarCollapsed"]),
+    menuStyle() {
+      return {
+        menu: true,
+        nav: true,
+        "flex-column": true
+      };
+    },
+    sidebarStyle() {
+      return {
+        "ui-element": true,
+        sidebar: true,
+        overlay: true,
+        sidebarcollapsed: this.sidebarCollapsed,
+        sidebarextended: !this.sidebarCollapsed,
+        shadow: true
+      };
+    }
+  }
+};
+</script>
+
+<style lang="scss">
+@import "./assets/application.scss";
+
+.router-link-exact-active {
+  background-color: #f2f2f2;
+}
+
+.navicon {
+  margin-right: $small-offset;
+}
+
+.menu {
+  padding-top: 5vh;
+  height: 90%;
+}
+
+.sidebar {
+  top: 0;
+  background-color: #ffffff;
+  padding-top: $large-offset;
+  height: 100vh;
+  opacity: 0.96;
+}
+
+.overlay {
+  position: absolute;
+  z-index: -1;
+}
+
+.sidebarcollapsed {
+  transition: $transition;
+  left: -250px;
+}
+
+.sidebarextended {
+  transition: $transition;
+  left: 0;
+}
+</style>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/application/Topbar.vue	Fri Sep 07 11:13:56 2018 +0200
@@ -0,0 +1,73 @@
+<template>
+  <div class="topbar d-flex flex-row justify-content-between">
+    <div>
+      <i @click="toggleSidebar" class="ui-element menubutton fa fa-bars"></i>
+    </div>
+    <div v-if="routeName != 'usermanagement'" class="input-group searchcontainer">
+      <div class="input-group-prepend">
+        <span class="input-group-text searchlabel" for="search">
+          <i class="fa fa-search"></i>
+        </span>
+      </div>
+      <input id="search" type="text" class="form-control ui-element search searchbar">
+    </div>
+    <Layers v-if="routeName != 'usermanagement'"></Layers>
+  </div>
+</template>
+
+<style lang="scss">
+@import "./assets/application.scss";
+
+.menubutton {
+  background-color: white;
+  padding: 0.5rem;
+}
+
+.searchcontainer {
+  margin-left: 20vw;
+  margin-right: auto;
+  width: 50vw !important;
+  height: 39px;
+  border-radius: 0.25rem;
+}
+
+.searchbar {
+  margin-left: auto;
+  margin-right: auto;
+  height: 50px;
+}
+
+.topbar {
+  padding-top: 2vh;
+  margin-right: 1vw;
+  margin-left: 0;
+}
+
+.logout {
+  font-size: x-large;
+}
+</style>
+
+
+<script>
+import Layers from "../layers/Layers";
+
+export default {
+  name: "topbar",
+  components: {
+    Layers: Layers
+  },
+  methods: {
+    toggleSidebar() {
+      this.$store.commit("application/toggleSidebar");
+    }
+  },
+  computed: {
+    routeName() {
+      const routeName = this.$route.name;
+      console.log(routeName);
+      return routeName;
+    }
+  }
+};
+</script>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/application/User.vue	Fri Sep 07 11:13:56 2018 +0200
@@ -0,0 +1,38 @@
+<template>
+  <div class="ui-element d-flex justify-content-around usermanagement">
+    <img class="userpic" src="../application/assets/user.png">
+    <span class="username align-self-center">{{ userinfo }}</span>
+    <span class="logout align-self-center" @click="logoff">
+      <i class="fa fa-power-off"></i>
+    </span>
+  </div>
+</template>
+
+<style lang="scss">
+.usermanagement {
+  background: white;
+  width: 150px;
+  padding: 0.25rem;
+  border-radius: 0.25rem;
+}
+</style>
+
+<script>
+import { mapGetters } from "vuex";
+export default {
+  name: "user",
+  data() {
+    return {};
+  },
+  methods: {
+    logoff() {
+      this.$store.commit("user/clear_auth");
+      this.$store.commit("application/resetSidebar");
+      this.$router.push("/login");
+    }
+  },
+  computed: {
+    ...mapGetters("user", ["userinfo"])
+  }
+};
+</script>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/application/assets/application.scss	Fri Sep 07 11:13:56 2018 +0200
@@ -0,0 +1,24 @@
+$offset: 20px;
+$small-offset: 10px;
+$large-offset: 30px;
+$x-large-offset: 50px;
+$iconsize: 3em;
+$iconLineHeight: 0.25em;
+$iconwidth: 20px;
+$basic-shadow: 1px 3px 8px 2px rgba(220, 220, 220, 0.75);
+$basic-shadow-light: 1px 1px 12px 1px rgba(235, 235, 235, 0.75);
+$transition: 0.5s;
+$transition-fast: 0.1s;
+$transition-slow: 3s;
+$topbarheight: 5vh;
+
+%fully-centered {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
+}
+
+.ui-element {
+  pointer-events: auto;
+}
Binary file client/src/application/assets/logo.png has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/application/assets/tooltip.scss	Fri Sep 07 11:13:56 2018 +0200
@@ -0,0 +1,109 @@
+.tooltip {
+  display: block !important;
+  z-index: 10000;
+
+  .tooltip-inner {
+    background: black;
+    color: white;
+    border-radius: 16px;
+    padding: 5px 10px 4px;
+  }
+
+  .tooltip-arrow {
+    width: 0;
+    height: 0;
+    border-style: solid;
+    position: absolute;
+    margin: 5px;
+    border-color: black;
+    z-index: 1;
+  }
+
+  &[x-placement^="top"] {
+    margin-bottom: 5px;
+
+    .tooltip-arrow {
+      border-width: 5px 5px 0 5px;
+      border-left-color: transparent !important;
+      border-right-color: transparent !important;
+      border-bottom-color: transparent !important;
+      bottom: -5px;
+      left: calc(50% - 5px);
+      margin-top: 0;
+      margin-bottom: 0;
+    }
+  }
+
+  &[x-placement^="bottom"] {
+    margin-top: 5px;
+
+    .tooltip-arrow {
+      border-width: 0 5px 5px 5px;
+      border-left-color: transparent !important;
+      border-right-color: transparent !important;
+      border-top-color: transparent !important;
+      top: -5px;
+      left: calc(50% - 5px);
+      margin-top: 0;
+      margin-bottom: 0;
+    }
+  }
+
+  &[x-placement^="right"] {
+    margin-left: 5px;
+
+    .tooltip-arrow {
+      border-width: 5px 5px 5px 0;
+      border-left-color: transparent !important;
+      border-top-color: transparent !important;
+      border-bottom-color: transparent !important;
+      left: -5px;
+      top: calc(50% - 5px);
+      margin-left: 0;
+      margin-right: 0;
+    }
+  }
+
+  &[x-placement^="left"] {
+    margin-right: 5px;
+
+    .tooltip-arrow {
+      border-width: 5px 0 5px 5px;
+      border-top-color: transparent !important;
+      border-right-color: transparent !important;
+      border-bottom-color: transparent !important;
+      right: -5px;
+      top: calc(50% - 5px);
+      margin-left: 0;
+      margin-right: 0;
+    }
+  }
+
+  &.popover {
+    $color: #f9f9f9;
+
+    .popover-inner {
+      background: $color;
+      color: black;
+      padding: 24px;
+      border-radius: 5px;
+      box-shadow: 0 5px 30px rgba(black, 0.1);
+    }
+
+    .popover-arrow {
+      border-color: $color;
+    }
+  }
+
+  &[aria-hidden="true"] {
+    visibility: hidden;
+    opacity: 0;
+    transition: opacity 0.15s, visibility 0.15s;
+  }
+
+  &[aria-hidden="false"] {
+    visibility: visible;
+    opacity: 1;
+    transition: opacity 0.15s;
+  }
+}
Binary file client/src/application/assets/user.png has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/application/lib/errors.js	Fri Sep 07 11:13:56 2018 +0200
@@ -0,0 +1,11 @@
+/** facade to wrap calls to vue2-toastr */
+import app from "../../main";
+
+const displayError = ({ title, message }) => {
+  app.$toast.error({
+    title: title,
+    message: message
+  });
+};
+
+export { displayError };
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/application/lib/http.js	Fri Sep 07 11:13:56 2018 +0200
@@ -0,0 +1,8 @@
+import axios from "axios";
+
+export const HTTP = axios.create({
+  baseURL: process.env.VUE_APP_API_URL || "/api"
+  /* headers: {
+    Authorization: 'Bearer {token}'
+  }*/
+});
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/application/lib/session.js	Fri Sep 07 11:13:56 2018 +0200
@@ -0,0 +1,23 @@
+/**
+ * Compares whether session is current
+ * based on the expiry information and the
+ * current date
+ *
+ * @param  {number} expiresFromPastSession
+ */
+function sessionStillActive(expiresFromPastSession) {
+  if (!expiresFromPastSession) return false;
+  const now = Date.now();
+  const stillActive = now < expiresFromPastSession;
+  return stillActive;
+}
+/**
+ * Converts a given unix time to Milliseconds
+ *
+ * @param  {string} timestring
+ */
+function toMillisFromString(timestring) {
+  return timestring * 1000;
+}
+
+export { sessionStillActive, toMillisFromString };
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/application/stores/application.js	Fri Sep 07 11:13:56 2018 +0200
@@ -0,0 +1,41 @@
+const defaultCollapseState = true;
+
+const Application = {
+  namespaced: true,
+  state: {
+    appTitle: process.env.VUE_APP_TITLE,
+    secondaryLogo: process.env.VUE_APP_SECONDARY_LOGO_URL,
+    sidebar: {
+      iscollapsed: defaultCollapseState
+    },
+    countries: ["AT", "SK", "HU", "HR", "RS", "BiH", "BG", "RO", "UA"]
+  },
+  getters: {
+    countries: state => {
+      return state.countries;
+    },
+    sidebarCollapsed: state => {
+      return state.sidebar.iscollapsed;
+    },
+    appTitle: state => {
+      return state.appTitle;
+    },
+    secondaryLogo: state => {
+      return state.secondaryLogo;
+    }
+  },
+  mutations: {
+    toggleSidebar: state => {
+      state.sidebar.iscollapsed = !state.sidebar.iscollapsed;
+    },
+    resetSidebar: state => {
+      state.sidebar.iscollapsed = defaultCollapseState;
+    },
+    collapseSidebar: state => {
+      state.sidebar.iscollapsed = true;
+    }
+  },
+  actions: {}
+};
+
+export default Application;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/application/stores/user.js	Fri Sep 07 11:13:56 2018 +0200
@@ -0,0 +1,81 @@
+import { HTTP } from "../lib/http";
+
+const User = {
+  namespaced: true,
+  state: {
+    authenticated: false,
+    expires: null,
+    roles: [],
+    user: ""
+  },
+  getters: {
+    isAuthenticated: state => {
+      return state.authenticated;
+    },
+    userinfo: state => {
+      return state.user;
+    },
+    roles: state => {
+      return state.roles;
+    },
+    expires: state => {
+      return state.expires;
+    },
+    isWaterwayAdmin: state => {
+      return state.roles.includes("waterway_admin");
+    },
+    isSysAdmin: state => {
+      return state.roles.includes("sys_admin");
+    }
+  },
+  mutations: {
+    auth_success: (state, data) => {
+      const { token, user, expires, roles } = data;
+      localStorage.setItem("expires", expires);
+      localStorage.setItem("roles", roles);
+      localStorage.setItem("token", token);
+      localStorage.setItem("user", user);
+      state.expires = expires;
+      state.roles = roles;
+      state.user = user;
+      state.authenticated = true;
+    },
+    clear_auth: state => {
+      state.authenticated = false;
+      state.expires = null;
+      state.roles = [];
+      state.user = "";
+      localStorage.clear();
+    },
+    set_user: (state, name) => {
+      state.user = name;
+    },
+    set_roles: (state, roles) => {
+      state.roles = roles;
+    },
+    set_expires: (state, expires) => {
+      state.expires = expires;
+    },
+    set_authenticate: state => {
+      state.authenticated = true;
+    }
+  },
+  actions: {
+    login({ commit }, user) {
+      // using POST is a bit more secure than GET
+      return new Promise((resolve, reject) => {
+        HTTP.post("/login", user)
+          .then(response => {
+            commit("auth_success", response.data);
+            resolve(response);
+          })
+          .catch(error => {
+            commit("clear_auth");
+            reject(error);
+          });
+      });
+    }
+  }
+};
+
+export default User;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/fairway/Fairwayprofile.vue	Fri Sep 07 11:13:56 2018 +0200
@@ -0,0 +1,163 @@
+<template>
+  <div class="fairwayprofile">
+    <svg :width="width +'px'" :height="height +'px'">
+    </svg>
+  </div>
+</template>
+
+<style lang="scss">
+.fairwayprofile {
+  background-color: white;
+  margin-left: auto;
+  margin-right: auto;
+  margin-top: auto;
+  margin-bottom: auto;
+}
+</style>
+
+<script>
+import * as d3 from "d3";
+
+const WATER_COLOR = "#005DFF";
+const GROUND_COLOR = "#4A2F06";
+
+const sampleData = [
+  { x: 0, y: -3.0 },
+  { x: 25, y: -2.0 },
+  { x: 50, y: -4.5 },
+  { x: 75, y: -4.0 },
+  { x: 100, y: -3.0 },
+  { x: 125, y: -4.0 },
+  { x: 150, y: -5.0 },
+  { x: 175, y: -4.0 },
+  { x: 200, y: -3.0 },
+  { x: 225, y: -3.5 },
+  { x: 250, y: -3.0 },
+  { x: 300, y: -2.5 }
+];
+
+export default {
+  name: "fairwayprofile",
+  props: ["width", "height", "xScale", "yScaleLeft", "yScaleRight", "margin"],
+  data() {
+    return {};
+  },
+  methods: {
+    generateCoordinates(svg, height, width) {
+      let xScale = d3
+        .scaleLinear()
+        .domain(this.xScale)
+        .rangeRound([0, width]);
+
+      xScale.ticks(5);
+      let yScaleLeft = d3
+        .scaleLinear()
+        .domain(this.yScaleLeft)
+        .rangeRound([height, 0]);
+
+      let yScaleRight = d3
+        .scaleLinear()
+        .domain(this.yScaleRight)
+        .rangeRound([height, 0]);
+
+      let xAxis = d3.axisBottom(xScale);
+      let yAxis = d3.axisLeft(yScaleLeft);
+      let yAxis2 = d3.axisRight(yScaleRight);
+      let graph = svg
+        .append("g")
+        .attr(
+          "transform",
+          "translate(" + this.margin.left + "," + this.margin.top + ")"
+        );
+      graph
+        .append("g")
+        .attr("transform", "translate(0," + height + ")")
+        .call(xAxis.ticks(5));
+      graph.append("g").call(yAxis);
+      graph
+        .append("g")
+        .attr("transform", "translate(" + width + ",0)")
+        .call(yAxis2);
+      return { xScale, yScaleLeft, yScaleRight, graph };
+    },
+    drawWaterlevel({ graph, xScale, yScaleRight, height }) {
+      let waterArea = d3
+        .area()
+        .x(function(d) {
+          return xScale(d.x);
+        })
+        .y0(height)
+        .y1(function(d) {
+          return yScaleRight(d.y);
+        });
+      graph
+        .append("path")
+        .datum([{ x: 0, y: 0 }, { x: 300, y: 0 }])
+        .attr("fill", WATER_COLOR)
+        .attr("stroke", WATER_COLOR)
+        .attr("d", waterArea);
+    },
+    drawProfile({ graph, xScale, yScaleRight, sampleData, height }) {
+      let profileLine = d3
+        .line()
+        .x(d => {
+          return xScale(d.x);
+        })
+        .y(d => {
+          return yScaleRight(d.y);
+        });
+      let profileArea = d3
+        .area()
+        .x(function(d) {
+          return xScale(d.x);
+        })
+        .y0(height)
+        .y1(function(d) {
+          return yScaleRight(d.y);
+        });
+      graph
+        .append("path")
+        .datum(sampleData)
+        .attr("fill", GROUND_COLOR)
+        .attr("stroke", GROUND_COLOR)
+        .attr("stroke-width", 3)
+        .attr("d", profileArea);
+      graph
+        .append("path")
+        .datum(sampleData)
+        .attr("fill", "none")
+        .attr("stroke", "black")
+        .attr("stroke-linejoin", "round")
+        .attr("stroke-linecap", "round")
+        .attr("stroke-width", 3)
+        .attr("d", profileLine);
+    }
+  },
+  mounted() {
+    let svg = d3.select("svg");
+    const width = this.width - this.margin.right - this.margin.left;
+    const height = this.height - this.margin.top - this.margin.bottom;
+
+    const { xScale, yScaleRight, graph } = this.generateCoordinates(
+      svg,
+      height,
+      width
+    );
+    this.drawWaterlevel({
+      graph,
+      xScale,
+      yScaleRight,
+      height,
+      width
+    });
+    this.drawProfile({
+      graph,
+      xScale,
+      yScaleRight,
+      sampleData,
+      height,
+      width
+    });
+  }
+};
+</script>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/layers/Layers.vue	Fri Sep 07 11:13:56 2018 +0200
@@ -0,0 +1,41 @@
+<template>
+  <div class="ui-element card layerselection shadow">
+    <div class="card-body">
+      <div class="headline">
+        <h4 class="card-title">Layers</h4>
+      </div>
+      <hr>
+      <div class="d-flex flex-column">
+        <Layerselect :layerindex="index" :layername="layer.name" v-for="(layer, index) in layers" :key="layer.name" :isVisible="layer.isVisible" @visibilityToggled="visibilityToggled"></Layerselect>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style lang="scss">
+@import "../application/assets/application.scss";
+
+.layerselection {
+  background-color: white;
+  margin-left: 0.5rem;
+}
+</style>
+
+<script>
+import Layerselect from "./Layerselect";
+import { mapGetters } from "vuex";
+export default {
+  name: "layers",
+  components: {
+    Layerselect
+  },
+  computed: {
+    ...mapGetters("mapstore", ["layers"])
+  },
+  methods: {
+    visibilityToggled(layer) {
+      this.$store.commit("mapstore/toggleVisibility", layer);
+    }
+  }
+};
+</script>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/layers/Layerselect.vue	Fri Sep 07 11:13:56 2018 +0200
@@ -0,0 +1,23 @@
+<template>
+  <div class="form-check d-flex flex-row">
+    <input class="form-check-input" @change="visibilityToggled" :id="layername" type="checkbox" :checked="isVisible">
+    <label class="form-check-label" :for="layername">{{this.layername}}</label>
+  </div>
+</template>
+
+<style lang="sass">
+
+</style>
+
+
+<script>
+export default {
+  props: ["layername", "layerindex", "isVisible"],
+  name: "layerselect",
+  methods: {
+    visibilityToggled() {
+      this.$emit("visibilityToggled", this.layerindex);
+    }
+  }
+};
+</script>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/login/Login.vue	Fri Sep 07 11:13:56 2018 +0200
@@ -0,0 +1,201 @@
+(<template>
+  <div class="d-flex flex-column login shadow-lg">
+    <div class="loginmask">
+      <!-- logo section -->
+      <div class="d-flex flex-row justify-content-center mb-3">
+        <div class="logo"><img src="../application/assets/logo.png"></div>
+        <div class="title">
+          <h1>{{ appTitle }}</h1>
+        </div>
+      </div>
+      <!-- end logo section -->
+      <form class="loginform" @submit.prevent="login">
+        <div id="alert" :style="errorMessageStyle" :class="errorMessageClass" role="alert">
+          <span>{{ errorMessage }}</span>
+        </div>
+        <div class="input-group mb-3">
+          <input type="text" v-model="user" id="inputUsername" class="form-control shadow-sm" :placeholder="usernameLabel" required autofocus>
+        </div>
+        <div class="input-group mb-3">
+          <input :type="isPasswordVisible" v-model="password" id="inputPassword" class="form-control shadow-sm" :placeholder='passwordLabel' :required='!showPasswordReset' :disabled='showPasswordReset'>
+          <div class="input-group-append">
+            <span class="input-group-text disabled" id="basic-addon2" @click="showPassword">
+              <i :class="eyeIcon"></i>
+            </span>
+          </div>
+        </div>
+        <button v-if="showPasswordReset==false" class="btn btn-primary btn-block shadow-sm" :disabled="submitted || showPasswordReset" type="submit">
+          <translate>Login</translate>
+        </button>
+        <div v-if="showPasswordReset" class="passwordreset">
+          <button class="btn btn-block btn-info" type="button" @click="resetPassword">
+            <translate>Request password reset!</translate>
+          </button>
+          <div class="pull-right">
+            <a href="#" @click.prevent="togglePasswordReset">
+              <translate>back to login</translate>
+            </a>
+          </div>
+        </div>
+        <div v-else class="pull-right">
+          <a href="#" @click.prevent="togglePasswordReset">
+            <translate>Forgot password</translate>
+          </a>
+        </div>
+      </form>
+
+      <!-- bottom logo section -->
+      <div class="mb-3 secondary-logo"><img :src="secondaryLogo"></div>
+    </div>
+  </div>
+</template>)
+
+<style lang="scss">
+@import "../application/assets/application.scss";
+
+.login {
+  background-color: white;
+  min-width: 375px;
+  min-height: 500px;
+  @extend %fully-centered;
+}
+
+.loginform {
+  max-width: 375px;
+  margin-left: auto;
+  margin-right: auto;
+}
+
+.loginmask {
+  margin-left: $large-offset;
+  margin-right: $large-offset;
+  margin-top: $large-offset;
+}
+
+.logo {
+  margin-right: $offset;
+}
+
+.alert {
+  padding: 0.5rem;
+}
+
+.secondary-logo {
+  max-width: 375px;
+  margin-left: auto;
+  margin-right: auto;
+  margin-bottom: auto;
+}
+</style>
+
+<script>
+import { mapGetters } from "vuex";
+import { HTTP } from "../application/lib/http.js";
+import { displayError } from "../application/lib/errors.js";
+
+export default {
+  name: "login",
+  data() {
+    return {
+      user: "",
+      password: "",
+      submitted: false,
+      loginFailed: false,
+      passwordJustResetted: false,
+      readablePassword: false,
+      showPasswordReset: false,
+      usernameToReset: ""
+    };
+  },
+  computed: {
+    errorMessage() {
+      if (this.loginFailed) return this.$gettext("Login failed");
+      if (this.passwordJustResetted)
+        return this.$gettext("Password reset requested!");
+      return "&npsp;";
+    },
+    passwordLabel() {
+      return this.$gettext("Enter passphrase");
+    },
+    usernameLabel() {
+      return this.$gettext("Enter username");
+    },
+    isPasswordVisible() {
+      return this.readablePassword ? "text" : "password";
+    },
+    eyeIcon() {
+      return {
+        fa: true,
+        "fa-eye": !this.readablePassword,
+        "fa-eye-slash": this.readablePassword
+      };
+    },
+    errorMessageStyle() {
+      if (this.loginFailed || this.passwordJustResetted) {
+        return "visibility:visible";
+      }
+      return "visibility:hidden";
+    },
+    errorMessageClass() {
+      let result = {
+        "mb-3": true,
+        errormessage: true,
+        alert: true
+      };
+      if (this.loginFailed) {
+        result["alert-danger"] = true;
+      }
+      if (this.passwordJustResetted) {
+        result["alert-info"] = true;
+      }
+      return result;
+    },
+    ...mapGetters("application", ["appTitle", "secondaryLogo"])
+  },
+  methods: {
+    login() {
+      this.submitted = true;
+      this.passwordJustResetted = false;
+      const { user, password } = this;
+      this.$store
+        .dispatch("user/login", { user, password })
+        .then(() => {
+          this.loginFailed = false;
+          this.$router.push("/");
+        })
+        .catch(error => {
+          this.loginFailed = true;
+          this.submitted = false;
+          const { status, data } = error.response;
+          displayError({
+            title: "Backend Error",
+            message: `${status}: ${data.message || data}`
+          });
+        });
+    },
+    showPassword() {
+      // disallowing toggle when in reset mode
+      if (this.showPasswordReset) return;
+      this.readablePassword = !this.readablePassword;
+    },
+    togglePasswordReset() {
+      this.passwordJustResetted = false;
+      this.showPasswordReset = !this.showPasswordReset;
+      this.loginFailed = false;
+    },
+    resetPassword() {
+      if (this.user) {
+        HTTP.post("/users/passwordreset", { user: this.user }).catch(error => {
+          const { status, data } = error.response;
+          displayError({
+            title: "Backend Error",
+            message: `${status}: ${data.message || data}`
+          });
+        });
+        this.togglePasswordReset();
+        this.passwordJustResetted = true;
+      }
+    }
+  }
+};
+</script>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/map/Maplayer.vue	Fri Sep 07 11:13:56 2018 +0200
@@ -0,0 +1,107 @@
+<template>
+  <div class="mapdisplay">
+    <div id="map"></div>
+    <!-- <div class="profile d-flex flex-row">
+      <Fairwayprofile height="300" width="1024" :xScale="[0, 300]" :yScaleLeft="[191, 199]" :yScaleRight="[-6, 1]" :margin="{ top: 20, right: 40, bottom: 20, left: 40 }"></Fairwayprofile>
+    </div> -->
+  </div>
+</template>
+
+<style lang="scss">
+@import "../application/assets/application.scss";
+
+.profile {
+  background-color: white;
+  height: 50vh-$topbarheight;
+}
+
+.mapdisplay {
+  height: 100vh;
+}
+
+#map {
+  height: 100vh;
+}
+
+.ol-zoom {
+  display: flex;
+  left: 15vw;
+  margin-top: 2vh;
+  z-index: 5;
+  background-color: white;
+}
+</style>
+
+<script>
+import { HTTP } from "../application/lib/http";
+import "ol/ol.css";
+import { Map, View } from "ol";
+// needed for vector filter example
+// import { greaterThan as greaterThanFilter } from "ol/format/filter.js";
+import { WFS, GeoJSON } from "ol/format.js";
+import { mapGetters } from "vuex";
+import Fairwayprofile from "../fairway/Fairwayprofile";
+
+export default {
+  name: "maplayer",
+  props: ["lat", "long", "zoom"],
+  components: {
+    Fairwayprofile
+  },
+  data() {
+    return {
+      projection: "EPSG:3857",
+      openLayersMap: null
+    };
+  },
+  computed: {
+    ...mapGetters("mapstore", ["layers"]),
+    layerData() {
+      return this.layers.map(x => {
+        return x.data;
+      });
+    }
+  },
+  methods: {},
+  mounted() {
+    var that = this;
+    this.openLayersMap = new Map({
+      layers: this.layerData,
+      target: "map",
+      view: new View({
+        center: [this.long, this.lat],
+        zoom: this.zoom,
+        projection: this.projection
+      })
+    });
+
+    var featureRequest = new WFS().writeGetFeature({
+      // srsName: "urn:ogc:def:crs:EPSG::4326",
+      srsName: "EPSG:3857",
+      featureNS: "gemma",
+      featurePrefix: "gemma",
+      featureTypes: ["fairway_dimensions"],
+      outputFormat: "application/json"
+      // example for a filter
+      //filter: greaterThanFilter("level_of_service", 0)
+    });
+
+    HTTP.post(
+      "/internal/wfs",
+      new XMLSerializer().serializeToString(featureRequest),
+      {
+        headers: {
+          "X-Gemma-Auth": localStorage.getItem("token"),
+          "Content-type": "text/xml; charset=UTF-8"
+        }
+      }
+    ).then(function(response) {
+      var features = new GeoJSON().readFeatures(JSON.stringify(response.data));
+      var vectorSrc = that.layers[2].data.getSource();
+      vectorSrc.addFeatures(features);
+      // would scale to the extend of all resulting features
+      // that.openLayersMap.getView().fit(vectorSrc.getExtent());
+    });
+  }
+};
+</script>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/map/Mapview.vue	Fri Sep 07 11:13:56 2018 +0200
@@ -0,0 +1,24 @@
+<template>
+  <div class="main d-flex flex-column">
+    <Maplayer :lat="6155376" :long="1819178" :zoom="11"></Maplayer>
+  </div>
+</template>
+
+<style lang="scss">
+@import "../application/assets/application.scss";
+
+.menubutton {
+  margin-left: $small-offset;
+}
+</style>
+
+<script>
+import Maplayer from "./Maplayer";
+
+export default {
+  name: "mainview",
+  components: {
+    Maplayer
+  }
+};
+</script>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/map/store.js	Fri Sep 07 11:13:56 2018 +0200
@@ -0,0 +1,58 @@
+//import { HTTP } from "../lib/http";
+
+import TileWMS from "ol/source/TileWMS.js";
+import { Tile as TileLayer, Vector as VectorLayer } from "ol/layer.js";
+import OSM from "ol/source/OSM";
+import { Stroke, Style } from "ol/style.js";
+import VectorSource from "ol/source/Vector.js";
+
+const MapStore = {
+  namespaced: true,
+  state: {
+    layers: [
+      {
+        name: "Open Streetmap",
+        data: new TileLayer({
+          source: new OSM()
+        }),
+        isVisible: true
+      },
+      {
+        name: "D4D",
+        data: new TileLayer({
+          source: new TileWMS({
+            url: "https://demo.d4d-portal.info/wms",
+            params: { LAYERS: "d4d", VERSION: "1.1.1", TILED: true }
+          })
+        }),
+        isVisible: true
+      },
+      {
+        name: "Fairways Dimensions",
+        data: new VectorLayer({
+          source: new VectorSource(),
+          style: new Style({
+            stroke: new Stroke({
+              color: "rgba(0, 0, 255, 1.0)",
+              width: 2
+            })
+          })
+        }),
+        isVisible: true
+      }
+    ]
+  },
+  getters: {
+    layers: state => {
+      return state.layers;
+    }
+  },
+  mutations: {
+    toggleVisibility: (state, layer) => {
+      state.layers[layer].isVisible = !state.layers[layer].isVisible;
+      state.layers[layer].data.setVisible(state.layers[layer].isVisible);
+    }
+  }
+};
+
+export default MapStore;
--- a/client/src/router.js	Fri Sep 07 09:13:03 2018 +0200
+++ b/client/src/router.js	Fri Sep 07 11:13:56 2018 +0200
@@ -1,12 +1,15 @@
 import Vue from "vue";
 import Router from "vue-router";
 import store from "./store";
-import { sessionStillActive, toMillisFromString } from "./lib/session";
+import {
+  sessionStillActive,
+  toMillisFromString
+} from "./application/lib/session";
 
 /*  facilitate codesplitting */
-const Login = () => import("./views/Login.vue");
-const Main = () => import("./views/Main.vue");
-const Usermanagement = () => import("./views/Usermanagement.vue");
+const Login = () => import("./login/Login.vue");
+const Main = () => import("./map/Mapview.vue");
+const Usermanagement = () => import("./usermanagement/Usermanagement.vue");
 
 Vue.use(Router);
 
--- a/client/src/store.js	Fri Sep 07 09:13:03 2018 +0200
+++ b/client/src/store.js	Fri Sep 07 11:13:56 2018 +0200
@@ -1,9 +1,9 @@
 import Vue from "vue";
 import Vuex from "vuex";
-import Application from "./stores/application";
-import user from "./stores/user";
-import usermanagement from "./stores/usermanagement";
-import mapstore from "./stores/mapstore";
+import Application from "./application/stores/application";
+import user from "./application/stores/user";
+import usermanagement from "./usermanagement/store";
+import mapstore from "./map/store";
 
 Vue.use(Vuex);
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/usermanagement/Passwordfield.vue	Fri Sep 07 11:13:56 2018 +0200
@@ -0,0 +1,43 @@
+<template>
+  <div>
+    <label for="password">{{this.label}}</label>
+    <div class="d-flex d-row">
+      <input :type="isPasswordVisible" @change="fieldChanged" class="form-control" :placeholder='placeholder' :required="required">
+      <span class="input-group-text" @click="showPassword"><i :class="eyeIcon"></i></span>
+    </div>
+    <div v-show="passworderrors" class="text-danger"><small><i class="fa fa-warning"></i> {{ this.passworderrors}}</small></div>
+  </div>
+</template>
+
+<script>
+export default {
+  name: "passwordfield",
+  props: ["model", "placeholder", "label", "passworderrors", "required"],
+  data() {
+    return {
+      password: "",
+      readablePassword: false
+    };
+  },
+  methods: {
+    showPassword() {
+      this.readablePassword = !this.readablePassword;
+    },
+    fieldChanged(e) {
+      this.$emit("fieldchange", e.target.value);
+    }
+  },
+  computed: {
+    isPasswordVisible() {
+      return this.readablePassword ? "text" : "password";
+    },
+    eyeIcon() {
+      return {
+        fa: true,
+        "fa-eye": !this.readablePassword,
+        "fa-eye-slash": this.readablePassword
+      };
+    }
+  }
+};
+</script>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/usermanagement/Userdetail.vue	Fri Sep 07 11:13:56 2018 +0200
@@ -0,0 +1,262 @@
+<template>
+  <div class="userdetails shadow fadeIn animated">
+    <div class="card">
+      <div class="card-header shadow-sm text-white bg-info mb-3">
+        {{ this.cardHeader }}
+        <span @click="closeDetailview" class="pull-right">
+          <i class="fa fa-close"></i>
+        </span>
+      </div>
+      <div class="card-body">
+        <form @submit.prevent="save">
+          <div class="formfields">
+            <div v-if="currentUser.isNew" class="form-group row">
+              <label for="user">Username</label>
+              <input type="user" :placeholder="userNamePlaceholder" class="form-control form-control-sm" id="user" aria-describedby="userHelp" v-model="currentUser.user">
+              <div v-show="errors.user" class="text-danger">
+                <small>
+                  <i class="fa fa-warning"></i> {{ errors.user }}</small>
+              </div>
+            </div>
+            <div class="form-group row">
+              <label for="country">Country</label>
+              <select class="form-control form-control-sm" v-on:change="validateCountry" v-model="currentUser.country">
+                <option disabled value="">Please select one</option>
+                <option v-for="country in countries" v-bind:value="country" v-bind:key="country">{{country}}</option>
+              </select>
+              <div v-show="errors.country" class="text-danger">
+                <small>
+                  <i class="fa fa-warning"></i> {{ errors.country }}</small>
+              </div>
+            </div>
+            <div class="form-group row">
+              <label for="email">Email address</label>
+              <input type="email" v-on:change="validateEmailaddress" class="form-control form-control-sm" id="email" aria-describedby="emailHelp" v-model="currentUser.email">
+              <div v-show="errors.email" class="text-danger">
+                <small>
+                  <i class="fa fa-warning"></i> {{ errors.email }}</small>
+              </div>
+            </div>
+            <div class="form-group row">
+              <label for="role">Role</label>
+              <select class="form-control form-control-sm" v-on:change="validateRole" v-model="currentUser.role">
+                <option disabled value="">Please select one</option>
+                <option value="sys_admin">Sysadmin</option>
+                <option value="waterway_admin">Waterway Admin</option>
+                <option value="waterway_user">Waterway User</option>
+              </select>
+              <div v-show="errors.role" class="text-danger">
+                <small>
+                  <i class="fa fa-warning"></i> {{ errors.role }}</small>
+              </div>
+            </div>
+            <div class="form-group row">
+              <PasswordField @fieldchange="passwordChanged" :placeholder="passwordPlaceholder" :label="passwordLabel" :passworderrors="errors.password"></PasswordField>
+            </div>
+            <div class="form-group row">
+              <PasswordField @fieldchange="passwordReChanged" :placeholder="passwordRePlaceholder" :label="passwordReLabel" :passworderrors="errors.passwordre"></PasswordField>
+            </div>
+          </div>
+          <div>
+            <button type="submit" :disabled="submitted" class="shadow-sm btn btn-info pull-right">Submit</button>
+          </div>
+        </form>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style lang="scss">
+@import "../application/assets/application.scss";
+
+.formfields {
+  width: 10vw;
+}
+
+.userdetails {
+  margin-top: $topbarheight;
+  min-width: 40vw;
+  margin-right: auto;
+  height: 100%;
+}
+
+form {
+  margin-left: $offset;
+  font-size: 0.9rem;
+}
+</style>
+<script>
+import { displayError } from "../application/lib/errors.js";
+import { mapGetters } from "vuex";
+import PasswordField from "./Passwordfield";
+
+const emptyErrormessages = () => {
+  return {
+    email: "",
+    country: "",
+    role: "",
+    password: "",
+    passwordre: ""
+  };
+};
+
+const isEmailValid = email => {
+  /**
+   *
+   * For convenience purposes the same regex used as in the go code
+   * cf. types.go
+   *
+   */
+  // eslint-disable-next-line
+  return /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])/.test(
+    email
+  );
+};
+
+const violatedPasswordRules = password => {
+  return (
+    // rules according to issue 70
+    password.length < 7 ||
+    /\W/.test(password) == false ||
+    /\d/.test(password) == false
+  );
+};
+
+export default {
+  name: "userdetail",
+  components: {
+    PasswordField
+  },
+  data() {
+    return {
+      passwordLabel: "Password",
+      passwordReLabel: "Repeat Password",
+      passwordPlaceholder: "password",
+      passwordRePlaceholder: "password again",
+      password: "",
+      passwordre: "",
+      currentUser: {},
+      path: null,
+      submitted: false,
+      errors: {
+        email: "",
+        country: "",
+        role: "",
+        password: "",
+        passwordre: ""
+      }
+    };
+  },
+  mounted() {
+    this.currentUser = { ...this.user };
+    this.path = this.user.name;
+  },
+  watch: {
+    user() {
+      this.currentUser = { ...this.user };
+      this.path = this.user.name;
+      this.clearPassword();
+      this.clearErrors();
+    }
+  },
+  computed: {
+    cardHeader() {
+      if (this.currentUser.isNew) return "N.N";
+      return this.currentUser.user;
+    },
+    userNamePlaceholder() {
+      if (this.currentUser.isNew) return "N.N";
+      return "";
+    },
+    ...mapGetters("application", ["countries"]),
+    user() {
+      return this.$store.getters["usermanagement/currentUser"];
+    },
+    isFormValid() {
+      return (
+        isEmailValid(this.currentUser.email) &&
+        this.currentUser.country &&
+        this.password === this.passwordre &&
+        (this.password === "" || !violatedPasswordRules(this.password))
+      );
+    }
+  },
+  methods: {
+    passwordChanged(value) {
+      this.password = value;
+      this.validatePassword();
+    },
+    passwordReChanged(value) {
+      this.passwordre = value;
+      this.validatePassword();
+    },
+    clearErrors() {
+      this.errors = emptyErrormessages();
+    },
+    clearPassword() {
+      this.password = "";
+      this.passwordre = "";
+    },
+    closeDetailview() {
+      this.$store.commit("usermanagement/clearCurrentUser");
+      this.$store.commit("usermanagement/setUserDetailsInvisible");
+    },
+    validateCountry() {
+      this.errors.country = this.currentUser.country
+        ? ""
+        : "Please choose a country";
+    },
+    validateRole() {
+      this.errors.role = this.currentUser.role ? "" : "Please choose a role";
+    },
+    validatePassword() {
+      this.errors.passwordre =
+        this.password === this.passwordre ? "" : "Passwords do not match!";
+      this.errors.password =
+        this.password === "" || !violatedPasswordRules(this.password)
+          ? ""
+          : "Password should at least be 8 char long including 1 digit and 1 special char like $";
+    },
+    validateEmailaddress() {
+      this.errors.email = isEmailValid(this.currentUser.email)
+        ? ""
+        : "invalid email";
+    },
+    validate() {
+      this.validateCountry();
+      this.validateRole();
+      this.validatePassword();
+      this.validateEmailaddress();
+    },
+    save() {
+      this.validate();
+      if (!this.isFormValid) return;
+      if (this.password) this.currentUser.password = this.password;
+      this.submitted = true;
+      this.$store
+        .dispatch("usermanagement/saveCurrentUser", {
+          path: this.user.user,
+          user: this.currentUser
+        })
+        .then(() => {
+          this.submitted = false;
+          this.$store.dispatch("usermanagement/loadUsers").catch(error => {
+            const { status, data } = error.response;
+            displayError({
+              title: "Backend Error",
+              message: `${status}: ${data.message || data}`
+            });
+          });
+        })
+        .catch(error => {
+          this.submitted = false;
+          const { status, data } = error.response;
+          displayError({
+            title: "Error while saving user",
+            message: `${status}: ${data.message || data}`
+          });
+        });
+    }
+  }
+};
+</script>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/usermanagement/Usermanagement.vue	Fri Sep 07 11:13:56 2018 +0200
@@ -0,0 +1,284 @@
+<template>
+  <div class="main d-flex flex-column">
+    <div class="d-flex content flex-column">
+      <div class="d-flex flex-row">
+        <div :class="userlistStyle">
+          <div class="card">
+            <div class="card-header shadow-sm text-white bg-info mb-3">
+              Users
+            </div>
+            <div class="card-body">
+              <table id="datatable" :class="tableStyle">
+                <thead>
+                  <tr>
+                    <th scope="col" @click="sortBy('user')">
+                      <span>Username&nbsp;
+                        <i v-if="sortCriterion=='user'" class="fa fa-angle-down"></i>
+                      </span>
+                    </th>
+                    <th scope="col" @click="sortBy('country')">
+                      <span>Country&nbsp;
+                        <i v-if="sortCriterion=='country'" class="fa fa-angle-down"></i>
+                      </span>
+                    </th>
+                    <th scope="col" @click="sortBy('email')">
+                      <span>Email&nbsp;
+                        <i v-if="sortCriterion=='email'" class="fa fa-angle-down"></i>
+                      </span>
+                    </th>
+                    <th scope="col" @click="sortBy('role')">
+                      <span>Role&nbsp;
+                        <i v-if="sortCriterion=='role'" class="fa fa-angle-down"></i>
+                      </span>
+                    </th>
+                    <th scope="col"></th>
+                  </tr>
+                </thead>
+                <tbody>
+                  <tr v-for="user in users" :key="user.user" @click="selectUser(user.user)">
+                    <td>{{ user.user }}</td>
+                    <td>{{ user.country }}</td>
+                    <td>{{ user.email}}</td>
+                    <td>
+                      <i v-tooltip="user.roleLabel" :class="{
+                        fa:true,
+                        icon:true, 
+                        'fa-user':user.role==='waterway_user',
+                        'fa-star':user.role=='sys_admin',
+                        'fa-adn':user.role==='waterway_admin'}"></i>
+                    </td>
+                    <td>
+                      <i @click="deleteUser(user.user)" class="icon fa fa-trash-o"></i>
+                    </td>
+                  </tr>
+                </tbody>
+              </table>
+            </div>
+            <div class="d-flex flex-row pagination">
+              <i @click=" prevPage " v-if="this.currentPage!=1 " class="backwards btn btn-sm btn-light align-self-center pages fa fa-caret-left "></i> {{this.currentPage}} / {{this.pages}}
+              <i @click="nextPage " class="forwards btn btn-sm btn-light align-self-center pages fa fa-caret-right "></i>
+            </div>
+            <div class="adduser ">
+              <button @click="addUser " class="btn btn-info pull-right shadow-sm ">Add User</button>
+            </div>
+          </div>
+        </div>
+        <Userdetail v-if="isUserDetailsVisible "></Userdetail>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style lang="scss">
+@import "../application/assets/application.scss";
+@import "../application/assets/tooltip.scss";
+
+.main {
+  height: 100vh;
+}
+
+.backwards {
+  margin-right: 0.5rem;
+}
+
+.forwards {
+  margin-left: 0.5rem;
+}
+
+.content {
+  margin-top: $large-offset;
+  margin-left: auto;
+  margin-right: auto;
+}
+
+.adduser {
+  margin-right: $offset;
+  padding-bottom: $offset;
+}
+
+.icon {
+  font-size: large;
+}
+
+.userlist {
+  margin-top: $topbarheight;
+  margin-right: $offset;
+  min-width: 520px;
+  height: 100%;
+}
+
+.pagination {
+  margin-left: auto;
+  margin-right: auto;
+}
+.userlistsmall {
+  width: 30vw;
+}
+
+.userlistextended {
+  width: 70vw;
+}
+
+.table {
+  width: 90% !important;
+  margin: auto;
+}
+
+.table th,
+.pages {
+  cursor: pointer;
+}
+
+.table th,
+td {
+  font-size: 0.9rem;
+  border-top: 0px !important;
+  text-align: left;
+  padding: 0.5rem !important;
+}
+
+.table td {
+  font-size: 0.9rem;
+  cursor: pointer;
+}
+
+tr span {
+  display: flex;
+}
+</style>
+
+<script>
+import Userdetail from "./Userdetail";
+import store from "../store";
+import { mapGetters } from "vuex";
+import { displayError } from "../application/lib/errors.js";
+
+export default {
+  name: "userview",
+  data() {
+    return {
+      sortCriterion: "user",
+      pageSize: 10,
+      currentPage: 1
+    };
+  },
+  components: {
+    Userdetail
+  },
+  computed: {
+    ...mapGetters("usermanagement", ["isUserDetailsVisible"]),
+    ...mapGetters("application", ["sidebarCollapsed"]),
+    users() {
+      let users = [...this.$store.getters["usermanagement/users"]];
+      users.sort((a, b) => {
+        if (
+          a[this.sortCriterion].toLowerCase() <
+          b[this.sortCriterion].toLowerCase()
+        )
+          return -1;
+        if (
+          a[this.sortCriterion].toLowerCase() >
+          b[this.sortCriterion].toLowerCase()
+        )
+          return 1;
+        return 0;
+      });
+      const start = (this.currentPage - 1) * this.pageSize;
+      return users.slice(start, start + this.pageSize);
+    },
+    pages() {
+      let users = [...this.$store.getters["usermanagement/users"]];
+      return Math.ceil(users.length / this.pageSize);
+    },
+    tableStyle() {
+      return {
+        table: true,
+        "table-hover": true,
+        "table-sm": this.isUserDetailsVisible,
+        fadeIn: true,
+        animated: true
+      };
+    },
+    userlistStyle() {
+      return {
+        userlist: true,
+        shadow: true,
+        userlistsmall: this.isUserDetailsVisible,
+        userlistextended: !this.isUserDetailsVisible
+      };
+    }
+  },
+  methods: {
+    tween() {},
+    nextPage() {
+      if (this.currentPage < this.pages) {
+        document.querySelector("#datatable").classList.add("fadeOut");
+        setTimeout(() => {
+          document.querySelector("#datatable").classList.remove("fadeOut");
+          this.currentPage += 1;
+        }, 10);
+      }
+      return;
+    },
+    prevPage() {
+      if (this.currentPage > 0) {
+        document.querySelector("#datatable").classList.add("fadeOut");
+        setTimeout(() => {
+          document.querySelector("#datatable").classList.remove("fadeOut");
+          this.currentPage -= 1;
+        }, 10);
+      }
+      return;
+    },
+    sortBy(criterion) {
+      this.sortCriterion = criterion;
+    },
+    deleteUser(name) {
+      this.$store
+        .dispatch("usermanagement/deleteUser", { name: name })
+        .then(() => {
+          this.submitted = false;
+          this.$store.dispatch("usermanagement/loadUsers").catch(error => {
+            const { status, data } = error.response;
+            displayError({
+              title: "Backend Error",
+              message: `${status}: ${data.message || data}`
+            });
+          });
+        })
+        .catch(error => {
+          const { status, data } = error.response;
+          displayError({
+            title: "Backend Error",
+            message: `${status}: ${data.message || data}`
+          });
+        });
+    },
+    addUser() {
+      this.$store.commit("usermanagement/clearCurrentUser");
+      this.$store.commit("usermanagement/setUserDetailsVisible");
+    },
+    selectUser(name) {
+      const user = this.$store.getters["usermanagement/getUserByName"](name);
+      this.$store.commit("usermanagement/setCurrentUser", user);
+    }
+  },
+  beforeRouteEnter(to, from, next) {
+    store
+      .dispatch("usermanagement/loadUsers")
+      .then(next)
+      .catch(error => {
+        const { status, data } = error.response;
+        displayError({
+          title: "Backend Error",
+          message: `${status}: ${data}`
+        });
+      });
+  },
+  beforeRouteLeave(to, from, next) {
+    store.commit("usermanagement/clearCurrentUser");
+    store.commit("usermanagement/setUserDetailsInvisible");
+    next();
+  }
+};
+</script>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/usermanagement/Users.vue	Fri Sep 07 11:13:56 2018 +0200
@@ -0,0 +1,283 @@
+<template>
+  <div class="main d-flex flex-column">
+    <div class="d-flex content flex-column">
+      <div class="d-flex flex-row">
+        <div :class="userlistStyle">
+          <div class="card">
+            <div class="card-header shadow-sm text-white bg-info mb-3">
+              Users
+            </div>
+            <div class="card-body">
+              <table id="datatable" :class="tableStyle">
+                <thead>
+                  <tr>
+                    <th scope="col" @click="sortBy('user')">
+                      <span>Username&nbsp;
+                        <i v-if="sortCriterion=='user'" class="fa fa-angle-down"></i>
+                      </span>
+                    </th>
+                    <th scope="col" @click="sortBy('country')">
+                      <span>Country&nbsp;
+                        <i v-if="sortCriterion=='country'" class="fa fa-angle-down"></i>
+                      </span>
+                    </th>
+                    <th scope="col" @click="sortBy('email')">
+                      <span>Email&nbsp;
+                        <i v-if="sortCriterion=='email'" class="fa fa-angle-down"></i>
+                      </span>
+                    </th>
+                    <th scope="col" @click="sortBy('role')">
+                      <span>Role&nbsp;
+                        <i v-if="sortCriterion=='role'" class="fa fa-angle-down"></i>
+                      </span>
+                    </th>
+                    <th scope="col"></th>
+                  </tr>
+                </thead>
+                <tbody>
+                  <tr v-for="user in users" :key="user.user" @click="selectUser(user.user)">
+                    <td>{{ user.user }}</td>
+                    <td>{{ user.country }}</td>
+                    <td>{{ user.email}}</td>
+                    <td>
+                      <i v-tooltip="user.roleLabel" :class="{
+                        fa:true,
+                        icon:true, 
+                        'fa-user':user.role==='waterway_user',
+                        'fa-star':user.role=='sys_admin',
+                        'fa-adn':user.role==='waterway_admin'}"></i>
+                    </td>
+                    <td>
+                      <i @click="deleteUser(user.user)" class="icon fa fa-trash-o"></i>
+                    </td>
+                  </tr>
+                </tbody>
+              </table>
+            </div>
+            <div class="d-flex flex-row pagination">
+              <i @click=" prevPage " v-if="this.currentPage!=1 " class="backwards btn btn-sm btn-light align-self-center pages fa fa-caret-left "></i> {{this.currentPage}} / {{this.pages}}
+              <i @click="nextPage " class="forwards btn btn-sm btn-light align-self-center pages fa fa-caret-right "></i>
+            </div>
+            <div class="adduser ">
+              <button @click="addUser " class="btn btn-info pull-right shadow-sm ">Add User</button>
+            </div>
+          </div>
+        </div>
+        <Userdetail v-if="isUserDetailsVisible "></Userdetail>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style lang="scss">
+@import "../application/assets/application.scss";
+@import "../application/assets/tooltip.scss";
+.main {
+  height: 100vh;
+}
+
+.backwards {
+  margin-right: 0.5rem;
+}
+
+.forwards {
+  margin-left: 0.5rem;
+}
+
+.content {
+  margin-top: $large-offset;
+  margin-left: auto;
+  margin-right: auto;
+}
+
+.adduser {
+  margin-right: $offset;
+  padding-bottom: $offset;
+}
+
+.icon {
+  font-size: large;
+}
+
+.userlist {
+  margin-top: $topbarheight;
+  margin-right: $offset;
+  min-width: 520px;
+  height: 100%;
+}
+
+.pagination {
+  margin-left: auto;
+  margin-right: auto;
+}
+.userlistsmall {
+  width: 30vw;
+}
+
+.userlistextended {
+  width: 70vw;
+}
+
+.table {
+  width: 90% !important;
+  margin: auto;
+}
+
+.table th,
+.pages {
+  cursor: pointer;
+}
+
+.table th,
+td {
+  font-size: 0.9rem;
+  border-top: 0px !important;
+  text-align: left;
+  padding: 0.5rem !important;
+}
+
+.table td {
+  font-size: 0.9rem;
+  cursor: pointer;
+}
+
+tr span {
+  display: flex;
+}
+</style>
+
+<script>
+import Userdetail from "./Userdetail";
+import store from "../store";
+import { mapGetters } from "vuex";
+import { displayError } from "../application/lib/errors.js";
+
+export default {
+  name: "userview",
+  data() {
+    return {
+      sortCriterion: "user",
+      pageSize: 10,
+      currentPage: 1
+    };
+  },
+  components: {
+    Userdetail
+  },
+  computed: {
+    ...mapGetters("usermanagement", ["isUserDetailsVisible"]),
+    ...mapGetters("application", ["sidebarCollapsed"]),
+    users() {
+      let users = [...this.$store.getters["usermanagement/users"]];
+      users.sort((a, b) => {
+        if (
+          a[this.sortCriterion].toLowerCase() <
+          b[this.sortCriterion].toLowerCase()
+        )
+          return -1;
+        if (
+          a[this.sortCriterion].toLowerCase() >
+          b[this.sortCriterion].toLowerCase()
+        )
+          return 1;
+        return 0;
+      });
+      const start = (this.currentPage - 1) * this.pageSize;
+      return users.slice(start, start + this.pageSize);
+    },
+    pages() {
+      let users = [...this.$store.getters["usermanagement/users"]];
+      return Math.ceil(users.length / this.pageSize);
+    },
+    tableStyle() {
+      return {
+        table: true,
+        "table-hover": true,
+        "table-sm": this.isUserDetailsVisible,
+        fadeIn: true,
+        animated: true
+      };
+    },
+    userlistStyle() {
+      return {
+        userlist: true,
+        shadow: true,
+        userlistsmall: this.isUserDetailsVisible,
+        userlistextended: !this.isUserDetailsVisible
+      };
+    }
+  },
+  methods: {
+    tween() {},
+    nextPage() {
+      if (this.currentPage < this.pages) {
+        document.querySelector("#datatable").classList.add("fadeOut");
+        setTimeout(() => {
+          document.querySelector("#datatable").classList.remove("fadeOut");
+          this.currentPage += 1;
+        }, 10);
+      }
+      return;
+    },
+    prevPage() {
+      if (this.currentPage > 0) {
+        document.querySelector("#datatable").classList.add("fadeOut");
+        setTimeout(() => {
+          document.querySelector("#datatable").classList.remove("fadeOut");
+          this.currentPage -= 1;
+        }, 10);
+      }
+      return;
+    },
+    sortBy(criterion) {
+      this.sortCriterion = criterion;
+    },
+    deleteUser(name) {
+      this.$store
+        .dispatch("usermanagement/deleteUser", { name: name })
+        .then(() => {
+          this.submitted = false;
+          this.$store.dispatch("usermanagement/loadUsers").catch(error => {
+            const { status, data } = error.response;
+            displayError({
+              title: "Backend Error",
+              message: `${status}: ${data.message || data}`
+            });
+          });
+        })
+        .catch(error => {
+          const { status, data } = error.response;
+          displayError({
+            title: "Backend Error",
+            message: `${status}: ${data.message || data}`
+          });
+        });
+    },
+    addUser() {
+      this.$store.commit("usermanagement/clearCurrentUser");
+      this.$store.commit("usermanagement/setUserDetailsVisible");
+    },
+    selectUser(name) {
+      const user = this.$store.getters["usermanagement/getUserByName"](name);
+      this.$store.commit("usermanagement/setCurrentUser", user);
+    }
+  },
+  beforeRouteEnter(to, from, next) {
+    store
+      .dispatch("usermanagement/loadUsers")
+      .then(next)
+      .catch(error => {
+        const { status, data } = error.response;
+        displayError({
+          title: "Backend Error",
+          message: `${status}: ${data}`
+        });
+      });
+  },
+  beforeRouteLeave(to, from, next) {
+    store.commit("usermanagement/clearCurrentUser");
+    store.commit("usermanagement/setUserDetailsInvisible");
+    next();
+  }
+};
+</script>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/usermanagement/store.js	Fri Sep 07 11:13:56 2018 +0200
@@ -0,0 +1,134 @@
+import { HTTP } from "../application/lib/http";
+
+const newUser = () => {
+  return {
+    user: "",
+    email: "",
+    country: null,
+    role: null,
+    isNew: true,
+    password: "",
+    roleLabel: ""
+  };
+};
+
+const UserManagement = {
+  namespaced: true,
+  state: {
+    users: null,
+    currentUser: null,
+    userDetailsVisible: false
+  },
+  getters: {
+    isUserDetailsVisible: state => {
+      return state.userDetailsVisible;
+    },
+    currentUser: state => {
+      return state.currentUser;
+    },
+    users: state => {
+      return state.users;
+    },
+    getUserByName: state => name => {
+      return state.users.find(user => {
+        return user.user === name;
+      });
+    }
+  },
+  mutations: {
+    setUserDetailsInvisible: state => {
+      state.userDetailsVisible = false;
+    },
+    setUserDetailsVisible: state => {
+      state.userDetailsVisible = true;
+    },
+    usersLoaded: (state, data) => {
+      const resolveLabel = x => {
+        const labels = {
+          waterway_user: "Waterway User",
+          waterway_admin: "Waterway Administrator",
+          sys_admin: "System Admininistrator"
+        };
+        return labels[x];
+      };
+      let users = data.users.map(u => {
+        u["roleLabel"] = resolveLabel(u["role"]);
+        return u;
+      });
+      state.users = users;
+    },
+    setCurrentUser: (state, data) => {
+      state.currentUser = data;
+      state.userDetailsVisible = true;
+    },
+    clearCurrentUser: state => {
+      state.currentUser = newUser();
+    }
+  },
+  actions: {
+    deleteUser({ commit }, data) {
+      const { name } = data;
+      return new Promise((resolve, reject) => {
+        HTTP.delete("/users/" + name, {
+          headers: { "X-Gemma-Auth": localStorage.getItem("token") }
+        })
+          .then(response => {
+            commit("clearCurrentUser");
+            commit("setUserDetailsInvisible");
+            resolve(response);
+          })
+          .catch(error => {
+            reject(error);
+          });
+      });
+    },
+    saveCurrentUser({ commit }, data) {
+      const { path, user } = data;
+      if (user.isNew) {
+        return new Promise((resolve, reject) => {
+          HTTP.post("/users", user, {
+            headers: { "X-Gemma-Auth": localStorage.getItem("token") }
+          })
+            .then(response => {
+              commit("setUserDetailsInvisible");
+              commit("clearCurrentUser");
+              resolve(response);
+            })
+            .catch(error => {
+              reject(error);
+            });
+        });
+      } else {
+        return new Promise((resolve, reject) => {
+          HTTP.put("/users/" + path, user, {
+            headers: { "X-Gemma-Auth": localStorage.getItem("token") }
+          })
+            .then(response => {
+              commit("setUserDetailsInvisible");
+              commit("clearCurrentUser");
+              resolve(response);
+            })
+            .catch(error => {
+              reject(error);
+            });
+        });
+      }
+    },
+    loadUsers({ commit }) {
+      return new Promise((resolve, reject) => {
+        HTTP.get("/users", {
+          headers: { "X-Gemma-Auth": localStorage.getItem("token") }
+        })
+          .then(response => {
+            commit("usersLoaded", response.data);
+            resolve(response);
+          })
+          .catch(error => {
+            reject(error);
+          });
+      });
+    }
+  }
+};
+
+export default UserManagement;