Mercurial > gemma
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).
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; +}
--- /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; + } +}
--- /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 + <i v-if="sortCriterion=='user'" class="fa fa-angle-down"></i> + </span> + </th> + <th scope="col" @click="sortBy('country')"> + <span>Country + <i v-if="sortCriterion=='country'" class="fa fa-angle-down"></i> + </span> + </th> + <th scope="col" @click="sortBy('email')"> + <span>Email + <i v-if="sortCriterion=='email'" class="fa fa-angle-down"></i> + </span> + </th> + <th scope="col" @click="sortBy('role')"> + <span>Role + <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 + <i v-if="sortCriterion=='user'" class="fa fa-angle-down"></i> + </span> + </th> + <th scope="col" @click="sortBy('country')"> + <span>Country + <i v-if="sortCriterion=='country'" class="fa fa-angle-down"></i> + </span> + </th> + <th scope="col" @click="sortBy('email')"> + <span>Email + <i v-if="sortCriterion=='email'" class="fa fa-angle-down"></i> + </span> + </th> + <th scope="col" @click="sortBy('role')"> + <span>Role + <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;