Compare commits

...

4 commits

Author SHA1 Message Date
8de6ae8d8b stash 2026-08-26 20:40:23 +02:00
35be834799 stash 2026-08-26 18:37:06 +02:00
9ce700c388 stash 2026-08-26 18:34:28 +02:00
582853ab27 stash 2026-08-26 18:17:12 +02:00
23 changed files with 404 additions and 128 deletions

View file

@ -1,7 +1,7 @@
from django.contrib import admin
from authentication.models import ToolshedUser, KnownIdentity, FriendRequestOutgoing, FriendRequestIncoming, \
AccountPreference, Group, GroupInvite, GroupInviteIncoming
AccountPreference, Group, GroupInvite, GroupInviteIncoming, GroupMembership
class ToolshedUserAdmin(admin.ModelAdmin):
@ -48,6 +48,11 @@ class GroupInviteIncomingAdmin(admin.ModelAdmin):
search_fields = ('secret', 'group_name', 'group_domain', 'inviter_username', 'inviter_domain', 'invitee_user')
class GroupMembershipAdmin(admin.ModelAdmin):
list_display = ('user', 'group_name', 'group_domain', 'created_at')
search_fields = ('user__username', 'group_name', 'group_domain')
admin.site.register(ToolshedUser, ToolshedUserAdmin)
admin.site.register(KnownIdentity, KnownIdentityAdmin)
admin.site.register(FriendRequestOutgoing, FriendRequestOutgoingAdmin)
@ -56,3 +61,4 @@ admin.site.register(AccountPreference, AccountPreferenceAdmin)
admin.site.register(Group, GroupAdmin)
admin.site.register(GroupInvite, GroupInviteAdmin)
admin.site.register(GroupInviteIncoming, GroupInviteIncomingAdmin)
admin.site.register(GroupMembership, GroupMembershipAdmin)

View file

@ -0,0 +1,28 @@
# Generated by Django 4.2.2 on 2026-08-26 16:25
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('authentication', '0005_group_groupinviteincoming_groupinvite_and_more'),
]
operations = [
migrations.CreateModel(
name='GroupMembership',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('group_name', models.CharField(max_length=255)),
('group_domain', models.CharField(max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_memberships', to=settings.AUTH_USER_MODEL)),
],
options={
'unique_together': {('user', 'group_name', 'group_domain')},
},
),
]

View file

@ -180,3 +180,23 @@ class GroupInviteIncoming(models.Model):
inviter_domain = models.CharField(max_length=255)
invitee_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='group_invites_incoming')
created_at = models.DateTimeField(auto_now_add=True)
class GroupMembership(models.Model):
"""A pointer kept on a member's own home backend, recording that the local user is a member
of a group that may be hosted here or on a remote domain (mirrors FriendRequestIncoming's
role: independently recorded on the member's own side, not just the group's authoritative
backend). Written once the invitee's accept request against the group's home backend has
succeeded (see GroupInvitesIncomingAccept), analogous to how a friendship is independently
recorded on both sides via KnownIdentity.friends rather than only on one. See
docs/design-in-progress/groups-mvp.md's 'Known limitation'."""
user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='group_memberships')
group_name = models.CharField(max_length=255)
group_domain = models.CharField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ('user', 'group_name', 'group_domain')
def __str__(self):
return f"{self.user} MEMBER_OF #{self.group_name}@{self.group_domain}"

View file

@ -9,11 +9,12 @@ from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ViewSetMixin
from authentication.models import Group, GroupInvite, GroupInviteIncoming, KnownIdentity, ToolshedUser
from authentication.models import Group, GroupInvite, GroupInviteIncoming, GroupMembership, KnownIdentity, \
ToolshedUser
from authentication.signature_auth import SignatureAuthentication, SignatureAuthenticationLocal, \
authenticate_request_against_local_users, split_grouphandle_or_throw, split_userhandle_or_throw, \
verify_incoming_group_invite
from toolshed.serializers import GroupSerializer, GroupInviteIncomingSerializer
from toolshed.serializers import GroupSerializer, GroupInviteIncomingSerializer, GroupMembershipSerializer
class Groups(APIView, ViewSetMixin):
@ -126,6 +127,34 @@ def declineGroupInvite(request, pk, format=None): # /api/groupinvites/<pk>/
return Response(status=status.HTTP_204_NO_CONTENT)
@api_view(['POST'])
@authentication_classes([SignatureAuthenticationLocal])
@permission_classes([IsAuthenticated])
def recordGroupMembership(request, pk, format=None): # /api/groupinvites/<pk>/accept/
"""Local bookkeeping call on the invitee's own home backend, made by the client once its
accept request against the group's home backend (acceptGroupInvite) has succeeded. Turns the
now-obsolete GroupInviteIncoming into a durable GroupMembership pointer, so this backend
remembers which foreign groups its own user belongs to -- the same way FriendRequestIncoming
ends up recorded as a friend on both sides, not just the group's authoritative backend."""
invite = get_object_or_404(request.user.group_invites_incoming, pk=pk)
membership, _ = GroupMembership.objects.get_or_create(
user=request.user, group_name=invite.group_name, group_domain=invite.group_domain)
invite.delete()
return Response(status=status.HTTP_201_CREATED, data=GroupMembershipSerializer(membership).data)
class GroupMemberships(APIView, ViewSetMixin):
"""/api/groupmemberships/ - the personal index of every group (local or remote) this backend
has recorded the caller as belonging to, kept independently of the group's own membership
roster (see GroupMembership and recordGroupMembership)."""
authentication_classes = [SignatureAuthenticationLocal]
permission_classes = [IsAuthenticated]
def get(self, request, format=None): # /api/groupmemberships/
memberships = request.user.group_memberships.all()
return Response(GroupMembershipSerializer(memberships, many=True).data)
@api_view(['POST'])
def acceptGroupInvite(request, format=None): # /api/group_invites/accept/ - lands on the group's home backend
raw_request = request.body.decode('utf-8')
@ -161,5 +190,7 @@ urlpatterns = [
path('groups/<int:pk>/invites/', createGroupInvite, name='create_group_invite'),
path('groupinvites/', GroupInvitesIncoming.as_view(), name='group_invites_incoming'),
path('groupinvites/<int:pk>/', declineGroupInvite, name='decline_group_invite'),
path('groupinvites/<int:pk>/accept/', recordGroupMembership, name='record_group_membership'),
path('group_invites/accept/', acceptGroupInvite, name='accept_group_invite'),
path('groupmemberships/', GroupMemberships.as_view(), name='group_memberships'),
]

