From 582853ab27a85f349445bb945de2c8998ee3d414 Mon Sep 17 00:00:00 2001 From: jedi Date: Wed, 26 Aug 2026 18:17:12 +0200 Subject: [PATCH 1/4] stash --- frontend/src/views/Register.vue | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/frontend/src/views/Register.vue b/frontend/src/views/Register.vue index 363dd72..ef4cfb2 100644 --- a/frontend/src/views/Register.vue +++ b/frontend/src/views/Register.vue @@ -203,4 +203,8 @@ export default { .is-invalid .invalid-feedback { display: block; } + +.visually-hidden { + display: none; +} \ No newline at end of file From 9ce700c38804927306ccb6612016f06f79ae8bc3 Mon Sep 17 00:00:00 2001 From: jedi Date: Wed, 26 Aug 2026 18:33:31 +0200 Subject: [PATCH 2/4] stash --- backend/authentication/admin.py | 8 +- .../migrations/0006_groupmembership.py | 28 +++++++ backend/authentication/models.py | 20 +++++ backend/toolshed/api/group.py | 35 ++++++++- backend/toolshed/serializers.py | 14 +++- backend/toolshed/tests/test_group.py | 76 ++++++++++++++++++- frontend/src/store.js | 12 ++- 7 files changed, 185 insertions(+), 8 deletions(-) create mode 100644 backend/authentication/migrations/0006_groupmembership.py diff --git a/backend/authentication/admin.py b/backend/authentication/admin.py index 3aa5e5f..50dfdbe 100644 --- a/backend/authentication/admin.py +++ b/backend/authentication/admin.py @@ -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) diff --git a/backend/authentication/migrations/0006_groupmembership.py b/backend/authentication/migrations/0006_groupmembership.py new file mode 100644 index 0000000..1c764be --- /dev/null +++ b/backend/authentication/migrations/0006_groupmembership.py @@ -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')}, + }, + ), + ] diff --git a/backend/authentication/models.py b/backend/authentication/models.py index a19e759..fe27068 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -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}" diff --git a/backend/toolshed/api/group.py b/backend/toolshed/api/group.py index 561e3d2..757d871 100644 --- a/backend/toolshed/api/group.py +++ b/backend/toolshed/api/group.py @@ -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// 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//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//invites/', createGroupInvite, name='create_group_invite'), path('groupinvites/', GroupInvitesIncoming.as_view(), name='group_invites_incoming'), path('groupinvites//', declineGroupInvite, name='decline_group_invite'), + path('groupinvites//accept/', recordGroupMembership, name='record_group_membership'), path('group_invites/accept/', acceptGroupInvite, name='accept_group_invite'), + path('groupmemberships/', GroupMemberships.as_view(), name='group_memberships'), ] diff --git a/backend/toolshed/serializers.py b/backend/toolshed/serializers.py index 369222f..0bfe6b0 100644 --- a/backend/toolshed/serializers.py +++ b/backend/toolshed/serializers.py @@ -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() diff --git a/backend/toolshed/tests/test_group.py b/backend/toolshed/tests/test_group.py index b2542de..d6074b0 100644 --- a/backend/toolshed/tests/test_group.py +++ b/backend/toolshed/tests/test_group.py @@ -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) diff --git a/frontend/src/store.js b/frontend/src/store.js index 585e156..e9e725e 100644 --- a/frontend/src/store.js +++ b/frontend/src/store.js @@ -74,6 +74,7 @@ export default createStore({ friendProfiles: {}, groups: [], groupInvites: [], + groupMemberships: [], idmap: {identities: [], groups: []}, idmapLoaded: false, item_map: {}, @@ -114,6 +115,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; @@ -544,6 +548,12 @@ export default createStore({ commit('setGroupInvites', data) return data }, + 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, groupHandle, invitee}) { const home_servers = await dispatch('getHomeServers') const home_reply = await home_servers.post( @@ -571,7 +581,7 @@ 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) { From 35be834799760b893beadecf25853be7cc555afb Mon Sep 17 00:00:00 2001 From: jedi Date: Wed, 26 Aug 2026 18:37:06 +0200 Subject: [PATCH 3/4] stash --- frontend/src/assets/fonts/pixel/PICO-8.ttf | Bin 11248 -> 0 bytes .../fonts/pixel/Silkscreen-Regular.woff2 | Bin 8404 -> 0 bytes frontend/src/assets/fonts/pixel/TomThumb.ttf | Bin 10390 -> 0 bytes frontend/src/router.js | 9 ++-- frontend/src/views/GroupDetail.vue | 24 +++++++--- frontend/src/views/GroupNew.vue | 3 +- frontend/src/views/Groups.vue | 41 ++++++++++++++++-- frontend/vite.config.js | 30 ++++++++++++- 8 files changed, 92 insertions(+), 15 deletions(-) delete mode 100644 frontend/src/assets/fonts/pixel/PICO-8.ttf delete mode 100644 frontend/src/assets/fonts/pixel/Silkscreen-Regular.woff2 delete mode 100644 frontend/src/assets/fonts/pixel/TomThumb.ttf diff --git a/frontend/src/assets/fonts/pixel/PICO-8.ttf b/frontend/src/assets/fonts/pixel/PICO-8.ttf deleted file mode 100644 index 7085f45f5216b25270db6627d6d74d2248828524..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11248 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 diff --git a/frontend/src/assets/fonts/pixel/Silkscreen-Regular.woff2 b/frontend/src/assets/fonts/pixel/Silkscreen-Regular.woff2 deleted file mode 100644 index 285a17af999975dd4bcef9af0027429b2f0b5999..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8404 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 diff --git a/frontend/src/assets/fonts/pixel/TomThumb.ttf b/frontend/src/assets/fonts/pixel/TomThumb.ttf deleted file mode 100644 index 1e85fd900dea711efcd248b59c9c7d5f8f8c4783..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10390 zcmeHNPl#Pt8UOCQnaNB#lcY&(^RIBLK`f*(7?Gk~G?}7Z2$m)bQwruKnPdX*%^M~! zX#x!d?4n{p1viDR#6<)bf-VF%T@{Z~oeB`nzUN++A91UwHXvfBh5I{u1=HB?zX9jW+lXK!=yQ{p%gu2DW$u zbgR=l-?pi%@0(5D#rXAZ`}&Gqw8y{$-^%57ck$hgYYR|4Vm9%UmEM(p@wVMD`{FyC zH`)sN;Pu;kzqj+*f0*rp!0feez4uYxw}RAWY$9j`ox(nWbP`nXlSp|x8zg6KuT2H< zL_X<~)*p)>V|H=|z-DXsWDmO2I?ffuj%@(_%!zLwwdbs~n{Gp4(_9wTY`8+{LyAZ( zd=jlrE-x^j=EUbZlGXL|G1rf?F4y^Q@Z;E&`}%2Z5*9+roOAoVApvF|BLRO2p`HwB zxw*k~l^Yuye+y#_{)gLV*|*`eJOj}R2zONhzW9x&d*U$qKImPf{yET_Ao3sLgw26o z0o?}OGn;x5gu7@O@@ZgAzh}0s1cBcMeg^O}STln)vnb2iTOizZ+n)#h-0YD1<6b}WHlVRUQ~ED?fZCq&7$tM{~9`>W^kC+@?z(U(1q`$2&^8`#(Tjan}`76l*1 z=mAv4FlCI+1@E|zY?g;EVFo!%;tU#;)`J$45|WV3hKwP&tPx(h_@J)?d&++KRb={c z4?Kmv#WA3=nycZ-I^hVH2qW;2i?!Uo|5z@vI8}SohD;9j0rrNp=7-y_mDF-gaq78( zgci=m1f$h>78MDdL#hyk>hvGkia87nWXxY!fi-^4A5?1;Ur4kjqIyj zr?Wma939>W9s@^W3>=3R1uM{3zG6DoOyey@B#$C*1#0_M8n{Z5g^DxP1@8!qF4 zF@^w$b%RMg)rp#_gw_Si(%LP=T`?Lz?(&Jf`=yBFSenJ6`a9i2@0=7VomQZ@kLa0M zW|Tc!@IBRZ!&}PWX?2^GZz0GN)}8JaunCvT;K@&CKxd);-U#f2ymZ>>&h2pu*;U@`TJM9_PmIfs_5RQvQ3--SqS$ zCdZtqPHM8h;a!_6g_qt9AgpX94y%IwR=y)w6oMWeJ@gUHYpLNTQygWFvR^+-#3iPz zCMacuukNwNNV<;na&IN`n)SL!?$j!GLZ(ToVfJjC%WApdjC;*< zo#LHxAY{dwW4^CMbod|7NCR#QJBUL|7ApzubQ`Jf9*WEC39*d+;}6O9PX#0V z^NmjwI03t&d;)jz{>bxWl#!prcZE+yej0x)|9a$SZHJwV{C4|-y%_mK})@Z{>{ z_HudZ)y00Ny?o(Bd2Mm^M%ll-eB(&j?Jf7NthCQB9x2aV?&JTh%8R|_etpXq7CW8Z zleU1LV{pG`tN7&TX@u?!_~UjIGp8}r#ptvx*;R1o>?K>YOW-@!hR*Q(u%v9%Nr)$b za~WvM*yohJ3jBURU$zT&0;GZzsb{=aeJ%<&2%(Wpc j@sD6!(|@R!FF?D4pPoI*Bg8|DR=mak6Z?PrY((JSz*Q#I diff --git a/frontend/src/router.js b/frontend/src/router.js index 9198495..c0dc882 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -56,13 +56,16 @@ const EXPANDED_ROUTE_BUILDERS = { 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 +120,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 diff --git a/frontend/src/views/GroupDetail.vue b/frontend/src/views/GroupDetail.vue index 181b089..448f639 100644 --- a/frontend/src/views/GroupDetail.vue +++ b/frontend/src/views/GroupDetail.vue @@ -134,7 +134,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 +144,8 @@ export default { ...BIcons }, props: { - id: { - type: [String, Number], + handle: { + type: String, required: true } }, @@ -159,14 +159,17 @@ export default { }, computed: { ...mapState(['user']), + id() { + return this.groupIdByHandle[decodeHandleFromUrl(this.handle)] + }, items() { return this.groupInventoryItems(this.id) }, - ...mapGetters(['groupInventoryItems']) + ...mapGetters(['groupInventoryItems', 'groupIdByHandle']) }, methods: { ...mapActions(['fetchGroup', 'removeGroupMember', 'inviteToGroup', 'fetchGroupInventoryItems', - 'deleteInventoryItem']), + 'deleteInventoryItem', 'fetchIdMap']), refresh() { this.fetchGroup({id: this.id}).then((group) => { this.group = group @@ -201,8 +204,15 @@ export default { } }, mounted() { - this.refresh() - this.fetchItems() + if (this.id === undefined) { + this.fetchIdMap().then(() => { + this.refresh() + this.fetchItems() + }) + } else { + this.refresh() + this.fetchItems() + } } } diff --git a/frontend/src/views/GroupNew.vue b/frontend/src/views/GroupNew.vue index 3a3be87..86415d4 100644 --- a/frontend/src/views/GroupNew.vue +++ b/frontend/src/views/GroupNew.vue @@ -25,6 +25,7 @@ diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 6d7fd0f..e74ebf9 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -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:', From 8de6ae8d8beebf42f014f4c01555ced2cd4ee081 Mon Sep 17 00:00:00 2001 From: jedi Date: Wed, 26 Aug 2026 20:40:23 +0200 Subject: [PATCH 4/4] stash --- frontend/src/label-layouts.js | 9 +-- frontend/src/router.js | 4 ++ frontend/src/store.js | 83 +++++++++++++++++--------- frontend/src/views/GroupDetail.vue | 68 ++++++++++++--------- frontend/src/views/Groups.vue | 57 +++++++----------- frontend/src/views/Inventory.vue | 7 +-- frontend/src/views/InventoryDetail.vue | 26 +++++--- frontend/src/views/InventoryEdit.vue | 6 +- frontend/src/views/InventoryNew.vue | 4 +- frontend/src/views/Print.vue | 14 +++-- frontend/src/views/Scan.vue | 40 +++---------- 11 files changed, 168 insertions(+), 150 deletions(-) diff --git a/frontend/src/label-layouts.js b/frontend/src/label-layouts.js index 84dc502..7d8fed1 100644 --- a/frontend/src/label-layouts.js +++ b/frontend/src/label-layouts.js @@ -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."); diff --git a/frontend/src/router.js b/frontend/src/router.js index c0dc882..b37ece4 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -51,6 +51,10 @@ 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), diff --git a/frontend/src/store.js b/frontend/src/store.js index e9e725e..162b980 100644 --- a/frontend/src/store.js +++ b/frontend/src/store.js @@ -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', @@ -398,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 @@ -452,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}) }, @@ -515,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/') @@ -530,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}) { @@ -554,11 +576,11 @@ export default createStore({ commit('setGroupMemberships', 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 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}) @@ -567,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 }, @@ -588,10 +610,13 @@ export default createStore({ 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}) { @@ -823,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])) }, diff --git a/frontend/src/views/GroupDetail.vue b/frontend/src/views/GroupDetail.vue index 448f639..21eaaa9 100644 --- a/frontend/src/views/GroupDetail.vue +++ b/frontend/src/views/GroupDetail.vue @@ -86,6 +86,9 @@ + + + @@ -111,6 +114,10 @@ Edit + + + @@ -120,7 +127,9 @@
- Add + Add +
@@ -159,42 +168,55 @@ export default { }, computed: { ...mapState(['user']), - id() { - return this.groupIdByHandle[decodeHandleFromUrl(this.handle)] + decodedHandle() { + return decodeHandleFromUrl(this.handle) }, items() { - return this.groupInventoryItems(this.id) + return this.group ? this.groupInventoryItems(this.group.id, this.group.domain) : [] }, - ...mapGetters(['groupInventoryItems', 'groupIdByHandle']) + isOwnDomain() { + return this.group && this.group.domain === this.user.split('@')[1] + }, + ...mapGetters(['groupInventoryItems']) }, methods: { ...mapActions(['fetchGroup', 'removeGroupMember', 'inviteToGroup', 'fetchGroupInventoryItems', - 'deleteInventoryItem', 'fetchIdMap']), + '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) { @@ -204,15 +226,7 @@ export default { } }, mounted() { - if (this.id === undefined) { - this.fetchIdMap().then(() => { - this.refresh() - this.fetchItems() - }) - } else { - this.refresh() - this.fetchItems() - } + this.refresh().then(() => this.fetchItems()) } } diff --git a/frontend/src/views/Groups.vue b/frontend/src/views/Groups.vue index f8fbc61..e2bd0d2 100644 --- a/frontend/src/views/Groups.vue +++ b/frontend/src/views/Groups.vue @@ -7,7 +7,7 @@
-
My Groups
+
Groups
@@ -15,7 +15,7 @@ - + - - - - -
Name Members - + Refresh @@ -27,37 +27,16 @@
- {{ group.handle }} + + {{ group.handle }} + {{ group.members.length }}
-
-
-
-
-
-
Other groups you're a member of
-
- - - - - - - - - - + + @@ -121,15 +100,25 @@ export default { }, computed: { ...mapState(['groups', 'groupInvites', 'groupMemberships']), - foreignGroupMemberships() { - const hostedHandles = new Set(this.groups.map(group => group.handle)) - return this.groupMemberships.filter(membership => !hostedHandles.has(membership.handle)) + 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: { encodeHandleForUrl, ...mapActions(['fetchGroups', 'fetchGroupInvites', 'fetchGroupMemberships', 'acceptGroupInvite', 'declineGroupInvite']), + refreshGroups() { + this.fetchGroups() + this.fetchGroupMemberships() + }, tryAcceptInvite(invite) { this.acceptGroupInvite(invite).then(() => { this.fetchGroupInvites() diff --git a/frontend/src/views/Inventory.vue b/frontend/src/views/Inventory.vue index 5adca48..15b8727 100644 --- a/frontend/src/views/Inventory.vue +++ b/frontend/src/views/Inventory.vue @@ -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() { diff --git a/frontend/src/views/InventoryDetail.vue b/frontend/src/views/InventoryDetail.vue index 0685771..3d9d847 100644 --- a/frontend/src/views/InventoryDetail.vue +++ b/frontend/src/views/InventoryDetail.vue @@ -46,7 +46,7 @@ Edit @@ -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() + } } } diff --git a/frontend/src/views/InventoryEdit.vue b/frontend/src/views/InventoryEdit.vue index da2d7d2..d539c84 100644 --- a/frontend/src/views/InventoryEdit.vue +++ b/frontend/src/views/InventoryEdit.vue @@ -57,7 +57,8 @@
@@ -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 diff --git a/frontend/src/views/InventoryNew.vue b/frontend/src/views/InventoryNew.vue index 0287d0e..3c1065e 100644 --- a/frontend/src/views/InventoryNew.vue +++ b/frontend/src/views/InventoryNew.vue @@ -66,7 +66,7 @@
@@ -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: { diff --git a/frontend/src/views/Print.vue b/frontend/src/views/Print.vue index c4ad0d4..bca75a7 100644 --- a/frontend/src/views/Print.vue +++ b/frontend/src/views/Print.vue @@ -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; diff --git a/frontend/src/views/Scan.vue b/frontend/src/views/Scan.vue index 51af3bd..918bf92 100644 --- a/frontend/src/views/Scan.vue +++ b/frontend/src/views/Scan.vue @@ -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) {
Group - - - Refresh - -
{{ membership.handle }}{{ group.hosted ? group.memberCount : '—' }}