This commit is contained in:
j3d1 2026-08-19 16:45:20 +02:00
parent 6d2167ac66
commit 4787acd8eb
23 changed files with 1241 additions and 25 deletions

View file

@ -33,6 +33,12 @@
<span class="align-middle">Friends</span>
</router-link>
</li>
<li class="sidebar-item">
<router-link to="/groups" class="sidebar-link">
<b-icon-people-fill class="bi-valign-middle"></b-icon-people-fill>
<span class="align-middle">Groups</span>
</router-link>
</li>
<li class="sidebar-item">
<router-link to="/files" class="sidebar-link">
<b-icon-folder class="bi-valign-middle"></b-icon-folder>

View file

@ -7,6 +7,9 @@ import store from '@/store';
import Profile from '@/views/Profile.vue';
import Settings from '@/views/Settings.vue';
import Friends from '@/views/Friends.vue';
import Groups from '@/views/Groups.vue';
import GroupNew from '@/views/GroupNew.vue';
import GroupDetail from '@/views/GroupDetail.vue';
import Inventory from '@/views/Inventory.vue';
import Search from '@/views/Search.vue';
import InventoryDetail from '@/views/InventoryDetail.vue';
@ -62,6 +65,19 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
path: '/friends',
component: Friends,
meta: {requiresAuth: true}
}, {
path: '/groups',
component: Groups,
meta: {requiresAuth: true}
}, {
path: '/groups/new',
component: GroupNew,
meta: {requiresAuth: true}
}, {
path: '/groups/:id',
component: GroupDetail,
meta: {requiresAuth: true},
props: true
}, {path: '/files', component: Files, meta: {requiresAuth: true}
}, {path: '/workflows',
component: Workflows,

View file

@ -72,6 +72,8 @@ export default createStore({
remember: false,
friends: [],
friendProfiles: {},
groups: [],
groupInvites: [],
item_map: {},
home_servers: null,
all_friends_servers: null,
@ -104,6 +106,12 @@ export default createStore({
setFriendProfiles(state, profiles) {
state.friendProfiles = profiles;
},
setGroups(state, groups) {
state.groups = groups;
},
setGroupInvites(state, invites) {
state.groupInvites = invites;
},
setHomeServers(state, home_servers) {
state.home_servers = home_servers;
},
@ -479,6 +487,75 @@ export default createStore({
const servers = await dispatch('getHomeServers')
return await servers.delete(getters.signAuth, '/api/friends/' + id + '/')
},
// Groups are only ever hosted on the current user's own home backend for now (see
// docs/design-in-progress/groups-mvp.md) - a remote member's edit/delete rights on a
// group-owned item work regardless, but "My Groups" has no way to discover a group hosted
// elsewhere, so every group action below talks to getHomeServers rather than resolving a
// per-group domain.
async fetchGroups({commit, dispatch, getters}) {
const servers = await dispatch('getHomeServers')
const data = await servers.get(getters.signAuth, '/api/groups/')
commit('setGroups', data)
return data
},
async fetchGroup({dispatch, getters}, {id}) {
const servers = await dispatch('getHomeServers')
return await servers.get(getters.signAuth, '/api/groups/' + id + '/')
},
async createGroup({dispatch, getters}, {name}) {
const servers = await dispatch('getHomeServers')
return await servers.post(getters.signAuth, '/api/groups/', {name})
},
async removeGroupMember({dispatch, getters}, {groupId, identityId}) {
const servers = await dispatch('getHomeServers')
return await servers.delete(getters.signAuth, '/api/groups/' + groupId + '/members/' + identityId + '/')
},
async fetchGroupInvites({commit, dispatch, getters}) {
const servers = await dispatch('getHomeServers')
const data = await servers.get(getters.signAuth, '/api/groupinvites/')
commit('setGroupInvites', data)
return data
},
async inviteToGroup({state, dispatch, getters}, {groupId, groupHandle, invitee}) {
const home_servers = await dispatch('getHomeServers')
const home_reply = await home_servers.post(
getters.signAuth, '/api/groups/' + groupId + '/invites/', {invitee})
if (!home_reply.secret) {
return false
}
const invitee_servers = await dispatch('getFriendServers', {username: invitee})
await invitee_servers.post(getters.signAuth, '/api/groupinvites/', {
group: groupHandle,
inviter: state.user,
inviter_key: nacl.to_hex(state.keypair.signPk),
invitee,
secret: home_reply.secret
})
return true
},
async acceptGroupInvite({state, dispatch, getters}, invite) {
const group_domain = invite.group.split('@')[1]
const group_servers = await dispatch('getFriendServers', {username: 'x@' + group_domain})
await group_servers.post(getters.signAuth, '/api/group_invites/accept/', {
group: invite.group,
invitee: state.user,
invitee_key: nacl.to_hex(state.keypair.signPk),
secret: invite.secret
})
const home_servers = await dispatch('getHomeServers')
await home_servers.delete(getters.signAuth, '/api/groupinvites/' + invite.id + '/')
return true
},
async declineGroupInvite({dispatch, getters}, invite) {
const servers = await dispatch('getHomeServers')
return await servers.delete(getters.signAuth, '/api/groupinvites/' + invite.id + '/')
},
async fetchGroupInventoryItems({commit, dispatch, getters}, groupId) {
const servers = await dispatch('getHomeServers')
const items = await servers.get(getters.signAuth, '/api/inventory_items/?group=' + groupId)
commit('setInventoryItems', {url: '/group/' + groupId, items})
return items
},
async fetchFiles({state, commit, dispatch, getters}) {
if (state.last_load.files > Date.now() - 1000 * 60 * 60 * 24) {
return state.files
@ -708,6 +785,7 @@ export default createStore({
inventory_items(state) {
return state.item_map['/'] || []
},
groupInventoryItems: (state) => (groupId) => state.item_map['/group/' + groupId] || [],
loaded_items(state) {
return Object.entries(state.item_map).reduce((acc, [url, items]) => {
return acc.concat(items)

View file

@ -0,0 +1,207 @@
<template>
<BaseLayout>
<main class="content">
<div class="container-fluid p-0">
<h1 class="h3 mb-3">{{ group ? group.handle : '' }}</h1>
<div class="row" v-if="group">
<div class="col-12 col-xl-6">
<div class="card">
<div class="card-header">
<h5 class="card-title">Members</h5>
</div>
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th style="width: 16em">
<a @click="refresh" class="align-middle">
<b-icon-arrow-clockwise></b-icon-arrow-clockwise>
Refresh
</a>
<a @click="show_invite = true" class="align-middle">
<b-icon-plus></b-icon-plus>
Add member
</a>
</th>
</tr>
</thead>
<tbody>
<tr v-if="show_invite">
<td>
<input type="text" class="form-control" placeholder="user@domain"
v-model="invitee">
</td>
<td>
<button class="btn btn-primary" @click="tryInvite">Invite</button>
</td>
</tr>
<tr v-for="member in group.members" :key="member.id">
<td>
<user-name-tag :user="member"/>
</td>
<td class="table-action">
<a href="#" class="align-middle" @click.prevent="tryRemoveMember(member)">
<b-icon-trash></b-icon-trash>
Remove
</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="card">
<div class="card-header">
<h5 class="card-title">Group inventory</h5>
<button v-if="layout === 'grid'" @click="layout = 'table'" class="btn">
<b-icon-list></b-icon-list>
</button>
<button v-else @click="layout = 'grid'" class="btn">
<b-icon-grid></b-icon-grid>
</button>
</div>
<table class="table table-striped" v-if="layout === 'table'">
<thead>
<tr>
<th style="width:40%;">Name</th>
<th style="width:25%">Availability Policy</th>
<th class="d-none d-md-table-cell" style="width:25%">Amount</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in items" :key="item.id">
<td>
<router-link :to="`/inventory/${item.id}`">{{ item.name }}</router-link>
</td>
<td class="d-none d-md-table-cell">
<span class="badge bg-secondary text-white">{{ item.availability_policy }}</span>
</td>
<td class="d-none d-md-table-cell">{{ item.owned_quantity }}</td>
<td class="table-action">
<router-link :to="`/inventory/${item.id}/edit`">
<b-icon-pencil-square></b-icon-pencil-square>
</router-link>
<a :href="`/inventory/${item.id}/delete`" @click.prevent="tryDeleteItem(item)">
<b-icon-trash></b-icon-trash>
</a>
</td>
</tr>
</tbody>
</table>
<div class="card-body" v-else>
<div class="row">
<div class="col-12 col-md-4 col-lg-3 col-xl-2" v-for="item in items" :key="item.id">
<div class="card">
<div class="card-body">
<h5 class="card-title mb-0">
<router-link :to="`/inventory/${item.id}`">
{{ item.name }}
</router-link>
</h5>
<div class="card-text text-black-50">
<span class="badge bg-secondary text-white">{{ item.availability_policy }}</span>
<span class="float-right">{{ item.owned_quantity }}</span>
</div>
<div class="btn-group">
<button class="btn btn-danger btn-sm" @click="tryDeleteItem(item)">
Delete
</button>
<router-link :to="`/inventory/${item.id}/edit`"
class="btn btn-primary btn-sm">Edit
</router-link>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card">
<button class="btn" @click="fetchItems">Refresh</button>
<router-link :to="`/inventory/new?group=${id}`" class="btn btn-primary">Add</router-link>
</div>
</div>
</div>
</div>
</main>
</BaseLayout>
</template>
<script>
import {mapActions, mapGetters, mapState} from "vuex";
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import UserNameTag from "@/components/UserNameTag.vue";
export default {
name: 'GroupDetail',
components: {
BaseLayout,
UserNameTag,
...BIcons
},
props: {
id: {
type: [String, Number],
required: true
}
},
data() {
return {
group: null,
layout: "grid",
show_invite: false,
invitee: ""
}
},
computed: {
...mapState(['user']),
items() {
return this.groupInventoryItems(this.id)
},
...mapGetters(['groupInventoryItems'])
},
methods: {
...mapActions(['fetchGroup', 'removeGroupMember', 'inviteToGroup', 'fetchGroupInventoryItems',
'deleteInventoryItem']),
refresh() {
this.fetchGroup({id: this.id}).then((group) => {
this.group = group
})
},
fetchItems() {
this.fetchGroupInventoryItems(this.id)
},
tryInvite() {
this.inviteToGroup({groupId: this.id, groupHandle: this.group.handle, invitee: this.invitee})
.then((ok) => {
if (ok) {
this.show_invite = false
this.invitee = ""
}
}).catch(() => {
})
},
tryRemoveMember(member) {
this.removeGroupMember({groupId: this.id, identityId: member.id}).then(() => {
this.refresh()
}).catch(() => {
})
},
tryDeleteItem(item) {
this.deleteInventoryItem(item).then(() => {
this.fetchItems()
})
}
},
mounted() {
this.refresh()
this.fetchItems()
}
}
</script>
<style scoped>
</style>

View file

@ -0,0 +1,52 @@
<template>
<BaseLayout>
<main class="content">
<div class="container-fluid p-0">
<h1 class="h3 mb-3">New group</h1>
<div class="row">
<div class="col-12 col-xl-6">
<div class="card">
<div class="card-body">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control" v-model="name">
</div>
<button type="submit" class="btn btn-primary" style="width: 100%"
@click="tryCreateGroup">Create</button>
</div>
</div>
</div>
</div>
</div>
</main>
</BaseLayout>
</template>
<script>
import {mapActions} from "vuex";
import BaseLayout from "@/components/BaseLayout.vue";
export default {
name: 'GroupNew',
components: {
BaseLayout
},
data() {
return {
name: ""
}
},
methods: {
...mapActions(['createGroup']),
tryCreateGroup() {
this.createGroup({name: this.name}).then((group) => {
this.$router.push(`/groups/${group.id}`)
}).catch(() => {
})
}
}
}
</script>
<style scoped>
</style>

View file

@ -0,0 +1,122 @@
<template>
<BaseLayout>
<main class="content">
<div class="container-fluid p-0">
<h1 class="h3 mb-3">Groups</h1>
<div class="row">
<div class="col-12 col-xl-6">
<div class="card">
<div class="card-header">
<h5 class="card-title">My Groups</h5>
</div>
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th class="d-none d-md-table-cell" style="width:25%">Members</th>
<th style="width: 16em">
<a @click="fetchGroups" class="align-middle">
<b-icon-arrow-clockwise></b-icon-arrow-clockwise>
Refresh
</a>
<router-link to="/groups/new" class="align-middle">
<b-icon-plus></b-icon-plus>
New group
</router-link>
</th>
</tr>
</thead>
<tbody>
<tr v-for="group in groups" :key="group.id">
<td>
<router-link :to="`/groups/${group.id}`">{{ group.handle }}</router-link>
</td>
<td class="d-none d-md-table-cell">{{ group.members.length }}</td>
<td class="table-action"></td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="col-12 col-xl-6">
<div class="card">
<div class="card-header">
<h5 class="card-title">Open group invites</h5>
</div>
<table class="table table-striped">
<thead>
<tr>
<th>Group</th>
<th class="d-none d-md-table-cell" style="width:25%">Invited by</th>
<th style="width: 16em">
<a @click="fetchGroupInvites" class="align-middle">
<b-icon-arrow-clockwise></b-icon-arrow-clockwise>
Refresh
</a>
</th>
</tr>
</thead>
<tbody>
<tr v-for="invite in groupInvites" :key="invite.id">
<td>{{ invite.group }}</td>
<td class="d-none d-md-table-cell">{{ invite.inviter }}</td>
<td class="table-action">
<button class="btn btn-sm btn-success" @click="tryAcceptInvite(invite)">
<b-icon-check></b-icon-check>
Accept
</button> &nbsp;
<button class="btn btn-sm btn-danger" @click="tryDeclineInvite(invite)">
<b-icon-x></b-icon-x>
Decline
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</main>
</BaseLayout>
</template>
<script>
import {mapActions, mapState} from "vuex";
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
export default {
name: 'Groups',
components: {
BaseLayout,
...BIcons
},
computed: {
...mapState(['groups', 'groupInvites']),
},
methods: {
...mapActions(['fetchGroups', 'fetchGroupInvites', 'acceptGroupInvite', 'declineGroupInvite']),
tryAcceptInvite(invite) {
this.acceptGroupInvite(invite).then(() => {
this.fetchGroupInvites()
this.fetchGroups()
}).catch(() => {
})
},
tryDeclineInvite(invite) {
this.declineGroupInvite(invite).then(() => {
this.fetchGroupInvites()
}).catch(() => {
})
}
},
mounted() {
this.fetchGroups()
this.fetchGroupInvites()
}
}
</script>
<style scoped>
</style>

View file

@ -92,7 +92,7 @@ export default {
}
},
computed: {
...mapGetters(["inventory_items"]),
...mapGetters(["loaded_items"]),
...mapState(["availability_policies", "storage_locations"]),
item() {
return {
@ -104,14 +104,14 @@ export default {
image: "",
files: [],
storage_location: null,
...this.inventory_items.find(item => item.id === parseInt(this.id))
...this.loaded_items.find(item => item.id === parseInt(this.id))
}
}
},
methods: {
...mapActions(["fetchInventoryItems", "updateInventoryItem", "fetchInfo", "fetchStorageLocations"]),
changeFiles(files) {
this.inventory_items.find(item => item.id === parseInt(this.id)).files = files
this.loaded_items.find(item => item.id === parseInt(this.id)).files = files
},
},
async mounted() {

View file

@ -24,6 +24,15 @@
<label for="property" class="form-label">Property</label>
<property-field :value="item.properties"></property-field>
</div>
<div class="mb-3">
<label for="owner" class="form-label">Owner</label>
<select class="form-select" id="owner" name="owner" v-model="item.owner_group">
<option :value="null">Myself</option>
<option v-for="group in groups" :value="group.id" :key="group.id">
{{ group.handle }}
</option>
</select>
</div>
<div class="mb-3">
<label for="quantity" class="form-label">Quantity</label>
<input type="number" class="form-control" id="quantity" name="quantity"
@ -96,19 +105,24 @@ export default {
tags: [],
properties: [],
files: [],
storage_location: null
storage_location: null,
owner_group: null
}
}
},
methods: {
...mapActions(['createInventoryItem', 'fetchInfo', 'fetchStorageLocations'])
...mapActions(['createInventoryItem', 'fetchInfo', 'fetchStorageLocations', 'fetchGroups'])
},
computed: {
...mapState(["availability_policies", "storage_locations"]),
...mapState(["availability_policies", "storage_locations", "groups"]),
},
async mounted() {
await this.fetchInfo();
await this.fetchStorageLocations();
await this.fetchGroups();
if (this.$route.query.group) {
this.item.owner_group = parseInt(this.$route.query.group, 10)
}
}
}
</script>