View file

@ -1,6 +1,7 @@
from django.core.exceptions import ObjectDoesNotExist
from rest_framework import serializers
from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming, Group, GroupInviteIncoming
from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming, Group, GroupInviteIncoming, \
GroupMembership
from authentication.serializers import OwnerSerializer, GroupOwnerSerializer
from files.models import File
from files.serializers import FileSerializer
@ -119,6 +120,17 @@ class GroupInviteIncomingSerializer(serializers.ModelSerializer):
return obj.inviter_username + '@' + obj.inviter_domain
class GroupMembershipSerializer(serializers.ModelSerializer):
handle = serializers.SerializerMethodField()
class Meta:
model = GroupMembership
fields = ['id', 'group_name', 'group_domain', 'handle', 'created_at']
def get_handle(self, obj):
return f"#{obj.group_name}@{obj.group_domain}"
class PropertySerializer(serializers.ModelSerializer):
category = serializers.SerializerMethodField()
handle = serializers.SerializerMethodField()

View file

@ -2,7 +2,7 @@ from django.test import Client
from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase, \
DummyExternalUser
from authentication.models import Group, GroupInvite, GroupInviteIncoming, KnownIdentity
from authentication.models import Group, GroupInvite, GroupInviteIncoming, GroupMembership, KnownIdentity
client = SignatureAuthClient()
@ -124,9 +124,13 @@ class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
self.assertTrue(group.is_member(invitee.public_identity))
self.assertEqual(GroupInvite.objects.count(), 0)
reply5 = client.delete('/api/groupinvites/{}/'.format(incoming.id), invitee)
self.assertEqual(reply5.status_code, 204)
reply5 = client.post('/api/groupinvites/{}/accept/'.format(incoming.id), invitee)
self.assertEqual(reply5.status_code, 201)
self.assertEqual(reply5.json()['handle'], str(group))
self.assertEqual(GroupInviteIncoming.objects.count(), 0)
membership = GroupMembership.objects.get(user=invitee)
self.assertEqual(membership.group_name, group.name)
self.assertEqual(membership.group_domain, group.domain)
def test_invite_non_member_denied(self):
group = self.f['group1']
@ -189,3 +193,69 @@ class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
})
self.assertEqual(reply.status_code, 401)
self.assertFalse(group.is_member(invitee.public_identity))
class GroupMembershipApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
"""Covers the pointer index kept on a member's own home backend (GroupMembership), which is
what lets that backend remember foreign group membership, analogous to how a friendship ends
up recorded on both sides rather than only on the group's authoritative backend."""
def setUp(self):
super().setUp()
self.prepare_users()
self.prepare_groups()
def test_record_membership_for_remote_group(self):
# local_user2's own home backend has no Group row at all for this group -- it's hosted
# entirely on another domain -- yet it should still remember the membership once the
# invitee's client confirms the accept against the group's home backend succeeded.
invitee = self.f['local_user2']
incoming = GroupInviteIncoming.objects.create(
group_name='remoteworkshop', group_domain='other.example',
inviter_username='someone', inviter_domain='other.example',
invitee_user=invitee, secret='some-secret')
reply = client.post('/api/groupinvites/{}/accept/'.format(incoming.id), invitee)
self.assertEqual(reply.status_code, 201)
self.assertEqual(reply.json()['handle'], '#remoteworkshop@other.example')
self.assertEqual(GroupInviteIncoming.objects.count(), 0)
membership = GroupMembership.objects.get(user=invitee)
self.assertEqual(membership.group_name, 'remoteworkshop')
self.assertEqual(membership.group_domain, 'other.example')
def test_record_membership_wrong_user_denied(self):
incoming = GroupInviteIncoming.objects.create(
group_name='remoteworkshop', group_domain='other.example',
inviter_username='someone', inviter_domain='other.example',
invitee_user=self.f['local_user2'], secret='some-secret')
reply = client.post('/api/groupinvites/{}/accept/'.format(incoming.id), self.f['local_user1'])
self.assertEqual(reply.status_code, 404)
self.assertEqual(GroupMembership.objects.count(), 0)
self.assertEqual(GroupInviteIncoming.objects.count(), 1)
def test_decline_does_not_record_membership(self):
incoming = GroupInviteIncoming.objects.create(
group_name='remoteworkshop', group_domain='other.example',
inviter_username='someone', inviter_domain='other.example',
invitee_user=self.f['local_user2'], secret='some-secret')
reply = client.delete('/api/groupinvites/{}/'.format(incoming.id), self.f['local_user2'])
self.assertEqual(reply.status_code, 204)
self.assertEqual(GroupMembership.objects.count(), 0)
def test_list_memberships(self):
GroupMembership.objects.create(
user=self.f['local_user1'], group_name='remoteworkshop', group_domain='other.example')
reply = client.get('/api/groupmemberships/', self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 1)
self.assertEqual(reply.json()[0]['handle'], '#remoteworkshop@other.example')
reply2 = client.get('/api/groupmemberships/', self.f['local_user2'])
self.assertEqual(reply2.status_code, 200)
self.assertEqual(len(reply2.json()), 0)

