diff --git a/backend/authentication/admin.py b/backend/authentication/admin.py index 50dfdbe..3aa5e5f 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, GroupMembership + AccountPreference, Group, GroupInvite, GroupInviteIncoming class ToolshedUserAdmin(admin.ModelAdmin): @@ -48,11 +48,6 @@ 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) @@ -61,4 +56,3 @@ 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 deleted file mode 100644 index 1c764be..0000000 --- a/backend/authentication/migrations/0006_groupmembership.py +++ /dev/null @@ -1,28 +0,0 @@ -# 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 fe27068..a19e759 100644 --- a/backend/authentication/models.py +++ b/backend/authentication/models.py @@ -180,23 +180,3 @@ 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 757d871..561e3d2 100644 --- a/backend/toolshed/api/group.py +++ b/backend/toolshed/api/group.py @@ -9,12 +9,11 @@ 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, GroupMembership, KnownIdentity, \ - ToolshedUser +from authentication.models import Group, GroupInvite, GroupInviteIncoming, 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, GroupMembershipSerializer +from toolshed.serializers import GroupSerializer, GroupInviteIncomingSerializer class Groups(APIView, ViewSetMixin): @@ -127,34 +126,6 @@ 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') @@ -190,7 +161,5 @@ 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 0bfe6b0..369222f 100644 --- a/backend/toolshed/serializers.py +++ b/backend/toolshed/serializers.py @@ -1,7 +1,6 @@ from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers -from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming, Group, GroupInviteIncoming, \ - GroupMembership +from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming, Group, GroupInviteIncoming from authentication.serializers import OwnerSerializer, GroupOwnerSerializer from files.models import File from files.serializers import FileSerializer @@ -120,17 +119,6 @@ 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 d6074b0..b2542de 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, GroupMembership, KnownIdentity +from authentication.models import Group, GroupInvite, GroupInviteIncoming, KnownIdentity client = SignatureAuthClient() @@ -124,13 +124,9 @@ class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase): self.assertTrue(group.is_member(invitee.public_identity)) self.assertEqual(GroupInvite.objects.count(), 0) - reply5 = client.post('/api/groupinvites/{}/accept/'.format(incoming.id), invitee) - self.assertEqual(reply5.status_code, 201) - self.assertEqual(reply5.json()['handle'], str(group)) + reply5 = client.delete('/api/groupinvites/{}/'.format(incoming.id), invitee) + self.assertEqual(reply5.status_code, 204) 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'] @@ -193,69 +189,3 @@ 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/assets/fonts/pixel/PICO-8.ttf b/frontend/src/assets/fonts/pixel/PICO-8.ttf new file mode 100644 index 0000000..7085f45 Binary files /dev/null and b/frontend/src/assets/fonts/pixel/PICO-8.ttf differ diff --git a/frontend/src/assets/fonts/pixel/Silkscreen-Regular.woff2 b/frontend/src/assets/fonts/pixel/Silkscreen-Regular.woff2 new file mode 100644 index 0000000..285a17a Binary files /dev/null and b/frontend/src/assets/fonts/pixel/Silkscreen-Regular.woff2 differ diff --git a/frontend/src/assets/fonts/pixel/TomThumb.ttf b/frontend/src/assets/fonts/pixel/TomThumb.ttf new file mode 100644 index 0000000..1e85fd9 Binary files /dev/null and b/frontend/src/assets/fonts/pixel/TomThumb.ttf differ diff --git a/frontend/src/label-layouts.js b/frontend/src/label-layouts.js index 7d8fed1..84dc502 100644 --- a/frontend/src/label-layouts.js +++ b/frontend/src/label-layouts.js @@ -1,5 +1,3 @@ -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"}; @@ -250,7 +248,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/${encodeHandleForUrl(f.userHandle)}/${f.itemId}`, + calc: (f) => `${f.webdomain}/i/${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. @@ -309,6 +307,11 @@ 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 b37ece4..9198495 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -51,25 +51,18 @@ 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}) => { - const handle = store.getters.groupHandleById[group_id]; - return handle ? `/groups/${encodeHandleForUrl(handle)}` : null; - }, + group: ({group_id}) => `/groups/${group_id}`, 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', 'group']); +export const NEEDS_IDMAP = new Set(['item', 'group_item']); export function expandedRoute({kind, ...fields}) { const buildRoute = EXPANDED_ROUTE_BUILDERS[kind]; @@ -124,7 +117,7 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, { component: GroupNew, meta: {requiresAuth: true} }, { - path: '/groups/:handle', + path: '/groups/:id', component: GroupDetail, meta: {requiresAuth: true}, props: true diff --git a/frontend/src/store.js b/frontend/src/store.js index 162b980..585e156 100644 --- a/frontend/src/store.js +++ b/frontend/src/store.js @@ -8,11 +8,6 @@ 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', @@ -79,7 +74,6 @@ export default createStore({ friendProfiles: {}, groups: [], groupInvites: [], - groupMemberships: [], idmap: {identities: [], groups: []}, idmapLoaded: false, item_map: {}, @@ -120,9 +114,6 @@ export default createStore({ setGroupInvites(state, invites) { state.groupInvites = invites; }, - setGroupMemberships(state, memberships) { - state.groupMemberships = memberships; - }, setIdMap(state, idmap) { state.idmap = idmap; state.idmapLoaded = true; @@ -403,22 +394,14 @@ 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 = item.owner_group - ? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain}) - : await dispatch('getHomeServers') + const servers = 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 = item.owner_group - ? await dispatch('getFriendServers', {username: 'x@' + splitGroupHandle(item.owner_group).domain}) - : await dispatch('getHomeServers') + const servers = await dispatch('getHomeServers') const ret = await servers.delete(getters.signAuth, '/api/inventory_items/' + item.id + '/') dispatch('fetchInventoryItems') return ret @@ -465,21 +448,19 @@ export default createStore({ return null; } }, - // 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. + // 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. async fetchItemByHandle({dispatch, getters}, {handle, id}) { if (handle.startsWith('#')) { - 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; + // 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 } + const items = await dispatch('fetchGroupInventoryItems', groupId) + return items.find(item => item.id === parseInt(id)) || null } return dispatch('fetchForeignItem', {owner: handle, id}) }, @@ -530,14 +511,9 @@ export default createStore({ const servers = await dispatch('getHomeServers') return await servers.delete(getters.signAuth, '/api/friends/' + id + '/') }, - // 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. + // 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. async fetchGroups({commit, dispatch, getters}) { const servers = await dispatch('getHomeServers') const data = await servers.get(getters.signAuth, '/api/groups/') @@ -550,18 +526,16 @@ export default createStore({ commit('setIdMap', idmap) return idmap }, - // 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 fetchGroup({dispatch, getters}, {id}) { + const servers = await dispatch('getHomeServers') + return await servers.get(getters.signAuth, '/api/groups/' + id + '/') }, async createGroup({dispatch, getters}, {name}) { const servers = await dispatch('getHomeServers') return await servers.post(getters.signAuth, '/api/groups/', {name}) }, - async removeGroupMember({dispatch, getters}, {groupId, domain, identityId}) { - const servers = await dispatch('getFriendServers', {username: 'x@' + domain}) + async removeGroupMember({dispatch, getters}, {groupId, identityId}) { + const servers = await dispatch('getHomeServers') return await servers.delete(getters.signAuth, '/api/groups/' + groupId + '/members/' + identityId + '/') }, async fetchGroupInvites({commit, dispatch, getters}) { @@ -570,17 +544,11 @@ 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, domain, groupHandle, invitee}) { - const group_servers = await dispatch('getFriendServers', {username: 'x@' + domain}) - const group_reply = await group_servers.post( + async inviteToGroup({state, dispatch, getters}, {groupId, groupHandle, invitee}) { + const home_servers = await dispatch('getHomeServers') + const home_reply = await home_servers.post( getters.signAuth, '/api/groups/' + groupId + '/invites/', {invitee}) - if (!group_reply.secret) { + if (!home_reply.secret) { return false } const invitee_servers = await dispatch('getFriendServers', {username: invitee}) @@ -589,7 +557,7 @@ export default createStore({ inviter: state.user, inviter_key: nacl.to_hex(state.keypair.signPk), invitee, - secret: group_reply.secret + secret: home_reply.secret }) return true }, @@ -603,20 +571,17 @@ export default createStore({ secret: invite.secret }) const home_servers = await dispatch('getHomeServers') - await home_servers.post(getters.signAuth, '/api/groupinvites/' + invite.id + '/accept/', {}) + await home_servers.delete(getters.signAuth, '/api/groupinvites/' + invite.id + '/') return true }, async declineGroupInvite({dispatch, getters}, invite) { const servers = await dispatch('getHomeServers') return await servers.delete(getters.signAuth, '/api/groupinvites/' + invite.id + '/') }, - async fetchGroupInventoryItems({commit, dispatch, getters}, {groupId, domain}) { - const servers = await dispatch('getFriendServers', {username: 'x@' + domain}) + async fetchGroupInventoryItems({commit, dispatch, getters}, groupId) { + const servers = await dispatch('getHomeServers') const items = await servers.get(getters.signAuth, '/api/inventory_items/?group=' + groupId) - // 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}) + commit('setInventoryItems', {url: '/group/' + groupId, items}) return items }, async fetchFiles({state, commit, dispatch, getters}) { @@ -848,7 +813,7 @@ export default createStore({ inventory_items(state) { return state.item_map['/'] || [] }, - groupInventoryItems: (state) => (groupId, domain) => state.item_map['/group/' + domain + '/' + groupId] || [], + groupInventoryItems: (state) => (groupId) => state.item_map['/group/' + 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 21eaaa9..181b089 100644 --- a/frontend/src/views/GroupDetail.vue +++ b/frontend/src/views/GroupDetail.vue @@ -86,9 +86,6 @@ - - - @@ -114,10 +111,6 @@ Edit - - - @@ -127,9 +120,7 @@
- Add - + Add
@@ -143,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 {decodeHandleFromUrl, encodeHandleForUrl} from "@/router"; +import {encodeHandleForUrl} from "@/router"; export default { name: 'GroupDetail', @@ -153,8 +144,8 @@ export default { ...BIcons }, props: { - handle: { - type: String, + id: { + type: [String, Number], required: true } }, @@ -168,55 +159,39 @@ export default { }, computed: { ...mapState(['user']), - decodedHandle() { - return decodeHandleFromUrl(this.handle) - }, items() { - return this.group ? this.groupInventoryItems(this.group.id, this.group.domain) : [] - }, - isOwnDomain() { - return this.group && this.group.domain === this.user.split('@')[1] + return this.groupInventoryItems(this.id) }, ...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() { - return this.fetchGroup({handle: this.decodedHandle}).then((group) => { + this.fetchGroup({id: this.id}).then((group) => { this.group = group }) }, fetchItems() { - if (this.group) { - this.fetchGroupInventoryItems({groupId: this.group.id, domain: this.group.domain}) - } + this.fetchGroupInventoryItems(this.id) }, 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.group.id, domain: this.group.domain, groupHandle: this.group.handle, - invitee: this.invitee - }).then((ok) => { - if (ok) { - this.show_invite = false - this.invitee = "" - } - }).catch(() => { + this.inviteToGroup({groupId: this.id, groupHandle: this.group.handle, invitee: this.invitee}) + .then((ok) => { + if (ok) { + this.show_invite = false + this.invitee = "" + } + }).catch(() => { }) }, tryRemoveMember(member) { - this.removeGroupMember({groupId: this.group.id, domain: this.group.domain, identityId: member.id}) - .then(() => { - this.refresh() - }).catch(() => { + this.removeGroupMember({groupId: this.id, identityId: member.id}).then(() => { + this.refresh() + }).catch(() => { }) }, tryDeleteItem(item) { @@ -226,7 +201,8 @@ export default { } }, mounted() { - this.refresh().then(() => this.fetchItems()) + this.refresh() + this.fetchItems() } } diff --git a/frontend/src/views/GroupNew.vue b/frontend/src/views/GroupNew.vue index 86415d4..3a3be87 100644 --- a/frontend/src/views/GroupNew.vue +++ b/frontend/src/views/GroupNew.vue @@ -25,7 +25,6 @@ diff --git a/frontend/src/views/Inventory.vue b/frontend/src/views/Inventory.vue index 15b8727..5adca48 100644 --- a/frontend/src/views/Inventory.vue +++ b/frontend/src/views/Inventory.vue @@ -150,10 +150,11 @@ 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) { - const userHandle = item.owner || item.owner_group - if (!userHandle) return null - return {path: '/print', query: {kind: 'item', userHandle, item: item.id}} + if (!item.owner) return null + return {path: '/print', query: {kind: 'item', userHandle: item.owner, item: item.id}} }, }, async mounted() { diff --git a/frontend/src/views/InventoryDetail.vue b/frontend/src/views/InventoryDetail.vue index 3d9d847..0685771 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, ownerOverviewRoute} from "@/router"; +import {decodeHandleFromUrl} from "@/router"; export default { name: "InventoryDetail", @@ -94,30 +94,23 @@ export default { }, computed: { ...mapGetters(["getNameFromHandle", "groupIdByHandle"]), - ...mapState(["user", "groupMemberships"]), + ...mapState(["user"]), 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.isGroupMember + return this.decodedHandle === this.user || this.decodedHandle in this.groupIdByHandle }, + // 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 || this.isGroupMember + return this.decodedHandle === this.user } }, methods: { - ownerOverviewRoute, - ...mapActions(["fetchItemByHandle", "deleteInventoryItem", "fetchGroupMemberships"]), + ...mapActions(["fetchItemByHandle", "deleteInventoryItem"]), async loadItem() { this.item = await this.fetchItemByHandle({handle: this.decodedHandle, id: this.id}) || {} } @@ -128,9 +121,6 @@ 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 d539c84..da2d7d2 100644 --- a/frontend/src/views/InventoryEdit.vue +++ b/frontend/src/views/InventoryEdit.vue @@ -57,8 +57,7 @@
@@ -76,7 +75,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, ownerOverviewRoute} from "@/router"; +import {decodeHandleFromUrl} from "@/router"; export default { name: "InventoryEdit", @@ -121,7 +120,6 @@ 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 3c1065e..0287d0e 100644 --- a/frontend/src/views/InventoryNew.vue +++ b/frontend/src/views/InventoryNew.vue @@ -66,7 +66,7 @@
@@ -84,7 +84,6 @@ 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", @@ -112,7 +111,6 @@ 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 bca75a7..c4ad0d4 100644 --- a/frontend/src/views/Print.vue +++ b/frontend/src/views/Print.vue @@ -337,7 +337,10 @@ export default { }; }, computed: { - ...mapGetters(["identityIdByHandle", "groupIdByHandle"]), + ...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. baseVars() { return BASE_VARS.filter(v => v !== SHORT_URL_VAR && v !== SHORT_ID_VAR); }, @@ -458,17 +461,12 @@ 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/Register.vue b/frontend/src/views/Register.vue index ef4cfb2..363dd72 100644 --- a/frontend/src/views/Register.vue +++ b/frontend/src/views/Register.vue @@ -203,8 +203,4 @@ export default { .is-invalid .invalid-feedback { display: block; } - -.visually-hidden { - display: none; -} \ No newline at end of file diff --git a/frontend/src/views/Scan.vue b/frontend/src/views/Scan.vue index 918bf92..51af3bd 100644 --- a/frontend/src/views/Scan.vue +++ b/frontend/src/views/Scan.vue @@ -148,14 +148,33 @@ 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", "fetchStorageLocations", "fetchIdMap"]), + ...mapActions(["fetchItemByHandle", "fetchGroup", "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) { @@ -166,6 +185,10 @@ 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 => { @@ -175,12 +198,19 @@ 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; @@ -212,12 +242,8 @@ export default { return handle === undefined ? null : this.describeItem(handle, decoded.item_local_id); } if (decoded.kind === "group") { - 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; + const group = await this.fetchGroup({id: decoded.group_id}); + return group ? group.handle : null; } if (decoded.kind === "storage_location") { if (!this.$store.state.storage_locations.length) { diff --git a/frontend/vite.config.js b/frontend/vite.config.js index e74ebf9..6d7fd0f 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -5,30 +5,6 @@ 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) - @@ -47,7 +23,7 @@ function gitCommit() { } export default defineConfig({ - plugins: [vue(), handleUrlFallback()], + plugins: [vue()], define: { __GIT_COMMIT__: JSON.stringify(gitCommit()) }, @@ -68,10 +44,6 @@ 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:',