Merge branch '177-refactor-users-code' into 'devel'

Refactor users code

Closes #177 and #176

See merge request wgp/dougal/software!57
This commit is contained in:
D. Berge
2025-07-25 12:26:39 +00:00
83 changed files with 3601 additions and 530 deletions

View File

@@ -5,7 +5,7 @@
max-width="600"
>
<template v-slot:activator="{ on, attrs }">
<v-btn v-if="adminaccess"
<v-btn v-if="adminaccess()"
title="Create a new project from scratch. Generally, it's preferable to clone an existing project (right-click → Clone)"
small
outlined
@@ -31,6 +31,7 @@
<script>
import { mapActions, mapGetters } from 'vuex';
import DougalProjectSettingsNameIdGeodetics from '@/components/project-settings/name-id-geodetics'
import AccessMixin from '@/mixins/access';
export default {
name: 'DougalAppBarExtensionProjectList',
@@ -39,6 +40,10 @@ export default {
DougalProjectSettingsNameIdGeodetics
},
mixins: [
AccessMixin
],
data() {
return {
dialogOpen: false,
@@ -50,10 +55,6 @@ export default {
};
},
computed: {
...mapGetters(["adminaccess"])
},
methods: {
async save (data) {
this.dialogOpen = false;

View File

@@ -1,7 +1,7 @@
<template>
<v-tabs :value="tab" show-arrows v-if="page != 'configuration'">
<v-tab v-for="tab, index in tabs" :key="index" link :to="tabLink(tab.href)" v-text="tab.text"></v-tab>
<template v-if="adminaccess">
<template v-if="adminaccess()">
<v-spacer></v-spacer>
<v-tab :to="tabLink('configuration')" class="orange--text darken-3" title="Edit project settings"><v-icon small left color="orange darken-3">mdi-cog-outline</v-icon> Settings</v-tab>
</template>
@@ -15,9 +15,15 @@
<script>
import { mapActions, mapGetters } from 'vuex';
import AccessMixin from '@/mixins/access';
export default {
name: 'DougalAppBarExtensionProject',
mixins: [
AccessMixin
],
data() {
return {
tabs: [
@@ -44,7 +50,6 @@ export default {
return this.tabs.findIndex(t => t.href == this.page);
},
...mapGetters(["adminaccess"])
},
methods: {

View File

@@ -127,7 +127,7 @@ export default {
},
computed: {
...mapGetters(['user', 'writeaccess', 'loading', 'serverEvent'])
...mapGetters(['user', 'loading', 'serverEvent'])
},
methods: {

View File

@@ -32,16 +32,61 @@
</template>
<v-list dense>
<v-list-item :href="`/settings/equipment`">
<v-list-item-title>Equipment list</v-list-item-title>
<v-list-item href="/settings/equipment">
<v-list-item-content>
<v-list-item-title>Equipment list</v-list-item-title>
<v-list-item-subtitle>Manage the list of equipment reported in logs</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-action><v-icon small>mdi-view-list</v-icon></v-list-item-action>
</v-list-item>
<template v-if="false">
<v-divider></v-divider>
<v-list-item href="/settings">
<v-list-item-content>
<v-list-item-title>Local settings</v-list-item-title>
<v-list-item-subtitle>Manage this vessel's configuration</v-list-item-subtitle>
</v-list-item-content>
<v-list-item-action><v-icon small>mdi-ferry</v-icon></v-list-item-action>
</v-list-item>
</template>
</v-list>
</v-menu>
<v-breadcrumbs :items="path"></v-breadcrumbs>
<v-breadcrumbs :items="path">
<template v-slot:item="{ item }">
<v-breadcrumbs-item :href="item.href" :disabled="item.disabled" v-if="item.organisations">
<v-tooltip bottom>
<template v-slot:activator="{ on, attrs }">
<span v-bind="attrs" v-on="on">{{ item.text }}</span>
</template>
<div class="text-overline">Project permissions</div>
<v-simple-table dense>
<template v-slot:default>
<thead>
<tr>
<th>Organisation</th><th>Read</th><th>Write</th><th>Edit</th>
</tr>
</thead>
<tbody>
<tr v-for="(operations, name) in item.organisations">
<td v-if="name == '*'"><v-chip small label color="primary">All</v-chip></td>
<td v-else><v-chip small label outlined>{{ name }}</v-chip></td>
<td>{{ operations.read ? "✔" : " " }}</td>
<td>{{ operations.write ? "✔" : " " }}</td>
<td>{{ operations.edit ? "✔" : " " }}</td>
</tr>
</tbody>
</template>
</v-simple-table>
</v-tooltip>
</v-breadcrumbs-item>
<v-breadcrumbs-item :href="item.href" :disabled="item.disabled" v-else>
{{ item.text }}
</v-breadcrumbs-item>
</template>
</v-breadcrumbs>
<template v-if="$route.name != 'Login'">
<v-btn text link to="/login" v-if="!user && !loading">Log in</v-btn>
@@ -50,10 +95,37 @@
<v-menu
offset-y
>
<template v-slot:activator="{on, attrs}">
<v-avatar :color="user.colour || 'primary'" :title="`${user.name} (${user.role})`" v-bind="attrs" v-on="on">
<span class="white--text">{{user.name.slice(0, 5)}}</span>
</v-avatar>
<template v-slot:activator="{ on: menu, attrs }">
<v-tooltip bottom>
<template v-slot:activator="{ on: tooltip }">
<v-avatar :color="user.colour || 'primary'" v-bind="attrs" v-on="{...tooltip, ...menu}">
<span class="white--text">{{user.name.slice(0, 5)}}</span>
</v-avatar>
</template>
<div class="text-overline">{{ user.name }}</div>
<v-card flat class="my-1" v-if="user.description">
<v-card-text class="pb-1" v-html="$root.markdown(user.description)">
</v-card-text>
</v-card>
<v-simple-table dense>
<template v-slot:default>
<thead>
<tr>
<th>Organisation</th><th>Read</th><th>Write</th><th>Edit</th>
</tr>
</thead>
<tbody>
<tr v-for="org in user.organisations">
<td v-if="org.name == '*'"><v-chip small label color="primary">All</v-chip></td>
<td v-else><v-chip small label outlined>{{ org.name }}</v-chip></td>
<td>{{ org.operations.read ? "✔" : " " }}</td>
<td>{{ org.operations.write ? "✔" : " " }}</td>
<td>{{ org.operations.edit ? "✔" : " " }}</td>
</tr>
</tbody>
</template>
</v-simple-table>
</v-tooltip>
</template>
<v-list dense>
@@ -66,8 +138,29 @@
</v-list-item>
<v-list-item link to="/logout" v-else>
<v-list-item-icon><v-icon small>mdi-logout</v-icon></v-list-item-icon>
<v-list-item-title>Log out</v-list-item-title>
<v-list-item-content>
<v-list-item-title>Log out</v-list-item-title>
</v-list-item-content>
</v-list-item>
<v-divider></v-divider>
<template v-if="canManageUsers">
<v-list-item link to="/users">
<v-list-item-icon><v-icon small>mdi-account-multiple</v-icon></v-list-item-icon>
<v-list-item-content>
<v-list-item-title>Manage users</v-list-item-title>
<v-list-item-subtitle>Add, edit and remove users</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</template>
<template v-else-if="user && !user.autologin">
<v-list-item link :to="`/users/${user.id}`">
<v-list-item-icon><v-icon small>mdi-account</v-icon></v-list-item-icon>
<v-list-item-content>
<v-list-item-title>User profile</v-list-item-title>
<v-list-item-subtitle>Edit your user profile</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</template>
</v-list>
</v-menu>
@@ -102,6 +195,19 @@ export default {
.pop()?.component;
},
title () {
return this.user.name + "\n" + [...this.user.organisations].map( ({name, operations}) => {
if (name == "*") name = "All organisations";
let str = name+": ";
str += [ "read", "write", "edit" ].map( op => operations[op] ? op : null ).filter( op => op ).join(", ");
return str;
}).join("\n")
},
canManageUsers () {
return this.user.organisations.accessToOperation("edit").length;
},
...mapGetters(['user', 'loading'])
},

View File

@@ -0,0 +1,112 @@
<template>
<v-row dense no-gutters>
<v-col>
<slot name="prepend"></slot>
</v-col>
<v-col cols="6">
<v-text-field
class="mr-5"
dense
label="Name"
:value="name"
:readonly="true"
></v-text-field>
</v-col>
<v-col>
<v-checkbox
class="mr-3"
label="Read"
v-model="operations.read"
:readonly="readonly"
></v-checkbox>
</v-col>
<v-col>
<v-checkbox
class="mr-3"
label="Write"
v-model="operations.write"
:readonly="readonly"
></v-checkbox>
</v-col>
<v-col>
<v-checkbox
class="mr-3"
label="Edit"
v-model="operations.edit"
:readonly="readonly"
></v-checkbox>
</v-col>
<v-col>
<!-- Just to fill the twelve-column grid -->
<!--
NOTE: this column could also be used for
a popdown menu with additional operations
if needed.
-->
</v-col>
<v-col>
<slot name="append"></slot>
</v-col>
</v-row>
</template>
<style scoped>
</style>
<script>
import { Organisations } from '@dougal/organisations';
export default {
name: "DougalOrganisationsItem",
props: {
name: String,
value: Object,
readonly: Boolean,
},
data () {
return {
operations: {...this.value}
}
},
watch: {
value: {
handler (newValue) {
this.operations = {...this.value};
},
deep: true,
},
operations: {
handler (newValue) {
if (["read", "write", "edit"].some( k => newValue[k] != this.value[k] )) {
// Only emit if a value has actually changed
this.$emit("input", {...newValue});
}
},
deep: true,
},
},
methods: {
reset () {
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,191 @@
<template>
<v-card>
<v-card-title>Organisations</v-card-title>
<v-card-subtitle>Organisation access</v-card-subtitle>
<v-card-text>
<v-form>
<v-container>
<dougal-organisations-item v-for="organisation in localOrganisations.names()"
:key="organisation"
:name="organisation"
:value="localOrganisations.get(organisation)"
@input="setOrganisation(organisation, $event)"
>
<template v-slot:append v-if="!readonly">
<v-btn
class="ml-3"
fab
text
small
title="Remove this organisation"
>
<v-icon
color="error"
@click="removeOrganisation(organisation)"
>mdi-minus</v-icon>
</v-btn>
</template>
</dougal-organisations-item>
<v-row no-gutters class="mb-2" v-if="!readonly">
<h4>Add organisation</h4>
</v-row>
<v-row no-gutters class="mb-2" v-if="!readonly">
<v-combobox v-if="canCreateOrganisations"
label="Organisation"
:items="remainingOrganisations"
v-model="organisationName"
@input.native="organisationName = $event.srcElement.value"
@keyup.enter="addOrganisation()"
></v-combobox>
<v-select v-else
label="Organisation"
:items="remainingOrganisations"
v-model="organisationName"
></v-select>
<v-btn
class="ml-3"
fab
text
small
title="Add organisation"
:disabled="!(organisationName && organisationName.length)"
@click="addOrganisation()"
>
<v-icon
color="primary"
>mdi-plus</v-icon>
</v-btn>
</v-row>
</v-container>
</v-form>
</v-card-text>
<v-card-actions>
<slot name="actions" v-bind="{ self, organisations, readonly, validationErrors, canCreateOrganisations }">
</slot>
</v-card-actions>
</v-card>
</template>
<script>
import { Organisations } from '@dougal/organisations';
import DougalOrganisationsItem from './organisations-item';
export default {
name: "DougalOrganisations",
components: {
DougalOrganisationsItem
},
props: {
self: Object,
organisations: Object,
readonly: Boolean
},
data () {
return {
organisationName: "",
localOrganisations: this.setLocalOrganisations(this.organisations)
}
},
computed: {
availableOrganisations () {
return this.self.organisations.names();
},
// Organisations available to add.
// These are the organisations in `availableOrganisations`
// minus any that have already been added.
// The special value "*" (meaning "every organisation")
// is not included.
remainingOrganisations () {
const orgs = [];
for (const org of this.availableOrganisations) {
if (org != "*" && !this.localOrganisations.has(org)) {
orgs.push(org);
}
}
return orgs;
},
canCreateOrganisations () {
return this.self.organisations.value("*")?.edit ?? false;
},
validationErrors () {
const errors = [];
// Check if there is at least one organisation
if (this.localOrganisations.length) {
errors.push("ERR_NO_ORGS");
}
// Check if at least one organisation has edit rights
},
},
watch: {
organisations (newValue) {
this.localOrganisations = this.setLocalOrganisations(newValue);
},
},
methods: {
setLocalOrganisations (value) {
return new Organisations(this.organisations);
},
setOrganisation(name, value) {
this.localOrganisations.set(name, value);
this.$emit("update:organisations", new Organisations(this.localOrganisations));
},
addOrganisation () {
const key = this.organisationName;
if (!this.localOrganisations.has(key)) {
this.localOrganisations.set(key);
this.$emit("update:organisations", this.localOrganisations);
}
this.organisationName = "";
},
removeOrganisation (key) {
if (this.localOrganisations.has(key)) {
this.localOrganisations.remove(key);
}
this.$emit("update:organisations", this.localOrganisations);
},
reset () {
},
save () {
},
back () {
this.$emit('close');
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,81 @@
<template>
<v-card flat>
<v-card-text>
<dougal-organisations
:self="user"
:organisations.sync="organisations_"
>
<template v-slot:actions>
<v-spacer></v-spacer>
<v-btn
color="secondary"
@click="back"
>Back</v-btn>
</template>
</dougal-organisations>
</v-card-text>
<v-card-actions>
</v-card-actions>
</v-card>
</template>
<script>
import { mapActions, mapGetters } from 'vuex'
import DougalOrganisations from '../organisations'
export default {
name: "DougalProjectSettingsOrganisations",
components: {
DougalOrganisations
},
props: {
organisations: Object,
value: Object
},
data () {
return {
}
},
computed: {
organisations_: {
get () {
return this.organisations;
},
set (v) {
this.$emit("input", {
...this.value,
organisations: v.toJSON()
});
}
},
...mapGetters(['user', 'loading', 'serverEvent'])
},
methods: {
reset () {
},
save () {
},
back () {
this.$emit('close');
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,256 @@
<template>
<v-card>
<v-card-title>
User {{ name }} <v-chip class="mx-3" small>{{id}}</v-chip>
<v-chip v-if="self.id == value.id"
small
color="primary"
>It's me!</v-chip>
</v-card-title>
<v-card-subtitle>User settings</v-card-subtitle>
<v-card-text>
<v-form>
<!--
<v-text-field
label="User ID"
hint="Unique user ID (read-only)"
persistent-hint
readonly
disabled
v-model="id"
>
</v-text-field>
-->
<v-switch
dense
label="Active"
:title="(self.id == value.id) ? 'You cannot make yourself inactive' : active ? 'Make this user inactive' : 'Make this user active'"
:disabled="self.id == value.id"
v-model="active"
></v-switch>
<label class="mr-3 pt-5">Colour
<v-menu v-model="colourMenu"
:close-on-content-click="false"
offset-y
>
<template v-slot:activator="{ on, attrs }">
<v-btn
:title="colour"
dense
small
icon
v-on="on"
><v-icon :color="colour">mdi-palette</v-icon>
</v-btn>
</template>
<v-color-picker
dot-size="25"
mode="hexa"
swatches-max-height="200"
v-model="colour"
></v-color-picker>
</v-menu>
</label>
<v-text-field
v-if="showIp || ip"
label="IP address"
hint="IP address or subnet specification for auto-login"
v-model="ip"
>
</v-text-field>
<v-text-field
v-if="showHost || host"
label="Host name"
hint="Hostname (for auto-login)"
v-model="host"
>
</v-text-field>
<v-text-field
label="Name"
hint="User name"
v-model="name"
>
</v-text-field>
<v-text-field
v-if="showPasswordField"
:type="visiblePassword ? 'text' : 'password'"
:append-icon="visiblePassword ? 'mdi-eye' : 'mdi-eye-off'"
@click:append="visiblePassword = !visiblePassword"
label="Password"
hint="User password"
v-model="password"
>
</v-text-field>
<v-text-field
label="Email"
hint="Email address"
v-model="email"
>
</v-text-field>
<v-textarea
class="mb-5"
label="Remarks"
hint="User description (visible to the user)"
auto-grow
v-model="description"
></v-textarea>
<dougal-organisations
:self="self"
:organisations.sync="organisations"
></dougal-organisations>
</v-form>
</v-card-text>
<v-card-actions>
<slot name="actions" v-bind="{ isValid, hasErrors, errors, dirty }"></slot>
</v-card-actions>
</v-card>
</template>
<script>
import { mapActions, mapGetters } from 'vuex';
import { User } from '@/lib/user';
import DougalOrganisations from './organisations'
export default {
name: "DougalUserSettings",
components: {
DougalOrganisations
},
props: {
value: Object,
self: Object, // User calling the dialogue
// The next three props determine whether the
// ip, host, and password fields are shown even
// when null / empty. If non-null, those fields
// are always shown
showIp: { type: Boolean, default: false },
showHost: { type: Boolean, default: false },
showPassword: { type: Boolean, default: false },
},
data () {
return {
colourMenu: null,
visiblePassword: false
}
},
computed: {
id () { return this.value.id },
ip: {
get () { return this.value.ip },
set (v) { this.input("ip", v) }
},
host: {
get () { return this.value.host },
set (v) { this.input("host", v) }
},
name: {
get () { return this.value.name },
set (v) { this.input("name", v) }
},
password: {
get () { return this.value.password },
set (v) { this.input("password", v) }
},
active: {
get () { return this.value.active },
set (v) { this.input("active", v) }
},
email: {
get () { return this.value.email },
set (v) { this.input("email", v) }
},
colour: {
get () { return this.value.colour },
set (v) { this.input("colour", v) }
},
description: {
get () { return this.value.description },
set (v) { this.input("description", v) }
},
organisations: {
get () { return this.value.organisations },
set (v) { this.input("organisations", v) }
},
errors () {
return this.value.errors;
},
hasErrors () {
return !this.isValid;
},
isValid () {
return this.value.isValid;
},
dirty () {
return this.value?.dirty ?? false;
},
showPasswordField () {
return this.password || (this.showPassword &&
!(this.showIp || this.ip || this.showHost || this.host));
},
...mapGetters(['user', 'loading', 'serverEvent'])
},
watch: {
validationErrors () {
this.$emit("update:errors", this.validationErrors);
}
},
methods: {
input (k, v) {
const user = new User(this.value);
user[k] = v;
this.$emit("input", user);
},
reset () {
},
save () {
},
back () {
this.$emit('close');
}
},
mounted () {
this.reset();
}
}
</script>

View File

@@ -0,0 +1,97 @@
import { User as BaseUser } from '@dougal/user';
class User extends BaseUser {
api // Instance of Vuex api method
dirty // Whether the values have changed since last saved
constructor (data, client) {
super (data);
if (client) {
this.api = client;
} else if (data instanceof User) {
this.api = data.api;
}
this.dirty = false;
this.on("changed", () => this.dirty = true);
}
static async fromAPI (api, id) {
if (id) {
const url = `/user/${id}`;
const res = await api([url]);
return new User(res, api);
} else {
const url = `/user`;
const res = await api([url]);
return res?.map( row => new User(row, api) );
}
}
/** Save this user to the server
*
* If this is a new user, the `api` parameter must be
* supplied and this will result in a `POST` request.
* For an existing user coming from the database,
* `this.api` will be used for a `PUT` request.
*/
async save (api) {
if (this.api) {
const url = `/user/${this.id}`;
const init = {
headers: {
"Content-Type": "application/json"
},
method: "PUT",
body: this.toJSON()
};
const res = await this.api([url, init]);
if (res) {
this.dirty = false;
return new User(res, this.api);
} else {
// Something has gone wrong
console.log("Something has gone wrong (PUT)");
}
} else if (api) {
const url = `/user`;
const init = {
headers: {
"Content-Type": "application/json"
},
method: "POST",
body: this.toJSON()
}
const res = await api([url, init]);
if (res) {
return new User(res, api);
} else {
// Something has gone wrong
console.log("Something has gone wrong (POST)");
}
} else {
throw new Error("Don't know how to save this user");
}
}
/** Delete this user from the server
*/
async remove () {
const url = `/user/${this.id}`;
const init = {
headers: {
"Content-Type": "application/json"
},
method: "PUT",
body: this.toJSON()
};
const res = await this.api([url, init]);
console.log("remove RES", res);
}
}
export default User;

View File

@@ -0,0 +1,5 @@
import User from './User'
export {
User
}

View File

@@ -47,9 +47,11 @@ new Vue({
methods: {
markdown (value) {
return typeof value == "string"
? marked(value)
: value;
return markdown(value);
},
markdownInline (value) {
return markdownInline(value);
},
showSnack(text, colour = "primary") {

View File

@@ -0,0 +1,35 @@
import { mapGetters } from 'vuex';
import { Organisations } from '@dougal/organisations';
export default {
name: "AccessMixin",
computed: {
...mapGetters(['user', 'projectConfiguration'])
},
methods: {
access (operation, organisations) {
if (this.user) {
if (!organisations) organisations = this.projectConfiguration?.organisations;
if (!organisations instanceof Organisations) {
organisations = new Organisations(organisations);
}
return this.user.canDo(operation, organisations);
}
},
readaccess (item) {
return this.access('read', item);
},
writeaccess (item) {
return this.access('write', item);
},
adminaccess (item) {
return this.access('edit', item);
}
}
}

View File

@@ -17,6 +17,7 @@ import QC from '../views/QC.vue'
import Graphs from '../views/Graphs.vue'
import Map from '../views/Map.vue'
import ProjectSettings from '../views/ProjectSettings.vue'
import Users from '../views/Users.vue'
import DougalAppBarExtensionProject from '../components/app-bar-extension-project'
import DougalAppBarExtensionProjectList from '../components/app-bar-extension-project-list'
@@ -49,6 +50,19 @@ Vue.use(VueRouter)
name: "equipment",
component: () => import(/* webpackChunkName: "about" */ '../views/Equipment.vue')
},
{
pathToRegexpOptions: { strict: true },
path: "/users",
redirect: "/users/"
},
{
pathToRegexpOptions: { strict: true },
name: "Users",
path: "/users/",
component: Users,
meta: {
}
},
{
pathToRegexpOptions: { strict: true },
path: "/login",
@@ -103,7 +117,9 @@ Vue.use(VueRouter)
{ text: "Projects", href: "/projects" },
{
text: (ctx) => ctx.$store.state.project.projectName || "…",
href: (ctx) => `/projects/${ctx.$store.state.project.projectId || ctx.$route.params.project || ""}/`
href: (ctx) => `/projects/${ctx.$store.state.project.projectId || ctx.$route.params.project || ""}/`,
title: (ctx) => Object.entries(ctx.$store.getters.projectConfiguration?.organisations ?? {}).map( ([org, ops]) => `* ${org}: ${Object.entries(ops).filter( ([k, v]) => v ).map( ([k, v]) => k ).join(", ")}`).join("\n"),
organisations: (ctx) => ctx.$store.getters.projectConfiguration?.organisations ?? {}
}
],
appBarExtension: {

View File

@@ -1,5 +1,5 @@
async function api ({state, getters, commit, dispatch}, [resource, init = {}, cb]) {
async function api ({state, getters, commit, dispatch}, [resource, init = {}, cb, opts = {}]) {
try {
commit("queueRequest");
if (init && init.hasOwnProperty("body")) {
@@ -49,7 +49,9 @@ async function api ({state, getters, commit, dispatch}, [resource, init = {}, cb
message = body.message;
}
}
await dispatch('showSnack', [message, "warning"]);
if (!opts?.silent) {
await dispatch('showSnack', [message, "warning"]);
}
}
} catch (err) {
if (err && err.name == "AbortError") return;

View File

@@ -13,7 +13,7 @@ async function refreshProjects ({commit, dispatch, state, rootState}) {
const init = {
signal: state.loading.signal
};
const res = await dispatch('api', [url, init]);
const res = await dispatch('api', [url, init, null, {silent:true}]);
if (res) {
commit('setProjects', res);

View File

@@ -1,4 +1,5 @@
import jwt_decode from 'jwt-decode';
import { User } from '@/lib/user';
async function login ({commit, dispatch}, loginRequest) {
const url = "/login";
@@ -34,13 +35,13 @@ function cookieChanged (cookie) {
return browserCookie != cookie;
}
function setCredentials ({state, commit, getters, dispatch}, {force, token} = {}) {
function setCredentials ({state, commit, getters, dispatch, rootState}, {force, token} = {}) {
if (token || force || cookieChanged(state.cookie)) {
try {
const cookie = browserCookie();
const decoded = (token ?? cookie) ? jwt_decode(token ?? cookie.split("=")[1]) : null;
commit('setCookie', (cookie ?? (token && ("JWT="+token))) || undefined);
commit('setUser', decoded);
commit('setUser', decoded ? new User(decoded, rootState.api.api) : null);
} catch (err) {
if (err.name == "InvalidTokenError") {
console.warn("Failed to decode", browserCookie());
@@ -56,11 +57,11 @@ function setCredentials ({state, commit, getters, dispatch}, {force, token} = {}
* Save user preferences to localStorage and store.
*
* User preferences are identified by a key that gets
* prefixed with the user name and role. The value can
* prefixed with the user ID. The value can
* be anything that JSON.stringify can parse.
*/
function saveUserPreference ({state, commit}, [key, value]) {
const k = `${state.user?.name}.${state.user?.role}.${key}`;
const k = `${state.user?.id}.${key}`;
if (value !== undefined) {
localStorage.setItem(k, JSON.stringify(value));
@@ -79,7 +80,7 @@ function saveUserPreference ({state, commit}, [key, value]) {
async function loadUserPreferences ({state, commit}) {
// Get all keys which are of interest to us
const prefix = `${state.user?.name}.${state.user?.role}`;
const prefix = `${state.user?.id}`;
const keys = Object.keys(localStorage).filter( k => k.startsWith(prefix) );
// Build the preferences object

View File

@@ -9,16 +9,8 @@ function jwt (state) {
}
}
function writeaccess (state) {
return state.user && ["user", "admin"].includes(state.user.role);
}
function adminaccess (state) {
return state.user && state.user.role == "admin";
}
function preferences (state) {
return state.preferences;
}
export default { user, jwt, writeaccess, adminaccess, preferences };
export default { user, jwt, preferences };

View File

@@ -8,7 +8,7 @@
@input="closeDialog"
>
<template v-slot:activator="{ on, attrs }">
<v-btn v-if="writeaccess"
<v-btn v-if="writeaccess()"
small
color="primary"
v-bind="attrs"
@@ -182,7 +182,7 @@
</v-container>
</v-card-text>
<v-card-actions>
<v-btn v-if="writeaccess"
<v-btn v-if="writeaccess()"
small
text
color="primary"
@@ -205,7 +205,7 @@
</v-btn>
</v-btn-toggle>
<v-spacer></v-spacer>
<v-btn v-if="writeaccess"
<v-btn v-if="writeaccess()"
small
dark
color="red"
@@ -247,7 +247,7 @@
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn v-if="writeaccess"
<v-btn v-if="writeaccess()"
small
dark
color="red"
@@ -303,10 +303,15 @@
<script>
import { mapActions, mapGetters } from 'vuex';
import AccessMixin from '@/mixins/access';
export default {
name: "Equipment",
mixins: [
AccessMixin
],
data () {
return {
latest: [],
@@ -395,7 +400,7 @@ export default {
return null;
},
...mapGetters(['user', 'writeaccess', 'loading', 'serverEvent'])
...mapGetters(['user', 'loading', 'serverEvent'])
},

View File

@@ -240,7 +240,7 @@ export default {
return this.sequences[0]?.sequence;
},
...mapGetters(['user', 'preferences', 'writeaccess', 'loading', 'serverEvent'])
...mapGetters(['user', 'preferences', 'loading', 'serverEvent'])
},
methods: {

View File

@@ -17,7 +17,7 @@
</v-card-title>
<v-card-text>
<v-menu v-if="writeaccess"
<v-menu v-if="writeaccess()"
v-model="contextMenuShow"
:position-x="contextMenuX"
:position-y="contextMenuY"
@@ -164,7 +164,7 @@
</v-text-field>
<div v-else>
<span v-html="$options.filters.markdownInline(item.remarks)"></span>
<v-btn v-if="writeaccess && edit === null"
<v-btn v-if="writeaccess() && edit === null"
icon
small
title="Edit"
@@ -196,6 +196,7 @@
<script>
import { mapActions, mapGetters } from 'vuex';
import DougalLineStatus from '@/components/line-status';
import AccessMixin from '@/mixins/access';
export default {
name: "LineList",
@@ -204,6 +205,10 @@ export default {
DougalLineStatus
},
mixins: [
AccessMixin
],
data () {
return {
headers: [
@@ -281,7 +286,7 @@ export default {
},
computed: {
...mapGetters(['user', 'writeaccess', 'linesLoading', 'lines', 'sequences', 'plannedSequences'])
...mapGetters(['user', 'linesLoading', 'lines', 'sequences', 'plannedSequences'])
},
watch: {

View File

@@ -44,7 +44,7 @@
>mdi-format-list-numbered</v-icon>
</a>
<dougal-event-edit v-if="writeaccess"
<dougal-event-edit v-if="$parent.writeaccess()"
v-model="eventDialog"
v-bind="editedEvent"
:available-labels="userLabels"
@@ -54,7 +54,7 @@
>
</dougal-event-edit>
<dougal-event-edit-labels v-if="writeaccess"
<dougal-event-edit-labels v-if="$parent.writeaccess()"
v-model="eventLabelsDialog"
:labels="userLabels"
:selected="contextMenuItem ? contextMenuItem.labels||[] : []"
@@ -171,7 +171,7 @@
<v-card-text>
<!-- BEGIN Context menu for log entries -->
<v-menu v-if="writeaccess"
<v-menu v-if="$parent.writeaccess()"
v-model="contextMenuShow"
:position-x="contextMenuX"
:position-y="contextMenuY"
@@ -325,7 +325,7 @@
@click="labelSearch=label"
>{{label}}</v-chip>
</span>
<dougal-event-edit-history v-if="entry.has_edits && writeaccess"
<dougal-event-edit-history v-if="entry.has_edits && $parent.writeaccess()"
:id="entry.id"
:disabled="eventsLoading"
:labels="labels"
@@ -541,7 +541,7 @@ export default {
}
},
...mapGetters(['user', 'writeaccess', 'eventsLoading', 'online', 'sequence', 'line', 'point', 'position', 'timestamp', 'lineName', 'events', 'labels', 'userLabels', 'projectConfiguration']),
...mapGetters(['user', 'eventsLoading', 'online', 'sequence', 'line', 'point', 'position', 'timestamp', 'lineName', 'events', 'labels', 'userLabels', 'projectConfiguration']),
...mapState({projectSchema: state => state.project.projectSchema})
},

View File

@@ -50,7 +50,7 @@
</v-card-title>
<v-card-text>
<v-menu v-if="writeaccess"
<v-menu v-if="writeaccess()"
v-model="contextMenuShow"
:position-x="contextMenuX"
:position-y="contextMenuY"
@@ -68,7 +68,7 @@
<v-card class="mb-5" flat>
<v-card-title class="text-overline">
Comments
<template v-if="writeaccess">
<template v-if="writeaccess()">
<v-btn v-if="!editRemarks"
class="ml-3"
small
@@ -131,7 +131,7 @@
</template>
<template v-slot:item.sequence="{item, value}">
<v-edit-dialog v-if="writeaccess"
<v-edit-dialog v-if="writeaccess()"
large
@open="editItem(item, 'sequence')"
@save="edit = null"
@@ -156,7 +156,7 @@
</template>
<template v-slot:item.name="{item, value}">
<v-edit-dialog v-if="writeaccess"
<v-edit-dialog v-if="writeaccess()"
large
@open="editItem(item, 'name')"
@save="edit = null"
@@ -175,7 +175,7 @@
</template>
<template v-slot:item.fsp="{item, value}">
<v-edit-dialog v-if="writeaccess"
<v-edit-dialog v-if="writeaccess()"
large
@open="editItem(item, 'fsp')"
@save="edit = null"
@@ -195,7 +195,7 @@
</template>
<template v-slot:item.lsp="{item, value}">
<v-edit-dialog v-if="writeaccess"
<v-edit-dialog v-if="writeaccess()"
large
@open="editItem(item, 'lsp')"
@save="edit = null"
@@ -215,7 +215,7 @@
</template>
<template v-slot:item.ts0="{item, value}">
<v-edit-dialog v-if="writeaccess"
<v-edit-dialog v-if="writeaccess()"
large
@open="editItem(item, 'ts0', item.ts0.toISOString())"
@save="edit = null"
@@ -235,7 +235,7 @@
</template>
<template v-slot:item.ts1="{item, value}">
<v-edit-dialog v-if="writeaccess"
<v-edit-dialog v-if="writeaccess()"
large
@open="editItem(item, 'ts1', item.ts1.toISOString())"
@save="edit = null"
@@ -263,7 +263,7 @@
</template>
<template v-slot:item.remarks="{item}">
<v-text-field v-if="writeaccess && edit && edit.sequence == item.sequence && edit.key == 'remarks'"
<v-text-field v-if="writeaccess() && edit && edit.sequence == item.sequence && edit.key == 'remarks'"
type="text"
v-model="edit.value"
prepend-icon="mdi-restore"
@@ -275,7 +275,7 @@
</v-text-field>
<div v-else>
<span v-html="$options.filters.markdownInline(item.remarks)"></span>
<v-btn v-if="edit === null && writeaccess"
<v-btn v-if="edit === null && writeaccess()"
icon
small
title="Edit"
@@ -289,7 +289,7 @@
</template>
<template v-slot:item.speed="{item}">
<v-edit-dialog v-if="writeaccess"
<v-edit-dialog v-if="writeaccess()"
large
@open="editItem(item, 'speed', knots(item).toFixed(1))"
@save="edit = null"
@@ -311,7 +311,7 @@
</template>
<template v-slot:item.lag="{item}">
<v-edit-dialog v-if="writeaccess"
<v-edit-dialog v-if="writeaccess()"
large
@open="editItem(item, 'lagAfter', Math.round(lagAfter(item)/(60*1000)))"
@save="edit = null"
@@ -344,6 +344,7 @@
<script>
import suncalc from 'suncalc';
import { mapActions, mapGetters } from 'vuex';
import AccessMixin from '@/mixins/access';
export default {
name: "Plan",
@@ -351,6 +352,10 @@ export default {
components: {
},
mixins: [
AccessMixin
],
data () {
return {
headers: [
@@ -557,7 +562,7 @@ export default {
},
computed: {
...mapGetters(['user', 'writeaccess', 'plannedSequencesLoading', 'plannedSequences', 'planRemarks'])
...mapGetters(['user', 'plannedSequencesLoading', 'plannedSequences', 'planRemarks'])
},
watch: {

View File

@@ -20,9 +20,15 @@
<script>
import { mapActions, mapGetters } from 'vuex'
import AccessMixin from '@/mixins/access';
export default {
name: 'Project',
mixins: [
AccessMixin
],
components: {
},
data () {

View File

@@ -19,7 +19,7 @@
>mdi-archive-outline</v-icon>
</template>
<template v-else>
<a :href="`/projects/${item.pid}`">{{value}}</a>
<a :href="`/projects/${item.pid}`" :title="title(item)">{{value}}</a>
</template>
</template>
@@ -62,7 +62,7 @@
</v-data-table>
<v-menu v-if="adminaccess"
<v-menu v-if="adminaccess(contextMenuItem)"
v-model="contextMenuShow"
:position-x="contextMenuX"
:position-y="contextMenuY"
@@ -106,6 +106,7 @@ td p:last-of-type {
<script>
import { mapActions, mapGetters } from 'vuex';
import DougalProjectSettingsNameIdRootpath from '@/components/project-settings/name-id-rootpath'
import AccessMixin from '@/mixins/access';
export default {
name: "ProjectList",
@@ -114,6 +115,10 @@ export default {
DougalProjectSettingsNameIdRootpath
},
mixins: [
AccessMixin
],
data () {
return {
headers: [
@@ -179,7 +184,7 @@ export default {
: this.items.filter(i => !i.archived);
},
...mapGetters(['loading', 'serverEvent', 'adminaccess', 'projects'])
...mapGetters(['loading', 'serverEvent', 'projects'])
},
watch: {
@@ -205,6 +210,15 @@ export default {
}
},
title (item) {
if (item.organisations) {
return "Access:\n" + Object.entries(item.organisations).map( org =>
`${org[0]} (${Object.entries(org[1]).filter( access => access[1] ).map( access => access[0] ).join(", ")})`
).join("\n")
}
return "";
},
async load () {
await this.list();
const promises = [];

View File

@@ -228,6 +228,7 @@ import { deepSet } from '@/lib/utils';
import DougalJsonBuilder from '@/components/json-builder/json-builder';
import DougalProjectSettingsNameId from '@/components/project-settings/name-id';
import DougalProjectSettingsOrganisations from '@/components/project-settings/organisations';
import DougalProjectSettingsGroups from '@/components/project-settings/groups';
import DougalProjectSettingsGeodetics from '@/components/project-settings/geodetics';
import DougalProjectSettingsBinning from '@/components/project-settings/binning';
@@ -248,6 +249,7 @@ import DougalProjectSettingsNotImplemented from '@/components/project-settings/n
const components = {
name_id: DougalProjectSettingsNameId,
organisations: DougalProjectSettingsOrganisations,
groups: DougalProjectSettingsGroups,
geodetics: DougalProjectSettingsGeodetics,
binning: DougalProjectSettingsBinning,
@@ -304,6 +306,13 @@ export default {
name: obj?.name
})
},
{
id: "organisations",
name: "Organisations",
values: (obj) => ({
organisations: obj?.organisations
})
},
{
id: "groups",
name: "Groups",
@@ -571,7 +580,7 @@ export default {
return messages;
},
...mapGetters(['user', 'writeaccess', 'loading', 'serverEvent'])
...mapGetters(['user', 'loading', 'serverEvent'])
},
methods: {

View File

@@ -82,13 +82,13 @@
small
:color="labels[label] && labels[label].view.colour"
:title="labels[label] && labels[label].view.description"
:close="writeaccess && label == 'QCAccepted'"
:close="writeaccess() && label == 'QCAccepted'"
@click:close="unaccept(item)"
>
{{label}}
</v-chip>
<dougal-qc-acceptance v-if="writeaccess"
<dougal-qc-acceptance v-if="writeaccess()"
:item="item"
@accept="accept"
@unaccept="unaccept"
@@ -105,7 +105,7 @@
>
</v-chip>
<dougal-qc-acceptance v-if="writeaccess"
<dougal-qc-acceptance v-if="writeaccess()"
:item="item"
@accept="accept"
@unaccept="unaccept"
@@ -113,7 +113,7 @@
</div>
<div class="text--secondary" v-else>
<dougal-qc-acceptance v-if="writeaccess"
<dougal-qc-acceptance v-if="writeaccess()"
:item="item"
@accept="accept"
@unaccept="unaccept"
@@ -140,6 +140,7 @@
import { mapActions, mapGetters } from 'vuex';
import { withParentProps } from '@/lib/utils';
import DougalQcAcceptance from '@/components/qc-acceptance';
import AccessMixin from '@/mixins/access';
export default {
name: "QC",
@@ -148,6 +149,10 @@ export default {
DougalQcAcceptance
},
mixins: [
AccessMixin
],
data () {
return {
updatedOn: null,
@@ -227,7 +232,7 @@ export default {
return values;
},
...mapGetters(['writeaccess', 'loading'])
...mapGetters(['loading'])
},
watch: {

View File

@@ -28,10 +28,10 @@
offset-y
>
<v-list dense v-if="contextMenuItem">
<v-list-item @click="addToPlan(false); contextMenuShow=false" v-if="writeaccess">
<v-list-item @click="addToPlan(false); contextMenuShow=false" v-if="writeaccess()">
<v-list-item-title>Reshoot</v-list-item-title>
</v-list-item>
<v-list-item @click="addToPlan(true); contextMenuShow=false" v-if="writeaccess">
<v-list-item @click="addToPlan(true); contextMenuShow=false" v-if="writeaccess()">
<v-list-item-title>Reshoot with overlap</v-list-item-title>
</v-list-item>
<v-list-item
@@ -85,7 +85,7 @@
<!-- Item is not in queue -->
<v-list-item
v-if="writeaccess && !contextMenuItemInTransferQueue"
v-if="writeaccess() && !contextMenuItemInTransferQueue"
@click="addToTransferQueue(); contextMenuShow=false"
>
<v-list-item-content>
@@ -97,7 +97,7 @@
</v-list-item>
<!-- Item queued, not yet sent -->
<v-list-item two-line
v-else-if="writeaccess && contextMenuItemInTransferQueue.status == 'queued'"
v-else-if="writeaccess() && contextMenuItemInTransferQueue.status == 'queued'"
@click="removeFromTransferQueue(); contextMenuShow=false"
>
<v-list-item-content>
@@ -112,7 +112,7 @@
</v-list-item>
<!-- Item already sent -->
<v-list-item two-line
v-else-if="writeaccess && contextMenuItemInTransferQueue.status == 'sent'"
v-else-if="writeaccess() && contextMenuItemInTransferQueue.status == 'sent'"
@click="addToTransferQueue(); contextMenuShow=false"
>
<v-list-item-content>
@@ -127,7 +127,7 @@
</v-list-item>
<!-- Item sending was cancelled -->
<v-list-item two-line
v-else-if="writeaccess && contextMenuItemInTransferQueue.status == 'cancelled'"
v-else-if="writeaccess() && contextMenuItemInTransferQueue.status == 'cancelled'"
@click="addToTransferQueue(); contextMenuShow=false"
>
<v-list-item-content>
@@ -170,7 +170,7 @@
<v-card outlined class="flex-grow-1">
<v-card-title>
Acquisition remarks
<template v-if="writeaccess">
<template v-if="writeaccess()">
<template v-if="edit && edit.sequence == item.sequence && edit.key == 'remarks'">
<v-btn
class="ml-3"
@@ -222,7 +222,7 @@
<v-card outlined class="flex-grow-1" v-if="item.remarks_final !== null">
<v-card-title>
Processing remarks
<template v-if="writeaccess">
<template v-if="writeaccess()">
<template v-if="edit && edit.sequence == item.sequence && edit.key == 'remarks_final'">
<v-btn
class="ml-3"
@@ -492,10 +492,15 @@ tr :nth-child(5), tr :nth-child(8), tr :nth-child(11), tr :nth-child(14) {
import { mapActions, mapGetters } from 'vuex';
import { basename } from 'path';
import throttle from '@/lib/throttle';
import AccessMixin from '@/mixins/access';
export default {
name: "SequenceList",
mixins: [
AccessMixin
],
data () {
return {
headers: [
@@ -616,7 +621,7 @@ export default {
return this.queuedItems.find(i => i.payload.sequence == this.contextMenuItem.sequence);
},
...mapGetters(['user', 'writeaccess', 'sequencesLoading', 'sequences'])
...mapGetters(['user', 'sequencesLoading', 'sequences'])
},
watch: {

View File

@@ -0,0 +1,451 @@
<template>
<v-container fluid>
<v-overlay :value="loading && !users" absolute>
<v-progress-circular
indeterminate
size="64"
></v-progress-circular>
</v-overlay>
<v-overlay :value="!users && !loading" absolute opacity="0.8">
<v-row justify="center">
<v-alert
type="error"
>
The configuration could not be loaded.
</v-alert>
</v-row>
<v-row justify="center">
<v-btn color="primary" @click="getUsers">Retry</v-btn>
</v-row>
</v-overlay>
<v-row>
<v-col cols="4" max-height="100%">
<v-toolbar
dense
flat
>
<v-toolbar-title>
User management
</v-toolbar-title>
<v-btn v-if="!showIP && !showHost"
class="ml-3"
small
icon
title="Show password field (allows changing a user's password)"
@click="showPassword = !showPassword"
>
<v-icon small>{{ showPassword ? "mdi-eye" : "mdi-eye-off" }}</v-icon>
</v-btn>
<v-spacer/>
<template v-if="isDirty">
<v-icon color="warning" @click="reset" title="Discard changes">mdi-undo</v-icon>
<v-spacer/>
<v-icon left color="primary" @click="saveToFile" title="Save changes to file">mdi-content-save-outline</v-icon>
<v-icon color="primary" @click="upload" title="Upload changes to server">mdi-cloud-upload</v-icon>
</template>
</v-toolbar>
<!--
<v-btn
class="mb-5"
color="primary"
small
@click="addUser"
>
<v-icon left small>mdi-account-plus-outline</v-icon>
Add user
</v-btn>
-->
<v-menu
offset-y
>
<template v-slot:activator="{ on, attrs }">
<v-btn
class="mb-5"
color="primary"
small
v-bind="attrs"
v-on="on"
>
<v-icon left small>mdi-account-plus-outline</v-icon>
Add user
</v-btn>
</template>
<v-list>
<v-list-item>
<v-btn
class="mb-5"
width="100%"
color="blue"
dark
small
@click="addUser({password: true})"
>
Add named user
</v-btn>
</v-list-item>
<v-list-item>
<v-btn
class="mb-5"
width="100%"
color="green"
dark
small
@click="addUser({ip: true})"
>
Add IP user
</v-btn>
</v-list-item>
<v-list-item>
<v-btn
class="mb-5"
width="100%"
color="cyan"
dark
small
@click="addUser({host: true})"
>
Add host user
</v-btn>
</v-list-item>
</v-list>
</v-menu>
<v-treeview
ref="tree"
dense
open-on-click
activatable
hoverable
:multiple-active="false"
:active.sync="active"
:open.sync="open"
:items="treeview"
style="cursor:pointer;"
>
<template v-slot:prepend="{ item, open }">
<v-icon v-if="userIsDirty(item.value)"
small
color="warning"
title="Discard changes"
@click="reset(item)"
>mdi-undo</v-icon>
</template>
<template v-slot:label="{ item, open }">
<v-chip v-if="item.value"
small
>
{{ item.id }}
</v-chip>
<template v-else>
{{ item.id }}
</template>
</template>
<template v-slot:append="{ item, open }">
<v-icon v-if="userIsDirty(item.value)"
small
color="primary"
title="Save changes"
@click.stop="saveUser(item.value)"
>mdi-cloud-upload</v-icon>
<v-btn v-else-if="item.value"
small
icon
@click="remove(item)"
>
<v-icon
small
color="danger"
title="Delete this user"
>mdi-trash-can-outline</v-icon>
</v-btn>
</template>
</v-treeview>
</v-col>
<v-col cols="8">
<dougal-user-settings v-if="newUser"
:self="user"
:show-ip="showIP"
:show-host="showHost"
:show-password="showPassword"
v-model="newUser"
>
<template v-slot:actions="{ hasErrors }">
<v-btn
color="primary"
:disabled="hasErrors"
@click="saveUser(newUser)"
>Save</v-btn>
<v-spacer></v-spacer>
<v-btn
color="warning"
@click="newUser=null"
>Cancel</v-btn>
</template>
</dougal-user-settings>
<dougal-user-settings v-else-if="activeUser"
:self="user"
:show-password="showPassword"
v-model="activeUser"
>
<template v-slot:actions="{ hasErrors, dirty }">
<v-btn
v-if="dirty"
color="primary"
:disabled="hasErrors"
@click="saveUser(activeUser)"
>Save</v-btn>
<v-spacer></v-spacer>
<v-btn
v-if="dirty"
color="warning"
@click="reset(activeUser)"
>Cancel</v-btn>
</template>
</dougal-user-settings>
<v-card v-else>
<v-card-text>
Select an user to edit from the list
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script>
import YAML from 'yaml';
import { mapActions, mapGetters } from 'vuex';
import { deepSet } from '@/lib/utils';
import { User } from '@/lib/user';
import DougalUserSettings from '@/components/user-settings';
export default {
name: "DougalUsers",
components: {
DougalUserSettings
},
data () {
return {
users: [],
active: [],
open: [],
newUser: null,
showIP: null,
showHost: null,
showPassword: null,
};
},
computed: {
isDirty () {
return this.users.some( user => user.dirty );
},
treeview () {
const tree = {};
for (const user of this.users) {
if (!tree[user.name]) {
tree[user.name] = {
id: user.name,
name: user.name,
children: []
}
}
tree[user.name].children.push({
id: user.id,
name: user.id,
value: user
});
}
return Object.values(tree);
},
activeID () {
return this.active[0];
},
activeUser: {
get () {
if (this.activeID) {
return this.users.find(i => i.id == this.activeID);
}
},
set (v) {
if (this.activeID) {
const idx = this.users.findIndex( i => i.id == this.activeID );
if (idx != -1) {
this.users.splice(idx, 1, v);
}
}
}
},
...mapGetters(['user', 'loading', 'serverEvent'])
},
methods: {
async getUsers () {
return await User.fromAPI(this.api) ?? [];
/*
const url = `/user`;
const init = {
headers: {
"If-None-Match": "" // Ensure we get a fresh response
}
};
return await this.api([url, init]);
*/
},
userIsDirty (user) {
return user?.dirty;
},
addUser (opts={}) {
this.showIP = opts?.ip;
this.showHost = opts?.host;
this.showPassword = opts?.password;
this.newUser = this.user.spawn();
// Do not add the wildcard org by default.
// It can still be added by hand if needed
this.newUser.organisations.remove("*");
},
async saveUser (user) {
if (user.api) {
console.log("Existing user");
} else {
console.log("New user");
}
if (user == this.newUser) {
console.log("POST new user");
} else {
console.log("PUT existing user");
}
console.log(user);
return await user.save(this.api);
},
async saveAll () {
let count = 0;
const dirtyUsers = this.users.filter( user => user.dirty );
for (const user of dirtyUsers) {
this.showSnack([`Saving ${user.name} ${++count} of ${dirtyUsers.length} users`, "info"]);
await user.save(this.api);
}
if (count) {
this.showSnack([`${dirtyUsers.length} users saved`, "success"]);
}
},
async remove (item) {
// TODO: Ask for confirmation before removing!
const url = `/user/${item.id}`;
const init = { method: "DELETE" };
const res = await this.api([url, init]);
this.reset();
},
replace(user) {
const idx = this.users.findIndex(i => i.id == user.id);
if (idx != -1) {
this.users.splice(idx, 1, user);
}
},
async saveToFile () {
const payload = YAML.stringify(this.users);
const blob = new Blob([payload], {type: "application/yaml"});
const url = URL.createObjectURL(blob);
const filename = "dougal-users.yaml";
const element = document.createElement('a');
element.download = filename;
element.href = url;
element.click();
URL.revokeObjectURL(url);
},
async upload () {
let success = true;
const dirty = this.users.filter(i => i.$isDirty);
let count = 0;
for (const user of dirty) {
const body = {...user};
delete body.$isDirty;
const url = `/user/${user.id}`;
const init = {
method: "PUT",
headers: {
"Content-Type": "application/json"
},
body
};
const res = await this.api([url, init]);
if (res && res.id) {
// In case the server decided to apply any changes
this.showSnack([`User ${user.id} uploaded to server (${++count}/${dirty.length})`, "success"]);
this.$nextTick( () => {
this.replace(res);
});
} else {
success = false;
this.showSnack([`Failed to save user ${user.name} (${user.id})`, "warning"])
}
}
},
async reset (item) {
this.active = [];
this.open = [];
const users = await this.getUsers();
if (item) {
// Reset only this user
const id = item.id;
const idx0 = this.users.findIndex(i => i.id == id);
const idx1 = users.findIndex(i => i.id == id);
if (idx0 != -1 && idx1 != -1) {
this.users.splice(idx0, 1, users[idx1]);
}
} else {
// Reset all
this.users = users;
}
this.$refs.tree.updateAll(true);
},
...mapActions(["api", "showSnack"])
},
async mounted () {
await this.reset();
this.$refs.tree.updateAll(true);
},
}
</script>