View file

@ -1,3 +1,5 @@
import {encodeHandleForUrl} from "@/router";
// A template's layout tree, content resolution, and selectability contract. See
// docs/implementation.md#template-layout-tree.
const GAP = {type: "empty", "min-width": "1mm", "min-height": "1mm"};
@ -248,7 +250,7 @@ export const DERIVED_VARS = {
// defaults to this origin but is editable, since any frontend can resolve any handle.
itemUrl: {
inputs: ["webdomain", "userHandle", "itemId"],
calc: (f) => `${f.webdomain}/i/${f.userHandle}/${f.itemId}`,
calc: (f) => `${f.webdomain}/i/${encodeHandleForUrl(f.userHandle)}/${f.itemId}`,
},
// Compact "owner handle + id" form (see docs/design-in-progress/items-labels.md) - meaningful
// only where context already makes clear it's a Toolshed item, unlike itemUrl.
@ -307,11 +309,6 @@ export function templateIsAvailable(t, fields) {
return available;
}
// Resolves t's content leaves against fields into a tree for label.js's
// drawLabel/drawFallbackLabel. Throws the same {message, short} shape as label.js's own
// capacity errors (see docs/implementation.md#missing-data-is-a-templatecontent-error) when a
// leaf isn't resolved yet, rather than returning null, so a caller's single catch around
// drawLabel/drawFallbackLabel handles both without a separate isAvailable pre-check.
export function templateContent(t, fields) {
if (!templateIsAvailable(t, fields)) {
const err = new Error("This layout needs more fields filled in before it can be drawn.");

View file

@ -51,18 +51,25 @@ function itemDetailRoute(handle, item_local_id) {
return handle ? `/inventory/${encodeHandleForUrl(handle)}/${item_local_id}` : null;
}
export function ownerOverviewRoute(item) {
return item.owner_group ? `/groups/${encodeHandleForUrl(item.owner_group)}` : '/inventory';
}
const EXPANDED_ROUTE_BUILDERS = {
item: ({owner_identity_id, item_local_id}) =>
itemDetailRoute(store.getters.identityHandleById[owner_identity_id], item_local_id),
group_item: ({owner_group_id, item_local_id}) =>
itemDetailRoute(store.getters.groupHandleById[owner_group_id], item_local_id),
group: ({group_id}) => `/groups/${group_id}`,
group: ({group_id}) => {
const handle = store.getters.groupHandleById[group_id];
return handle ? `/groups/${encodeHandleForUrl(handle)}` : null;
},
storage_location: ({storage_location_id}) => `/storage-locations/${storage_location_id}`,
workflow: ({workflow_id}) => `/workflows/${workflow_id}`,
};
// Kinds whose route needs identityHandleById/groupHandleById (from state.idmap); ShortId.vue only waits on an idmap fetch for these.
export const NEEDS_IDMAP = new Set(['item', 'group_item']);
export const NEEDS_IDMAP = new Set(['item', 'group_item', 'group']);
export function expandedRoute({kind, ...fields}) {
const buildRoute = EXPANDED_ROUTE_BUILDERS[kind];
@ -117,7 +124,7 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
component: GroupNew,
meta: {requiresAuth: true}
}, {
path: '/groups/:id',
path: '/groups/:handle',
component: GroupDetail,
meta: {requiresAuth: true},
props: true

View file

@ -8,6 +8,11 @@ import {serializeWorkflowPayload, deserializeWorkflowPayload} from "@/workflows.
//import sharedStatePlugin from "@/../extras/shared-state-plugin";
//import persistentStatePlugin from "@/../extras/persistent-state-plugin";
function splitGroupHandle(handle) {
const [name, domain] = handle.slice(1).split('@')
return {name, domain}
}
const defaultPreferenceDefinitions = [
{
key: 'ui.compact_mode',
@ -74,6 +79,7 @@ export default createStore({
friendProfiles: {},
groups: [],
groupInvites: [],
groupMemberships: [],
idmap: {identities: [], groups: []},
idmapLoaded: false,
item_map: {},
@ -114,6 +120,9 @@ export default createStore({
setGroupInvites(state, invites) {
state.groupInvites = invites;
},
setGroupMemberships(state, memberships) {
state.groupMemberships = memberships;
},
setIdMap(state, idmap) {
state.idmap = idmap;
state.idmapLoaded = true;
@ -394,14 +403,22 @@ export default createStore({
state.last_load.files = 0
return reply
},
// A group-owned item lives on its owning group's own backend, not necessarily the
// caller's home one (see the group actions below) - item.owner_group ("#name@domain")
// already carries that domain, so update/delete resolve it the same way rather than
// needing the caller to track it separately.
async updateInventoryItem({state, dispatch, getters}, item) {
const servers = await dispatch('getHomeServers')
const servers = item.owner_group
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain})
: await dispatch('getHomeServers')
const data = {availability_policy: 'friends', ...item}
data.files = data.files.map(file => file.id)
return await servers.patch(getters.signAuth, '/api/inventory_items/' + item.id + '/', data)
},
async deleteInventoryItem({state, dispatch, getters}, item) {
const servers = await dispatch('getHomeServers')
const servers = item.owner_group
? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain})
: await dispatch('getHomeServers')
const ret = await servers.delete(getters.signAuth, '/api/inventory_items/' + item.id + '/')
dispatch('fetchInventoryItems')
return ret
@ -448,19 +465,21 @@ export default createStore({
return null;
}
},
// Group handles have no owner-handle GET route yet, so they resolve differently than
// personal handles here. See docs/implementation.md#fetch-item-by-handle-group-vs-personal-handles.
// A group handle resolves via fetchGroup (itself domain-routed, see below), so a group
// hosted elsewhere works exactly like one hosted on the caller's own backend - no idmap
// detour needed here. ServerSet.get() never checks response.ok, so a 404 (nonexistent or
// non-member group) surfaces as a JSON-parse rejection rather than resolving falsy - same
// try/catch shape as fetchForeignItem.
async fetchItemByHandle({dispatch, getters}, {handle, id}) {
if (handle.startsWith('#')) {
// idmap isn't guaranteed loaded on a direct/refreshed visit here; fetchIdMap is
// cheap and already called unconditionally by every other caller too.
await dispatch('fetchIdMap')
const groupId = getters.groupIdByHandle[handle]
if (groupId === undefined) {
return null
try {
const group = await dispatch('fetchGroup', {handle})
const items = await dispatch('fetchGroupInventoryItems', {groupId: group.id, domain: group.domain})
return items.find(item => item.id === parseInt(id)) || null
} catch (error) {
console.error(`Failed to fetch group item ${id} for ${handle}:`, error);
return null;
}
const items = await dispatch('fetchGroupInventoryItems', groupId)
return items.find(item => item.id === parseInt(id)) || null
}
return dispatch('fetchForeignItem', {owner: handle, id})
},
@ -511,9 +530,14 @@ 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), so every group action below uses getHomeServers
// rather than resolving a per-group domain.
// Listing/creating a group is inherently home-scoped - it's this backend's own view of
// "groups I host you in" - but every action below that acts on an *existing* group
// (found via its handle, e.g. from a GroupMembership pointer to a group hosted
// elsewhere - see GroupMembership/docs/design-in-progress/groups-mvp.md) resolves that
// group's own domain and talks to it directly, the same way requestFriend/
// fetchForeignItem resolve a friend's domain, rather than assuming home. A group hosted
// on the caller's own domain goes through exactly the same code path - there's nothing
// to special-case.
async fetchGroups({commit, dispatch, getters}) {
const servers = await dispatch('getHomeServers')
const data = await servers.get(getters.signAuth, '/api/groups/')
@ -526,16 +550,18 @@ export default createStore({
commit('setIdMap', idmap)
return idmap
},
async fetchGroup({dispatch, getters}, {id}) {
const servers = await dispatch('getHomeServers')
return await servers.get(getters.signAuth, '/api/groups/' + id + '/')
// handle is "#name@domain".
async fetchGroup({dispatch, getters}, {handle}) {
const {name, domain} = splitGroupHandle(handle)
const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
return await servers.get(getters.signAuth, `/api/groups/handle/${name}/${domain}/`)
},
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')
async removeGroupMember({dispatch, getters}, {groupId, domain, identityId}) {
const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
return await servers.delete(getters.signAuth, '/api/groups/' + groupId + '/members/' + identityId + '/')
},
async fetchGroupInvites({commit, dispatch, getters}) {
@ -544,11 +570,17 @@ export default createStore({
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(
async fetchGroupMemberships({commit, dispatch, getters}) {
const servers = await dispatch('getHomeServers')
const data = await servers.get(getters.signAuth, '/api/groupmemberships/')
commit('setGroupMemberships', data)
return data
},
async inviteToGroup({state, dispatch, getters}, {groupId, domain, groupHandle, invitee}) {
const group_servers = await dispatch('getFriendServers', {username: 'x@' + domain})
const group_reply = await group_servers.post(
getters.signAuth, '/api/groups/' + groupId + '/invites/', {invitee})
if (!home_reply.secret) {
if (!group_reply.secret) {
return false
}
const invitee_servers = await dispatch('getFriendServers', {username: invitee})
@ -557,7 +589,7 @@ export default createStore({
inviter: state.user,
inviter_key: nacl.to_hex(state.keypair.signPk),
invitee,
secret: home_reply.secret
secret: group_reply.secret
})
return true
},
@ -571,17 +603,20 @@ export default createStore({
secret: invite.secret
})
const home_servers = await dispatch('getHomeServers')
await home_servers.delete(getters.signAuth, '/api/groupinvites/' + invite.id + '/')
await home_servers.post(getters.signAuth, '/api/groupinvites/' + invite.id + '/accept/', {})
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')
async fetchGroupInventoryItems({commit, dispatch, getters}, {groupId, domain}) {
const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
const items = await servers.get(getters.signAuth, '/api/inventory_items/?group=' + groupId)
commit('setInventoryItems', {url: '/group/' + groupId, items})
// Namespaced by domain, not just groupId: that pk is only unique within its own
// backend's database, so two different domains could otherwise collide on the same
// item_map key.
commit('setInventoryItems', {url: '/group/' + domain + '/' + groupId, items})
return items
},
async fetchFiles({state, commit, dispatch, getters}) {
@ -813,7 +848,7 @@ export default createStore({
inventory_items(state) {
return state.item_map['/'] || []
},
groupInventoryItems: (state) => (groupId) => state.item_map['/group/' + groupId] || [],
groupInventoryItems: (state) => (groupId, domain) => state.item_map['/group/' + domain + '/' + groupId] || [],
identityIdByHandle(state) {
return Object.fromEntries(state.idmap.identities.map(i => [i.username, i.id]))
},

View file

@ -86,6 +86,9 @@
<a :href="`${itemRoute(item)}/delete`" @click.prevent="tryDeleteItem(item)">
<b-icon-trash></b-icon-trash>
</a>
<router-link v-if="printLinkFor(item)" :to="printLinkFor(item)">
<b-icon-qr-code></b-icon-qr-code>
</router-link>
</td>
</tr>
</tbody>
@ -111,6 +114,10 @@
<router-link :to="`${itemRoute(item)}/edit`"
class="btn btn-primary btn-sm">Edit
</router-link>
<router-link v-if="printLinkFor(item)" :to="printLinkFor(item)"
class="btn btn-secondary btn-sm">
<b-icon-qr-code></b-icon-qr-code>
</router-link>
</div>
</div>
</div>
@ -120,7 +127,9 @@
</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>
<router-link v-if="isOwnDomain" :to="`/inventory/new?group=${group.id}`"
class="btn btn-primary">Add
</router-link>
</div>
</div>
</div>
@ -134,7 +143,7 @@ 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";
import {encodeHandleForUrl} from "@/router";
import {decodeHandleFromUrl, encodeHandleForUrl} from "@/router";
export default {
name: 'GroupDetail',
@ -144,8 +153,8 @@ export default {
...BIcons
},
props: {
id: {
type: [String, Number],
handle: {
type: String,
required: true
}
},
@ -159,39 +168,55 @@ export default {
},
computed: {
...mapState(['user']),
decodedHandle() {
return decodeHandleFromUrl(this.handle)
},
items() {
return this.groupInventoryItems(this.id)
return this.group ? this.groupInventoryItems(this.group.id, this.group.domain) : []
},
isOwnDomain() {
return this.group && this.group.domain === this.user.split('@')[1]
},
...mapGetters(['groupInventoryItems'])
},
methods: {
...mapActions(['fetchGroup', 'removeGroupMember', 'inviteToGroup', 'fetchGroupInventoryItems',
'deleteInventoryItem']),
// Loads by handle regardless of which backend actually hosts the group - see
// store.js's fetchGroup.
refresh() {
this.fetchGroup({id: this.id}).then((group) => {
return this.fetchGroup({handle: this.decodedHandle}).then((group) => {
this.group = group
})
},
fetchItems() {
this.fetchGroupInventoryItems(this.id)
if (this.group) {
this.fetchGroupInventoryItems({groupId: this.group.id, domain: this.group.domain})
}
},
itemRoute(item) {
return `/inventory/${encodeHandleForUrl(this.group.handle)}/${item.id}`
},
printLinkFor(item) {
return {path: '/print', query: {kind: 'item', userHandle: this.group.handle, item: item.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(() => {
this.inviteToGroup({
groupId: this.group.id, domain: this.group.domain, 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(() => {
this.removeGroupMember({groupId: this.group.id, domain: this.group.domain, identityId: member.id})
.then(() => {
this.refresh()
}).catch(() => {
})
},
tryDeleteItem(item) {
@ -201,8 +226,7 @@ export default {
}
},
mounted() {
this.refresh()
this.fetchItems()
this.refresh().then(() => this.fetchItems())
}
}
</script>

View file

@ -25,6 +25,7 @@
<script>
import {mapActions} from "vuex";
import BaseLayout from "@/components/BaseLayout.vue";
import {encodeHandleForUrl} from "@/router";
export default {
name: 'GroupNew',
@ -40,7 +41,7 @@ export default {
...mapActions(['createGroup']),
tryCreateGroup() {
this.createGroup({name: this.name}).then((group) => {
this.$router.push(`/groups/${group.id}`)
this.$router.push(`/groups/${encodeHandleForUrl(group.handle)}`)
}).catch(() => {
})
}

View file

@ -7,7 +7,7 @@
<div class="col-12 col-xl-6">
<div class="card">
<div class="card-header">
<h5 class="card-title">My Groups</h5>
<h5 class="card-title">Groups</h5>
</div>
<table class="table table-striped">
<thead>
@ -15,7 +15,7 @@
<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">
<a @click="refreshGroups" class="align-middle">
<b-icon-arrow-clockwise></b-icon-arrow-clockwise>
Refresh
</a>
@ -27,11 +27,16 @@
</tr>
</thead>
<tbody>
<tr v-for="group in groups" :key="group.id">
<tr v-for="group in allGroups" :key="group.key">
<td>
<router-link :to="`/groups/${group.id}`">{{ group.handle }}</router-link>
<router-link :to="`/groups/${encodeHandleForUrl(group.handle)}`">
{{ group.handle }}
</router-link>
</td>
<td class="d-none d-md-table-cell">{{ group.members.length }}</td>
<!-- GroupDetail.vue loads a foreign group's roster live by handle (see
store.js's fetchGroup) - this list just avoids an N-request roundtrip
per row, so a member count is only free for a group hosted here. -->
<td class="d-none d-md-table-cell">{{ group.hosted ? group.memberCount : '—' }}</td>
<td class="table-action"></td>
</tr>
</tbody>
@ -85,6 +90,7 @@
import {mapActions, mapState} from "vuex";
import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import {encodeHandleForUrl} from "@/router";
export default {
name: 'Groups',
@ -93,14 +99,31 @@ export default {
...BIcons
},
computed: {
...mapState(['groups', 'groupInvites']),
...mapState(['groups', 'groupInvites', 'groupMemberships']),
allGroups() {
const hosted = this.groups.map(group => ({
key: `hosted:${group.id}`, handle: group.handle, memberCount: group.members.length, hosted: true
}))
const hostedHandles = new Set(hosted.map(group => group.handle))
const foreign = this.groupMemberships
.filter(membership => !hostedHandles.has(membership.handle))
.map(membership => ({key: `foreign:${membership.id}`, handle: membership.handle, hosted: false}))
return [...hosted, ...foreign].sort((a, b) => a.handle.localeCompare(b.handle))
},
},
methods: {
...mapActions(['fetchGroups', 'fetchGroupInvites', 'acceptGroupInvite', 'declineGroupInvite']),
encodeHandleForUrl,
...mapActions(['fetchGroups', 'fetchGroupInvites', 'fetchGroupMemberships', 'acceptGroupInvite',
'declineGroupInvite']),
refreshGroups() {
this.fetchGroups()
this.fetchGroupMemberships()
},
tryAcceptInvite(invite) {
this.acceptGroupInvite(invite).then(() => {
this.fetchGroupInvites()
this.fetchGroups()
this.fetchGroupMemberships()
}).catch(() => {
})
},
@ -114,6 +137,7 @@ export default {
mounted() {
this.fetchGroups()
this.fetchGroupInvites()
this.fetchGroupMemberships()
}
}
</script>

View file

@ -150,11 +150,10 @@ export default {
if (owner_identity_id === undefined) return null
return shortenedRoute({kind: 'item', owner_identity_id, item_local_id: item.id})
},
// Routes to Print.vue with this item's raw identity rather than a pre-built link.
// See docs/implementation.md#print-link-shape-for-personal-items.
printLinkFor(item) {
if (!item.owner) return null
return {path: '/print', query: {kind: 'item', userHandle: item.owner, item: item.id}}
const userHandle = item.owner || item.owner_group
if (!userHandle) return null
return {path: '/print', query: {kind: 'item', userHandle, item: item.id}}
},
},
async mounted() {

View file

@ -46,7 +46,7 @@
Edit
</button>
<button type="submit" class="btn btn-danger"
@click="deleteInventoryItem(item).then(() => $router.push('/inventory'))">
@click="deleteInventoryItem(item).then(() => $router.push(ownerOverviewRoute(item)))">
<b-icon-trash></b-icon-trash>
Delete
</button>
@ -68,7 +68,7 @@ import * as BIcons from "bootstrap-icons-vue";
import BaseLayout from "@/components/BaseLayout.vue";
import {mapActions, mapGetters, mapState} from "vuex";
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
import {decodeHandleFromUrl} from "@/router";
import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
export default {
name: "InventoryDetail",
@ -94,23 +94,30 @@ export default {
},
computed: {
...mapGetters(["getNameFromHandle", "groupIdByHandle"]),
...mapState(["user"]),
...mapState(["user", "groupMemberships"]),
decodedHandle() {
return decodeHandleFromUrl(this.handle)
},
// Am I a member of this group, hosted here (groupIdByHandle, from idmap) or elsewhere
// (groupMemberships - see GroupMembership/docs/design-in-progress/groups-mvp.md)? Either
// way the backend actually hosting the group is what authorizes the write - this is just
// enough of a client-side hint to show/hide the buttons for it.
isGroupMember() {
return this.decodedHandle in this.groupIdByHandle
|| this.groupMemberships.some(m => m.handle === this.decodedHandle)
},
// Edit/Delete require actual authorization (own item or member group), not just view
// access - get_shared_item's friends_or_self() lets a friend view but never act.
canEdit() {
return this.decodedHandle === this.user || this.decodedHandle in this.groupIdByHandle
return this.decodedHandle === this.user || this.isGroupMember
},
// Printed labels only support a personal owner handle so far (see label.js's
// splitUserHandle); group items get no print link until that's supported too.
canPrint() {
return this.decodedHandle === this.user
return this.decodedHandle === this.user || this.isGroupMember
}
},
methods: {
...mapActions(["fetchItemByHandle", "deleteInventoryItem"]),
ownerOverviewRoute,
...mapActions(["fetchItemByHandle", "deleteInventoryItem", "fetchGroupMemberships"]),
async loadItem() {
this.item = await this.fetchItemByHandle({handle: this.decodedHandle, id: this.id}) || {}
}
@ -121,6 +128,9 @@ export default {
},
mounted() {
this.loadItem()
if (this.decodedHandle.startsWith('#')) {
this.fetchGroupMemberships()
}
}
}
</script>

View file

@ -57,7 +57,8 @@
</div>
<div class="mb-3">
<button type="submit" class="btn btn-primary" style="width: 100%"
@click="updateInventoryItem(item)">Update
@click="updateInventoryItem(item).then(updated => $router.push(ownerOverviewRoute(updated)))">
Update
</button>
</div>
</div>
@ -75,7 +76,7 @@ import BaseLayout from "@/components/BaseLayout.vue";
import TagField from "@/components/TagField.vue";
import PropertyField from "@/components/PropertyField.vue";
import CombinedFileField from "@/components/CombinedFileField.vue";
import {decodeHandleFromUrl} from "@/router";
import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
export default {
name: "InventoryEdit",
@ -120,6 +121,7 @@ export default {
}
},
methods: {
ownerOverviewRoute,
...mapActions(["fetchItemByHandle", "updateInventoryItem", "fetchInfo", "fetchStorageLocations"]),
changeFiles(files) {
this.item.files = files

View file

@ -66,7 +66,7 @@
</div>
<div class="mb-3">
<button type="submit" class="btn btn-primary" style="width: 100%"
@click="createInventoryItem(item).then(() => $router.push('/inventory'))">Add
@click="createInventoryItem(item).then(created => $router.push(ownerOverviewRoute(created)))">Add
</button>
</div>
</div>
@ -84,6 +84,7 @@ import BaseLayout from "@/components/BaseLayout.vue";
import TagField from "@/components/TagField.vue";
import PropertyField from "@/components/PropertyField.vue";
import CombinedFileField from "@/components/CombinedFileField.vue";
import {ownerOverviewRoute} from "@/router";
export default {
name: "InventoryNew",
@ -111,6 +112,7 @@ export default {
}
},
methods: {
ownerOverviewRoute,
...mapActions(['createInventoryItem', 'fetchInfo', 'fetchStorageLocations', 'fetchGroups'])
},
computed: {

View file

@ -337,10 +337,7 @@ export default {
};
},
computed: {
...mapGetters(["identityIdByHandle"]),
// Plain passthroughs of the form's base/derived vars, keeping the template from importing
// label-layouts.js just for these. SHORT_URL_VAR/SHORT_ID_VAR exclusion: see
// docs/implementation.md#calculated-short-link-fields.
...mapGetters(["identityIdByHandle", "groupIdByHandle"]),
baseVars() {
return BASE_VARS.filter(v => v !== SHORT_URL_VAR && v !== SHORT_ID_VAR);
},
@ -461,12 +458,17 @@ export default {
varLabel(v) {
return v.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, c => c.toUpperCase());
},
// Builds the bare short-id.js token for the current fields. See docs/implementation.md#shortid-resolution.
shortId(f) {
if (!f.userHandle) {
return null;
}
if (f.userHandle.startsWith("#")) {
const owner_group_id = this.groupIdByHandle[f.userHandle];
if (owner_group_id === undefined || !f.itemId) {
return null;
}
return shortenedRoute({kind: "group_item", owner_group_id, item_local_id: f.itemId}).slice(1);
}
const owner_identity_id = this.identityIdByHandle[f.userHandle];
if (owner_identity_id === undefined) {
return null;

View file

@ -203,4 +203,8 @@ export default {
.is-invalid .invalid-feedback {
display: block;
}
.visually-hidden {
display: none;
}
</style>

View file

@ -148,33 +148,14 @@ export default {
error: null,
insecureContext: window.isSecureContext,
insecureOrigin: `${window.location.protocol}//${window.location.hostname}`,
// One-shot: cleared by maybeVisitFirstMatch as soon as it navigates, so it doesn't
// keep firing router.push for every later scan/decode while left switched on.
// Defaults on when the page itself was opened with ?navigate=immediate (e.g. a link
// shared for a "scan and go" workflow), so the toggle doesn't need a manual flip first.
visitFirstMatch: this.$route.query.navigate === "immediate",
cameraLog: [],
};
},
methods: {
...mapActions(["fetchItemByHandle", "fetchGroup", "fetchStorageLocations", "fetchIdMap"]),
...mapActions(["fetchItemByHandle", "fetchStorageLocations", "fetchIdMap"]),
// Resolves entry.link into entry.description ("[#7] Cordless drill") for the non-URL
// formats classifyScanText recognizes - mutates the already-rendered entry in place once
// the lookup lands, rather than delaying the log/result list from showing the raw scanned
// text and link immediately. Left as `undefined` (template shows "resolving...") while in
// flight, and settles to a string or `null` (nothing else known to show, e.g. a workflow
// short id, or the owner/item genuinely couldn't be resolved).
//
// descriptionCache (keyed by the raw scanned text) memoizes the outcome - a still-in-frame
// code gets re-decoded and re-logged several times a second (see logDecode), and
// re-scanning the same printed label later is common too, so without this every repeat
// would re-fire the same fetchItemByHandle/fetchGroup/fetchStorageLocations/fetchIdMap
// round trip. Caching the in-flight promise itself (not just its settled value) also
// dedupes concurrent lookups for the same still-in-frame code, rather than firing one
// request per decode.
resolveDescription(entry) {
const {link, text} = entry;
if (!link || link.href) {
@ -185,10 +166,6 @@ export default {
return;
}
if (!this.descriptionCache.has(text)) {
// Wrapped as {error} rather than swallowed to null: a lookup can fail for very
// different reasons (not logged in, item not shared with this viewer, a genuine
// network error) and collapsing them all to "no description" made every one of
// them look identical to "nothing to show" - undiagnosable from the UI.
this.descriptionCache.set(text, this.describeLink(link).catch(e => ({error: e.message ?? String(e)})));
}
this.descriptionCache.get(text).then(result => {
@ -198,19 +175,12 @@ export default {
return;
}
entry.description = result;
// Only a truthy description confirms the target actually exists - a null/empty
// one (unresolvable, or a kind with no title lookup wired up) shouldn't count as
// a "match" to auto-visit.
if (result) {
this.maybeVisitFirstMatch(link);
}
});
},
// Sends the viewer straight to the first scan this session that's confirmed to resolve
// (immediately for a plain URL, or once resolveDescription confirms a real target for a
// token/handle) while the "Visit first match" toggle is on. One-shot: switches the toggle
// back off so it doesn't fire again for every later scan of the same or another code.
maybeVisitFirstMatch(link) {
if (!this.visitFirstMatch || !link?.to) {
return;
@ -242,8 +212,12 @@ export default {
return handle === undefined ? null : this.describeItem(handle, decoded.item_local_id);
}
if (decoded.kind === "group") {
const group = await this.fetchGroup({id: decoded.group_id});
return group ? group.handle : null;
let handle = this.$store.getters.groupHandleById[decoded.group_id];
if (handle === undefined) {
await this.fetchIdMap();
handle = this.$store.getters.groupHandleById[decoded.group_id];
}
return handle === undefined ? null : handle;
}
if (decoded.kind === "storage_location") {
if (!this.$store.state.storage_locations.length) {

View file

@ -5,6 +5,30 @@ import {existsSync} from 'node:fs'
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
// Vite's dev-server SPA fallback only rewrites a request to index.html when the last path
// segment has no dot (it sniffs for a file extension). A route whose only/last segment is a
// URL-encoded handle (e.g. /groups/+user@a.localhost, see encodeHandleForUrl in src/router.js)
// legitimately contains a dot from the domain part, so a direct load/refresh 404s there unless
// we pre-empt that check ourselves. Gating on both a text/html Accept header and an '@' in the
// last segment keeps this from ever matching real asset requests or vite's own internal
// @fs/@id/@vite module paths (which never carry an html Accept header).
function handleUrlFallback() {
return {
name: 'handle-url-fallback',
configureServer(server) {
server.middlewares.use((req, res, next) => {
const accept = req.headers.accept || ''
const pathname = (req.url || '').split('?')[0]
const lastSegment = pathname.slice(pathname.lastIndexOf('/') + 1)
if (req.method === 'GET' && accept.includes('text/html') && lastSegment.includes('@')) {
req.url = '/index.html'
}
next()
})
}
}
}
function gitCommit() {
// deploy/dev/docker-compose.yml bind-mounts the repo's real .git dir at
// /git (separate from /app, which only has the frontend/ subtree) -
@ -23,7 +47,7 @@ function gitCommit() {
}
export default defineConfig({
plugins: [vue()],
plugins: [vue(), handleUrlFallback()],
define: {
__GIT_COMMIT__: JSON.stringify(gitCommit())
},
@ -44,6 +68,10 @@ export default defineConfig({
//'Upgrade-Insecure-Requests': '1',
'Content-Security-Policy': 'default-src \'self\';'
+ ' script-src \'self\' \'wasm-unsafe-eval\' \'unsafe-eval\' \'unsafe-inline\';'
// Without this, worker-src falls back to script-src, which has no blob: source -
// CameraScanner.vue's decode Worker (built from a Blob URL) would be silently
// blocked by CSP rather than the app's own capability checks.
+ ' worker-src \'self\' blob:;'
+ ' style-src \'self\' \'unsafe-inline\';'
+ ' img-src \'self\' * data: blob:;'
+ ' connect-src * data:',