Add user management page to frontend

This commit is contained in:
D. Berge
2025-07-24 20:40:18 +02:00
parent 5d4e219403
commit ccfabf84f7
2 changed files with 465 additions and 0 deletions

View File

@@ -17,6 +17,7 @@ import QC from '../views/QC.vue'
import Graphs from '../views/Graphs.vue' import Graphs from '../views/Graphs.vue'
import Map from '../views/Map.vue' import Map from '../views/Map.vue'
import ProjectSettings from '../views/ProjectSettings.vue' import ProjectSettings from '../views/ProjectSettings.vue'
import Users from '../views/Users.vue'
import DougalAppBarExtensionProject from '../components/app-bar-extension-project' import DougalAppBarExtensionProject from '../components/app-bar-extension-project'
import DougalAppBarExtensionProjectList from '../components/app-bar-extension-project-list' import DougalAppBarExtensionProjectList from '../components/app-bar-extension-project-list'
@@ -49,6 +50,19 @@ Vue.use(VueRouter)
name: "equipment", name: "equipment",
component: () => import(/* webpackChunkName: "about" */ '../views/Equipment.vue') 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 }, pathToRegexpOptions: { strict: true },
path: "/login", path: "/login",

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>