Compare commits
4 commits
7c2db40eab
...
8de6ae8d8b
| Author | SHA1 | Date | |
|---|---|---|---|
| 8de6ae8d8b | |||
| 35be834799 | |||
| 9ce700c388 | |||
| 582853ab27 |
23 changed files with 404 additions and 128 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
from authentication.models import ToolshedUser, KnownIdentity, FriendRequestOutgoing, FriendRequestIncoming, \
|
from authentication.models import ToolshedUser, KnownIdentity, FriendRequestOutgoing, FriendRequestIncoming, \
|
||||||
AccountPreference, Group, GroupInvite, GroupInviteIncoming
|
AccountPreference, Group, GroupInvite, GroupInviteIncoming, GroupMembership
|
||||||
|
|
||||||
|
|
||||||
class ToolshedUserAdmin(admin.ModelAdmin):
|
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')
|
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(ToolshedUser, ToolshedUserAdmin)
|
||||||
admin.site.register(KnownIdentity, KnownIdentityAdmin)
|
admin.site.register(KnownIdentity, KnownIdentityAdmin)
|
||||||
admin.site.register(FriendRequestOutgoing, FriendRequestOutgoingAdmin)
|
admin.site.register(FriendRequestOutgoing, FriendRequestOutgoingAdmin)
|
||||||
|
|
@ -56,3 +61,4 @@ admin.site.register(AccountPreference, AccountPreferenceAdmin)
|
||||||
admin.site.register(Group, GroupAdmin)
|
admin.site.register(Group, GroupAdmin)
|
||||||
admin.site.register(GroupInvite, GroupInviteAdmin)
|
admin.site.register(GroupInvite, GroupInviteAdmin)
|
||||||
admin.site.register(GroupInviteIncoming, GroupInviteIncomingAdmin)
|
admin.site.register(GroupInviteIncoming, GroupInviteIncomingAdmin)
|
||||||
|
admin.site.register(GroupMembership, GroupMembershipAdmin)
|
||||||
|
|
|
||||||
28
backend/authentication/migrations/0006_groupmembership.py
Normal file
28
backend/authentication/migrations/0006_groupmembership.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# Generated by Django 4.2.2 on 2026-08-26 16:25
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('authentication', '0005_group_groupinviteincoming_groupinvite_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='GroupMembership',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('group_name', models.CharField(max_length=255)),
|
||||||
|
('group_domain', models.CharField(max_length=255)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_memberships', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'unique_together': {('user', 'group_name', 'group_domain')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -180,3 +180,23 @@ class GroupInviteIncoming(models.Model):
|
||||||
inviter_domain = models.CharField(max_length=255)
|
inviter_domain = models.CharField(max_length=255)
|
||||||
invitee_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='group_invites_incoming')
|
invitee_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='group_invites_incoming')
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
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}"
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,12 @@ from rest_framework.response import Response
|
||||||
from rest_framework.views import APIView
|
from rest_framework.views import APIView
|
||||||
from rest_framework.viewsets import ViewSetMixin
|
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, \
|
from authentication.signature_auth import SignatureAuthentication, SignatureAuthenticationLocal, \
|
||||||
authenticate_request_against_local_users, split_grouphandle_or_throw, split_userhandle_or_throw, \
|
authenticate_request_against_local_users, split_grouphandle_or_throw, split_userhandle_or_throw, \
|
||||||
verify_incoming_group_invite
|
verify_incoming_group_invite
|
||||||
from toolshed.serializers import GroupSerializer, GroupInviteIncomingSerializer
|
from toolshed.serializers import GroupSerializer, GroupInviteIncomingSerializer, GroupMembershipSerializer
|
||||||
|
|
||||||
|
|
||||||
class Groups(APIView, ViewSetMixin):
|
class Groups(APIView, ViewSetMixin):
|
||||||
|
|
@ -126,6 +127,34 @@ def declineGroupInvite(request, pk, format=None): # /api/groupinvites/<pk>/
|
||||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@authentication_classes([SignatureAuthenticationLocal])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def recordGroupMembership(request, pk, format=None): # /api/groupinvites/<pk>/accept/
|
||||||
|
"""Local bookkeeping call on the invitee's own home backend, made by the client once its
|
||||||
|
accept request against the group's home backend (acceptGroupInvite) has succeeded. Turns the
|
||||||
|
now-obsolete GroupInviteIncoming into a durable GroupMembership pointer, so this backend
|
||||||
|
remembers which foreign groups its own user belongs to -- the same way FriendRequestIncoming
|
||||||
|
ends up recorded as a friend on both sides, not just the group's authoritative backend."""
|
||||||
|
invite = get_object_or_404(request.user.group_invites_incoming, pk=pk)
|
||||||
|
membership, _ = GroupMembership.objects.get_or_create(
|
||||||
|
user=request.user, group_name=invite.group_name, group_domain=invite.group_domain)
|
||||||
|
invite.delete()
|
||||||
|
return Response(status=status.HTTP_201_CREATED, data=GroupMembershipSerializer(membership).data)
|
||||||
|
|
||||||
|
|
||||||
|
class GroupMemberships(APIView, ViewSetMixin):
|
||||||
|
"""/api/groupmemberships/ - the personal index of every group (local or remote) this backend
|
||||||
|
has recorded the caller as belonging to, kept independently of the group's own membership
|
||||||
|
roster (see GroupMembership and recordGroupMembership)."""
|
||||||
|
authentication_classes = [SignatureAuthenticationLocal]
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get(self, request, format=None): # /api/groupmemberships/
|
||||||
|
memberships = request.user.group_memberships.all()
|
||||||
|
return Response(GroupMembershipSerializer(memberships, many=True).data)
|
||||||
|
|
||||||
|
|
||||||
@api_view(['POST'])
|
@api_view(['POST'])
|
||||||
def acceptGroupInvite(request, format=None): # /api/group_invites/accept/ - lands on the group's home backend
|
def acceptGroupInvite(request, format=None): # /api/group_invites/accept/ - lands on the group's home backend
|
||||||
raw_request = request.body.decode('utf-8')
|
raw_request = request.body.decode('utf-8')
|
||||||
|
|
@ -161,5 +190,7 @@ urlpatterns = [
|
||||||
path('groups/<int:pk>/invites/', createGroupInvite, name='create_group_invite'),
|
path('groups/<int:pk>/invites/', createGroupInvite, name='create_group_invite'),
|
||||||
path('groupinvites/', GroupInvitesIncoming.as_view(), name='group_invites_incoming'),
|
path('groupinvites/', GroupInvitesIncoming.as_view(), name='group_invites_incoming'),
|
||||||
path('groupinvites/<int:pk>/', declineGroupInvite, name='decline_group_invite'),
|
path('groupinvites/<int:pk>/', declineGroupInvite, name='decline_group_invite'),
|
||||||
|
path('groupinvites/<int:pk>/accept/', recordGroupMembership, name='record_group_membership'),
|
||||||
path('group_invites/accept/', acceptGroupInvite, name='accept_group_invite'),
|
path('group_invites/accept/', acceptGroupInvite, name='accept_group_invite'),
|
||||||
|
path('groupmemberships/', GroupMemberships.as_view(), name='group_memberships'),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
from rest_framework import serializers
|
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 authentication.serializers import OwnerSerializer, GroupOwnerSerializer
|
||||||
from files.models import File
|
from files.models import File
|
||||||
from files.serializers import FileSerializer
|
from files.serializers import FileSerializer
|
||||||
|
|
@ -119,6 +120,17 @@ class GroupInviteIncomingSerializer(serializers.ModelSerializer):
|
||||||
return obj.inviter_username + '@' + obj.inviter_domain
|
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):
|
class PropertySerializer(serializers.ModelSerializer):
|
||||||
category = serializers.SerializerMethodField()
|
category = serializers.SerializerMethodField()
|
||||||
handle = serializers.SerializerMethodField()
|
handle = serializers.SerializerMethodField()
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ from django.test import Client
|
||||||
|
|
||||||
from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase, \
|
from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase, \
|
||||||
DummyExternalUser
|
DummyExternalUser
|
||||||
from authentication.models import Group, GroupInvite, GroupInviteIncoming, KnownIdentity
|
from authentication.models import Group, GroupInvite, GroupInviteIncoming, GroupMembership, KnownIdentity
|
||||||
|
|
||||||
client = SignatureAuthClient()
|
client = SignatureAuthClient()
|
||||||
|
|
||||||
|
|
@ -124,9 +124,13 @@ class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
|
||||||
self.assertTrue(group.is_member(invitee.public_identity))
|
self.assertTrue(group.is_member(invitee.public_identity))
|
||||||
self.assertEqual(GroupInvite.objects.count(), 0)
|
self.assertEqual(GroupInvite.objects.count(), 0)
|
||||||
|
|
||||||
reply5 = client.delete('/api/groupinvites/{}/'.format(incoming.id), invitee)
|
reply5 = client.post('/api/groupinvites/{}/accept/'.format(incoming.id), invitee)
|
||||||
self.assertEqual(reply5.status_code, 204)
|
self.assertEqual(reply5.status_code, 201)
|
||||||
|
self.assertEqual(reply5.json()['handle'], str(group))
|
||||||
self.assertEqual(GroupInviteIncoming.objects.count(), 0)
|
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):
|
def test_invite_non_member_denied(self):
|
||||||
group = self.f['group1']
|
group = self.f['group1']
|
||||||
|
|
@ -189,3 +193,69 @@ class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
|
||||||
})
|
})
|
||||||
self.assertEqual(reply.status_code, 401)
|
self.assertEqual(reply.status_code, 401)
|
||||||
self.assertFalse(group.is_member(invitee.public_identity))
|
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)
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,3 +1,5 @@
|
||||||
|
import {encodeHandleForUrl} from "@/router";
|
||||||
|
|
||||||
// A template's layout tree, content resolution, and selectability contract. See
|
// A template's layout tree, content resolution, and selectability contract. See
|
||||||
// docs/implementation.md#template-layout-tree.
|
// docs/implementation.md#template-layout-tree.
|
||||||
const GAP = {type: "empty", "min-width": "1mm", "min-height": "1mm"};
|
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.
|
// defaults to this origin but is editable, since any frontend can resolve any handle.
|
||||||
itemUrl: {
|
itemUrl: {
|
||||||
inputs: ["webdomain", "userHandle", "itemId"],
|
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
|
// 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.
|
// only where context already makes clear it's a Toolshed item, unlike itemUrl.
|
||||||
|
|
@ -307,11 +309,6 @@ export function templateIsAvailable(t, fields) {
|
||||||
return available;
|
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) {
|
export function templateContent(t, fields) {
|
||||||
if (!templateIsAvailable(t, fields)) {
|
if (!templateIsAvailable(t, fields)) {
|
||||||
const err = new Error("This layout needs more fields filled in before it can be drawn.");
|
const err = new Error("This layout needs more fields filled in before it can be drawn.");
|
||||||
|
|
|
||||||
|
|
@ -51,18 +51,25 @@ function itemDetailRoute(handle, item_local_id) {
|
||||||
return handle ? `/inventory/${encodeHandleForUrl(handle)}/${item_local_id}` : null;
|
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 = {
|
const EXPANDED_ROUTE_BUILDERS = {
|
||||||
item: ({owner_identity_id, item_local_id}) =>
|
item: ({owner_identity_id, item_local_id}) =>
|
||||||
itemDetailRoute(store.getters.identityHandleById[owner_identity_id], item_local_id),
|
itemDetailRoute(store.getters.identityHandleById[owner_identity_id], item_local_id),
|
||||||
group_item: ({owner_group_id, item_local_id}) =>
|
group_item: ({owner_group_id, item_local_id}) =>
|
||||||
itemDetailRoute(store.getters.groupHandleById[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}`,
|
storage_location: ({storage_location_id}) => `/storage-locations/${storage_location_id}`,
|
||||||
workflow: ({workflow_id}) => `/workflows/${workflow_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.
|
// 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}) {
|
export function expandedRoute({kind, ...fields}) {
|
||||||
const buildRoute = EXPANDED_ROUTE_BUILDERS[kind];
|
const buildRoute = EXPANDED_ROUTE_BUILDERS[kind];
|
||||||
|
|
@ -117,7 +124,7 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
||||||
component: GroupNew,
|
component: GroupNew,
|
||||||
meta: {requiresAuth: true}
|
meta: {requiresAuth: true}
|
||||||
}, {
|
}, {
|
||||||
path: '/groups/:id',
|
path: '/groups/:handle',
|
||||||
component: GroupDetail,
|
component: GroupDetail,
|
||||||
meta: {requiresAuth: true},
|
meta: {requiresAuth: true},
|
||||||
props: true
|
props: true
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,11 @@ import {serializeWorkflowPayload, deserializeWorkflowPayload} from "@/workflows.
|
||||||
//import sharedStatePlugin from "@/../extras/shared-state-plugin";
|
//import sharedStatePlugin from "@/../extras/shared-state-plugin";
|
||||||
//import persistentStatePlugin from "@/../extras/persistent-state-plugin";
|
//import persistentStatePlugin from "@/../extras/persistent-state-plugin";
|
||||||
|
|
||||||
|
function splitGroupHandle(handle) {
|
||||||
|
const [name, domain] = handle.slice(1).split('@')
|
||||||
|
return {name, domain}
|
||||||
|
}
|
||||||
|
|
||||||
const defaultPreferenceDefinitions = [
|
const defaultPreferenceDefinitions = [
|
||||||
{
|
{
|
||||||
key: 'ui.compact_mode',
|
key: 'ui.compact_mode',
|
||||||
|
|
@ -74,6 +79,7 @@ export default createStore({
|
||||||
friendProfiles: {},
|
friendProfiles: {},
|
||||||
groups: [],
|
groups: [],
|
||||||
groupInvites: [],
|
groupInvites: [],
|
||||||
|
groupMemberships: [],
|
||||||
idmap: {identities: [], groups: []},
|
idmap: {identities: [], groups: []},
|
||||||
idmapLoaded: false,
|
idmapLoaded: false,
|
||||||
item_map: {},
|
item_map: {},
|
||||||
|
|
@ -114,6 +120,9 @@ export default createStore({
|
||||||
setGroupInvites(state, invites) {
|
setGroupInvites(state, invites) {
|
||||||
state.groupInvites = invites;
|
state.groupInvites = invites;
|
||||||
},
|
},
|
||||||
|
setGroupMemberships(state, memberships) {
|
||||||
|
state.groupMemberships = memberships;
|
||||||
|
},
|
||||||
setIdMap(state, idmap) {
|
setIdMap(state, idmap) {
|
||||||
state.idmap = idmap;
|
state.idmap = idmap;
|
||||||
state.idmapLoaded = true;
|
state.idmapLoaded = true;
|
||||||
|
|
@ -394,14 +403,22 @@ export default createStore({
|
||||||
state.last_load.files = 0
|
state.last_load.files = 0
|
||||||
return reply
|
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) {
|
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}
|
const data = {availability_policy: 'friends', ...item}
|
||||||
data.files = data.files.map(file => file.id)
|
data.files = data.files.map(file => file.id)
|
||||||
return await servers.patch(getters.signAuth, '/api/inventory_items/' + item.id + '/', data)
|
return await servers.patch(getters.signAuth, '/api/inventory_items/' + item.id + '/', data)
|
||||||
},
|
},
|
||||||
async deleteInventoryItem({state, dispatch, getters}, item) {
|
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 + '/')
|
const ret = await servers.delete(getters.signAuth, '/api/inventory_items/' + item.id + '/')
|
||||||
dispatch('fetchInventoryItems')
|
dispatch('fetchInventoryItems')
|
||||||
return ret
|
return ret
|
||||||
|
|
@ -448,19 +465,21 @@ export default createStore({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Group handles have no owner-handle GET route yet, so they resolve differently than
|
// A group handle resolves via fetchGroup (itself domain-routed, see below), so a group
|
||||||
// personal handles here. See docs/implementation.md#fetch-item-by-handle-group-vs-personal-handles.
|
// 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}) {
|
async fetchItemByHandle({dispatch, getters}, {handle, id}) {
|
||||||
if (handle.startsWith('#')) {
|
if (handle.startsWith('#')) {
|
||||||
// idmap isn't guaranteed loaded on a direct/refreshed visit here; fetchIdMap is
|
try {
|
||||||
// cheap and already called unconditionally by every other caller too.
|
const group = await dispatch('fetchGroup', {handle})
|
||||||
await dispatch('fetchIdMap')
|
const items = await dispatch('fetchGroupInventoryItems', {groupId: group.id, domain: group.domain})
|
||||||
const groupId = getters.groupIdByHandle[handle]
|
return items.find(item => item.id === parseInt(id)) || null
|
||||||
if (groupId === undefined) {
|
} catch (error) {
|
||||||
return null
|
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})
|
return dispatch('fetchForeignItem', {owner: handle, id})
|
||||||
},
|
},
|
||||||
|
|
@ -511,9 +530,14 @@ export default createStore({
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = await dispatch('getHomeServers')
|
||||||
return await servers.delete(getters.signAuth, '/api/friends/' + id + '/')
|
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
|
// Listing/creating a group is inherently home-scoped - it's this backend's own view of
|
||||||
// docs/design-in-progress/groups-mvp.md), so every group action below uses getHomeServers
|
// "groups I host you in" - but every action below that acts on an *existing* group
|
||||||
// rather than resolving a per-group domain.
|
// (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}) {
|
async fetchGroups({commit, dispatch, getters}) {
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = await dispatch('getHomeServers')
|
||||||
const data = await servers.get(getters.signAuth, '/api/groups/')
|
const data = await servers.get(getters.signAuth, '/api/groups/')
|
||||||
|
|
@ -526,16 +550,18 @@ export default createStore({
|
||||||
commit('setIdMap', idmap)
|
commit('setIdMap', idmap)
|
||||||
return idmap
|
return idmap
|
||||||
},
|
},
|
||||||
async fetchGroup({dispatch, getters}, {id}) {
|
// handle is "#name@domain".
|
||||||
const servers = await dispatch('getHomeServers')
|
async fetchGroup({dispatch, getters}, {handle}) {
|
||||||
return await servers.get(getters.signAuth, '/api/groups/' + id + '/')
|
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}) {
|
async createGroup({dispatch, getters}, {name}) {
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = await dispatch('getHomeServers')
|
||||||
return await servers.post(getters.signAuth, '/api/groups/', {name})
|
return await servers.post(getters.signAuth, '/api/groups/', {name})
|
||||||
},
|
},
|
||||||
async removeGroupMember({dispatch, getters}, {groupId, identityId}) {
|
async removeGroupMember({dispatch, getters}, {groupId, domain, identityId}) {
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
|
||||||
return await servers.delete(getters.signAuth, '/api/groups/' + groupId + '/members/' + identityId + '/')
|
return await servers.delete(getters.signAuth, '/api/groups/' + groupId + '/members/' + identityId + '/')
|
||||||
},
|
},
|
||||||
async fetchGroupInvites({commit, dispatch, getters}) {
|
async fetchGroupInvites({commit, dispatch, getters}) {
|
||||||
|
|
@ -544,11 +570,17 @@ export default createStore({
|
||||||
commit('setGroupInvites', data)
|
commit('setGroupInvites', data)
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
async inviteToGroup({state, dispatch, getters}, {groupId, groupHandle, invitee}) {
|
async fetchGroupMemberships({commit, dispatch, getters}) {
|
||||||
const home_servers = await dispatch('getHomeServers')
|
const servers = await dispatch('getHomeServers')
|
||||||
const home_reply = await home_servers.post(
|
const data = await servers.get(getters.signAuth, '/api/groupmemberships/')
|
||||||
|
commit('setGroupMemberships', data)
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
async inviteToGroup({state, dispatch, getters}, {groupId, domain, groupHandle, invitee}) {
|
||||||
|
const group_servers = await dispatch('getFriendServers', {username: 'x@' + domain})
|
||||||
|
const group_reply = await group_servers.post(
|
||||||
getters.signAuth, '/api/groups/' + groupId + '/invites/', {invitee})
|
getters.signAuth, '/api/groups/' + groupId + '/invites/', {invitee})
|
||||||
if (!home_reply.secret) {
|
if (!group_reply.secret) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
const invitee_servers = await dispatch('getFriendServers', {username: invitee})
|
const invitee_servers = await dispatch('getFriendServers', {username: invitee})
|
||||||
|
|
@ -557,7 +589,7 @@ export default createStore({
|
||||||
inviter: state.user,
|
inviter: state.user,
|
||||||
inviter_key: nacl.to_hex(state.keypair.signPk),
|
inviter_key: nacl.to_hex(state.keypair.signPk),
|
||||||
invitee,
|
invitee,
|
||||||
secret: home_reply.secret
|
secret: group_reply.secret
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
|
|
@ -571,17 +603,20 @@ export default createStore({
|
||||||
secret: invite.secret
|
secret: invite.secret
|
||||||
})
|
})
|
||||||
const home_servers = await dispatch('getHomeServers')
|
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
|
return true
|
||||||
},
|
},
|
||||||
async declineGroupInvite({dispatch, getters}, invite) {
|
async declineGroupInvite({dispatch, getters}, invite) {
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = await dispatch('getHomeServers')
|
||||||
return await servers.delete(getters.signAuth, '/api/groupinvites/' + invite.id + '/')
|
return await servers.delete(getters.signAuth, '/api/groupinvites/' + invite.id + '/')
|
||||||
},
|
},
|
||||||
async fetchGroupInventoryItems({commit, dispatch, getters}, groupId) {
|
async fetchGroupInventoryItems({commit, dispatch, getters}, {groupId, domain}) {
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = await dispatch('getFriendServers', {username: 'x@' + domain})
|
||||||
const items = await servers.get(getters.signAuth, '/api/inventory_items/?group=' + groupId)
|
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
|
return items
|
||||||
},
|
},
|
||||||
async fetchFiles({state, commit, dispatch, getters}) {
|
async fetchFiles({state, commit, dispatch, getters}) {
|
||||||
|
|
@ -813,7 +848,7 @@ export default createStore({
|
||||||
inventory_items(state) {
|
inventory_items(state) {
|
||||||
return state.item_map['/'] || []
|
return state.item_map['/'] || []
|
||||||
},
|
},
|
||||||
groupInventoryItems: (state) => (groupId) => state.item_map['/group/' + groupId] || [],
|
groupInventoryItems: (state) => (groupId, domain) => state.item_map['/group/' + domain + '/' + groupId] || [],
|
||||||
identityIdByHandle(state) {
|
identityIdByHandle(state) {
|
||||||
return Object.fromEntries(state.idmap.identities.map(i => [i.username, i.id]))
|
return Object.fromEntries(state.idmap.identities.map(i => [i.username, i.id]))
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,9 @@
|
||||||
<a :href="`${itemRoute(item)}/delete`" @click.prevent="tryDeleteItem(item)">
|
<a :href="`${itemRoute(item)}/delete`" @click.prevent="tryDeleteItem(item)">
|
||||||
<b-icon-trash></b-icon-trash>
|
<b-icon-trash></b-icon-trash>
|
||||||
</a>
|
</a>
|
||||||
|
<router-link v-if="printLinkFor(item)" :to="printLinkFor(item)">
|
||||||
|
<b-icon-qr-code></b-icon-qr-code>
|
||||||
|
</router-link>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -111,6 +114,10 @@
|
||||||
<router-link :to="`${itemRoute(item)}/edit`"
|
<router-link :to="`${itemRoute(item)}/edit`"
|
||||||
class="btn btn-primary btn-sm">Edit
|
class="btn btn-primary btn-sm">Edit
|
||||||
</router-link>
|
</router-link>
|
||||||
|
<router-link v-if="printLinkFor(item)" :to="printLinkFor(item)"
|
||||||
|
class="btn btn-secondary btn-sm">
|
||||||
|
<b-icon-qr-code></b-icon-qr-code>
|
||||||
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -120,7 +127,9 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<button class="btn" @click="fetchItems">Refresh</button>
|
<button class="btn" @click="fetchItems">Refresh</button>
|
||||||
<router-link :to="`/inventory/new?group=${id}`" class="btn btn-primary">Add</router-link>
|
<router-link v-if="isOwnDomain" :to="`/inventory/new?group=${group.id}`"
|
||||||
|
class="btn btn-primary">Add
|
||||||
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -134,7 +143,7 @@ import {mapActions, mapGetters, mapState} from "vuex";
|
||||||
import * as BIcons from "bootstrap-icons-vue";
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
import BaseLayout from "@/components/BaseLayout.vue";
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
import UserNameTag from "@/components/UserNameTag.vue";
|
import UserNameTag from "@/components/UserNameTag.vue";
|
||||||
import {encodeHandleForUrl} from "@/router";
|
import {decodeHandleFromUrl, encodeHandleForUrl} from "@/router";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'GroupDetail',
|
name: 'GroupDetail',
|
||||||
|
|
@ -144,8 +153,8 @@ export default {
|
||||||
...BIcons
|
...BIcons
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
id: {
|
handle: {
|
||||||
type: [String, Number],
|
type: String,
|
||||||
required: true
|
required: true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -159,39 +168,55 @@ export default {
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(['user']),
|
...mapState(['user']),
|
||||||
|
decodedHandle() {
|
||||||
|
return decodeHandleFromUrl(this.handle)
|
||||||
|
},
|
||||||
items() {
|
items() {
|
||||||
return this.groupInventoryItems(this.id)
|
return this.group ? this.groupInventoryItems(this.group.id, this.group.domain) : []
|
||||||
|
},
|
||||||
|
isOwnDomain() {
|
||||||
|
return this.group && this.group.domain === this.user.split('@')[1]
|
||||||
},
|
},
|
||||||
...mapGetters(['groupInventoryItems'])
|
...mapGetters(['groupInventoryItems'])
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(['fetchGroup', 'removeGroupMember', 'inviteToGroup', 'fetchGroupInventoryItems',
|
...mapActions(['fetchGroup', 'removeGroupMember', 'inviteToGroup', 'fetchGroupInventoryItems',
|
||||||
'deleteInventoryItem']),
|
'deleteInventoryItem']),
|
||||||
|
// Loads by handle regardless of which backend actually hosts the group - see
|
||||||
|
// store.js's fetchGroup.
|
||||||
refresh() {
|
refresh() {
|
||||||
this.fetchGroup({id: this.id}).then((group) => {
|
return this.fetchGroup({handle: this.decodedHandle}).then((group) => {
|
||||||
this.group = group
|
this.group = group
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
fetchItems() {
|
fetchItems() {
|
||||||
this.fetchGroupInventoryItems(this.id)
|
if (this.group) {
|
||||||
|
this.fetchGroupInventoryItems({groupId: this.group.id, domain: this.group.domain})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
itemRoute(item) {
|
itemRoute(item) {
|
||||||
return `/inventory/${encodeHandleForUrl(this.group.handle)}/${item.id}`
|
return `/inventory/${encodeHandleForUrl(this.group.handle)}/${item.id}`
|
||||||
},
|
},
|
||||||
|
printLinkFor(item) {
|
||||||
|
return {path: '/print', query: {kind: 'item', userHandle: this.group.handle, item: item.id}}
|
||||||
|
},
|
||||||
tryInvite() {
|
tryInvite() {
|
||||||
this.inviteToGroup({groupId: this.id, groupHandle: this.group.handle, invitee: this.invitee})
|
this.inviteToGroup({
|
||||||
.then((ok) => {
|
groupId: this.group.id, domain: this.group.domain, groupHandle: this.group.handle,
|
||||||
if (ok) {
|
invitee: this.invitee
|
||||||
this.show_invite = false
|
}).then((ok) => {
|
||||||
this.invitee = ""
|
if (ok) {
|
||||||
}
|
this.show_invite = false
|
||||||
}).catch(() => {
|
this.invitee = ""
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
tryRemoveMember(member) {
|
tryRemoveMember(member) {
|
||||||
this.removeGroupMember({groupId: this.id, identityId: member.id}).then(() => {
|
this.removeGroupMember({groupId: this.group.id, domain: this.group.domain, identityId: member.id})
|
||||||
this.refresh()
|
.then(() => {
|
||||||
}).catch(() => {
|
this.refresh()
|
||||||
|
}).catch(() => {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
tryDeleteItem(item) {
|
tryDeleteItem(item) {
|
||||||
|
|
@ -201,8 +226,7 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.refresh()
|
this.refresh().then(() => this.fetchItems())
|
||||||
this.fetchItems()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
<script>
|
<script>
|
||||||
import {mapActions} from "vuex";
|
import {mapActions} from "vuex";
|
||||||
import BaseLayout from "@/components/BaseLayout.vue";
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
|
import {encodeHandleForUrl} from "@/router";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'GroupNew',
|
name: 'GroupNew',
|
||||||
|
|
@ -40,7 +41,7 @@ export default {
|
||||||
...mapActions(['createGroup']),
|
...mapActions(['createGroup']),
|
||||||
tryCreateGroup() {
|
tryCreateGroup() {
|
||||||
this.createGroup({name: this.name}).then((group) => {
|
this.createGroup({name: this.name}).then((group) => {
|
||||||
this.$router.push(`/groups/${group.id}`)
|
this.$router.push(`/groups/${encodeHandleForUrl(group.handle)}`)
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
<div class="col-12 col-xl-6">
|
<div class="col-12 col-xl-6">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h5 class="card-title">My Groups</h5>
|
<h5 class="card-title">Groups</h5>
|
||||||
</div>
|
</div>
|
||||||
<table class="table table-striped">
|
<table class="table table-striped">
|
||||||
<thead>
|
<thead>
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th class="d-none d-md-table-cell" style="width:25%">Members</th>
|
<th class="d-none d-md-table-cell" style="width:25%">Members</th>
|
||||||
<th style="width: 16em">
|
<th style="width: 16em">
|
||||||
<a @click="fetchGroups" class="align-middle">
|
<a @click="refreshGroups" class="align-middle">
|
||||||
<b-icon-arrow-clockwise></b-icon-arrow-clockwise>
|
<b-icon-arrow-clockwise></b-icon-arrow-clockwise>
|
||||||
Refresh
|
Refresh
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -27,11 +27,16 @@
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="group in groups" :key="group.id">
|
<tr v-for="group in allGroups" :key="group.key">
|
||||||
<td>
|
<td>
|
||||||
<router-link :to="`/groups/${group.id}`">{{ group.handle }}</router-link>
|
<router-link :to="`/groups/${encodeHandleForUrl(group.handle)}`">
|
||||||
|
{{ group.handle }}
|
||||||
|
</router-link>
|
||||||
</td>
|
</td>
|
||||||
<td class="d-none d-md-table-cell">{{ group.members.length }}</td>
|
<!-- GroupDetail.vue loads a foreign group's roster live by handle (see
|
||||||
|
store.js's fetchGroup) - this list just avoids an N-request roundtrip
|
||||||
|
per row, so a member count is only free for a group hosted here. -->
|
||||||
|
<td class="d-none d-md-table-cell">{{ group.hosted ? group.memberCount : '—' }}</td>
|
||||||
<td class="table-action"></td>
|
<td class="table-action"></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -85,6 +90,7 @@
|
||||||
import {mapActions, mapState} from "vuex";
|
import {mapActions, mapState} from "vuex";
|
||||||
import * as BIcons from "bootstrap-icons-vue";
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
import BaseLayout from "@/components/BaseLayout.vue";
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
|
import {encodeHandleForUrl} from "@/router";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Groups',
|
name: 'Groups',
|
||||||
|
|
@ -93,14 +99,31 @@ export default {
|
||||||
...BIcons
|
...BIcons
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState(['groups', 'groupInvites']),
|
...mapState(['groups', 'groupInvites', 'groupMemberships']),
|
||||||
|
allGroups() {
|
||||||
|
const hosted = this.groups.map(group => ({
|
||||||
|
key: `hosted:${group.id}`, handle: group.handle, memberCount: group.members.length, hosted: true
|
||||||
|
}))
|
||||||
|
const hostedHandles = new Set(hosted.map(group => group.handle))
|
||||||
|
const foreign = this.groupMemberships
|
||||||
|
.filter(membership => !hostedHandles.has(membership.handle))
|
||||||
|
.map(membership => ({key: `foreign:${membership.id}`, handle: membership.handle, hosted: false}))
|
||||||
|
return [...hosted, ...foreign].sort((a, b) => a.handle.localeCompare(b.handle))
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(['fetchGroups', 'fetchGroupInvites', 'acceptGroupInvite', 'declineGroupInvite']),
|
encodeHandleForUrl,
|
||||||
|
...mapActions(['fetchGroups', 'fetchGroupInvites', 'fetchGroupMemberships', 'acceptGroupInvite',
|
||||||
|
'declineGroupInvite']),
|
||||||
|
refreshGroups() {
|
||||||
|
this.fetchGroups()
|
||||||
|
this.fetchGroupMemberships()
|
||||||
|
},
|
||||||
tryAcceptInvite(invite) {
|
tryAcceptInvite(invite) {
|
||||||
this.acceptGroupInvite(invite).then(() => {
|
this.acceptGroupInvite(invite).then(() => {
|
||||||
this.fetchGroupInvites()
|
this.fetchGroupInvites()
|
||||||
this.fetchGroups()
|
this.fetchGroups()
|
||||||
|
this.fetchGroupMemberships()
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
@ -114,6 +137,7 @@ export default {
|
||||||
mounted() {
|
mounted() {
|
||||||
this.fetchGroups()
|
this.fetchGroups()
|
||||||
this.fetchGroupInvites()
|
this.fetchGroupInvites()
|
||||||
|
this.fetchGroupMemberships()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -150,11 +150,10 @@ export default {
|
||||||
if (owner_identity_id === undefined) return null
|
if (owner_identity_id === undefined) return null
|
||||||
return shortenedRoute({kind: 'item', owner_identity_id, item_local_id: item.id})
|
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) {
|
printLinkFor(item) {
|
||||||
if (!item.owner) return null
|
const userHandle = item.owner || item.owner_group
|
||||||
return {path: '/print', query: {kind: 'item', userHandle: item.owner, item: item.id}}
|
if (!userHandle) return null
|
||||||
|
return {path: '/print', query: {kind: 'item', userHandle, item: item.id}}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-danger"
|
<button type="submit" class="btn btn-danger"
|
||||||
@click="deleteInventoryItem(item).then(() => $router.push('/inventory'))">
|
@click="deleteInventoryItem(item).then(() => $router.push(ownerOverviewRoute(item)))">
|
||||||
<b-icon-trash></b-icon-trash>
|
<b-icon-trash></b-icon-trash>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -68,7 +68,7 @@ import * as BIcons from "bootstrap-icons-vue";
|
||||||
import BaseLayout from "@/components/BaseLayout.vue";
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
import {mapActions, mapGetters, mapState} from "vuex";
|
import {mapActions, mapGetters, mapState} from "vuex";
|
||||||
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
import AuthenticatedImage from "@/components/AuthenticatedImage.vue";
|
||||||
import {decodeHandleFromUrl} from "@/router";
|
import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "InventoryDetail",
|
name: "InventoryDetail",
|
||||||
|
|
@ -94,23 +94,30 @@ export default {
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters(["getNameFromHandle", "groupIdByHandle"]),
|
...mapGetters(["getNameFromHandle", "groupIdByHandle"]),
|
||||||
...mapState(["user"]),
|
...mapState(["user", "groupMemberships"]),
|
||||||
decodedHandle() {
|
decodedHandle() {
|
||||||
return decodeHandleFromUrl(this.handle)
|
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
|
// 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.
|
// access - get_shared_item's friends_or_self() lets a friend view but never act.
|
||||||
canEdit() {
|
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() {
|
canPrint() {
|
||||||
return this.decodedHandle === this.user
|
return this.decodedHandle === this.user || this.isGroupMember
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(["fetchItemByHandle", "deleteInventoryItem"]),
|
ownerOverviewRoute,
|
||||||
|
...mapActions(["fetchItemByHandle", "deleteInventoryItem", "fetchGroupMemberships"]),
|
||||||
async loadItem() {
|
async loadItem() {
|
||||||
this.item = await this.fetchItemByHandle({handle: this.decodedHandle, id: this.id}) || {}
|
this.item = await this.fetchItemByHandle({handle: this.decodedHandle, id: this.id}) || {}
|
||||||
}
|
}
|
||||||
|
|
@ -121,6 +128,9 @@ export default {
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.loadItem()
|
this.loadItem()
|
||||||
|
if (this.decodedHandle.startsWith('#')) {
|
||||||
|
this.fetchGroupMemberships()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,8 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<button type="submit" class="btn btn-primary" style="width: 100%"
|
<button type="submit" class="btn btn-primary" style="width: 100%"
|
||||||
@click="updateInventoryItem(item)">Update
|
@click="updateInventoryItem(item).then(updated => $router.push(ownerOverviewRoute(updated)))">
|
||||||
|
Update
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -75,7 +76,7 @@ import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
import TagField from "@/components/TagField.vue";
|
import TagField from "@/components/TagField.vue";
|
||||||
import PropertyField from "@/components/PropertyField.vue";
|
import PropertyField from "@/components/PropertyField.vue";
|
||||||
import CombinedFileField from "@/components/CombinedFileField.vue";
|
import CombinedFileField from "@/components/CombinedFileField.vue";
|
||||||
import {decodeHandleFromUrl} from "@/router";
|
import {decodeHandleFromUrl, ownerOverviewRoute} from "@/router";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "InventoryEdit",
|
name: "InventoryEdit",
|
||||||
|
|
@ -120,6 +121,7 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
ownerOverviewRoute,
|
||||||
...mapActions(["fetchItemByHandle", "updateInventoryItem", "fetchInfo", "fetchStorageLocations"]),
|
...mapActions(["fetchItemByHandle", "updateInventoryItem", "fetchInfo", "fetchStorageLocations"]),
|
||||||
changeFiles(files) {
|
changeFiles(files) {
|
||||||
this.item.files = files
|
this.item.files = files
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<button type="submit" class="btn btn-primary" style="width: 100%"
|
<button type="submit" class="btn btn-primary" style="width: 100%"
|
||||||
@click="createInventoryItem(item).then(() => $router.push('/inventory'))">Add
|
@click="createInventoryItem(item).then(created => $router.push(ownerOverviewRoute(created)))">Add
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -84,6 +84,7 @@ import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
import TagField from "@/components/TagField.vue";
|
import TagField from "@/components/TagField.vue";
|
||||||
import PropertyField from "@/components/PropertyField.vue";
|
import PropertyField from "@/components/PropertyField.vue";
|
||||||
import CombinedFileField from "@/components/CombinedFileField.vue";
|
import CombinedFileField from "@/components/CombinedFileField.vue";
|
||||||
|
import {ownerOverviewRoute} from "@/router";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "InventoryNew",
|
name: "InventoryNew",
|
||||||
|
|
@ -111,6 +112,7 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
ownerOverviewRoute,
|
||||||
...mapActions(['createInventoryItem', 'fetchInfo', 'fetchStorageLocations', 'fetchGroups'])
|
...mapActions(['createInventoryItem', 'fetchInfo', 'fetchStorageLocations', 'fetchGroups'])
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|
|
||||||
|
|
@ -337,10 +337,7 @@ export default {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters(["identityIdByHandle"]),
|
...mapGetters(["identityIdByHandle", "groupIdByHandle"]),
|
||||||
// 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() {
|
baseVars() {
|
||||||
return BASE_VARS.filter(v => v !== SHORT_URL_VAR && v !== SHORT_ID_VAR);
|
return BASE_VARS.filter(v => v !== SHORT_URL_VAR && v !== SHORT_ID_VAR);
|
||||||
},
|
},
|
||||||
|
|
@ -461,12 +458,17 @@ export default {
|
||||||
varLabel(v) {
|
varLabel(v) {
|
||||||
return v.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, c => c.toUpperCase());
|
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) {
|
shortId(f) {
|
||||||
if (!f.userHandle) {
|
if (!f.userHandle) {
|
||||||
return null;
|
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];
|
const owner_identity_id = this.identityIdByHandle[f.userHandle];
|
||||||
if (owner_identity_id === undefined) {
|
if (owner_identity_id === undefined) {
|
||||||
return null;
|
return null;
|
||||||
|
|
|
||||||
|
|
@ -203,4 +203,8 @@ export default {
|
||||||
.is-invalid .invalid-feedback {
|
.is-invalid .invalid-feedback {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.visually-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -148,33 +148,14 @@ export default {
|
||||||
error: null,
|
error: null,
|
||||||
insecureContext: window.isSecureContext,
|
insecureContext: window.isSecureContext,
|
||||||
insecureOrigin: `${window.location.protocol}//${window.location.hostname}`,
|
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",
|
visitFirstMatch: this.$route.query.navigate === "immediate",
|
||||||
|
|
||||||
cameraLog: [],
|
cameraLog: [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
methods: {
|
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) {
|
resolveDescription(entry) {
|
||||||
const {link, text} = entry;
|
const {link, text} = entry;
|
||||||
if (!link || link.href) {
|
if (!link || link.href) {
|
||||||
|
|
@ -185,10 +166,6 @@ export default {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.descriptionCache.has(text)) {
|
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.set(text, this.describeLink(link).catch(e => ({error: e.message ?? String(e)})));
|
||||||
}
|
}
|
||||||
this.descriptionCache.get(text).then(result => {
|
this.descriptionCache.get(text).then(result => {
|
||||||
|
|
@ -198,19 +175,12 @@ export default {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
entry.description = result;
|
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) {
|
if (result) {
|
||||||
this.maybeVisitFirstMatch(link);
|
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) {
|
maybeVisitFirstMatch(link) {
|
||||||
if (!this.visitFirstMatch || !link?.to) {
|
if (!this.visitFirstMatch || !link?.to) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -242,8 +212,12 @@ export default {
|
||||||
return handle === undefined ? null : this.describeItem(handle, decoded.item_local_id);
|
return handle === undefined ? null : this.describeItem(handle, decoded.item_local_id);
|
||||||
}
|
}
|
||||||
if (decoded.kind === "group") {
|
if (decoded.kind === "group") {
|
||||||
const group = await this.fetchGroup({id: decoded.group_id});
|
let handle = this.$store.getters.groupHandleById[decoded.group_id];
|
||||||
return group ? group.handle : null;
|
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 (decoded.kind === "storage_location") {
|
||||||
if (!this.$store.state.storage_locations.length) {
|
if (!this.$store.state.storage_locations.length) {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,30 @@ import {existsSync} from 'node:fs'
|
||||||
import {defineConfig} from 'vite'
|
import {defineConfig} from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
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() {
|
function gitCommit() {
|
||||||
// deploy/dev/docker-compose.yml bind-mounts the repo's real .git dir at
|
// deploy/dev/docker-compose.yml bind-mounts the repo's real .git dir at
|
||||||
// /git (separate from /app, which only has the frontend/ subtree) -
|
// /git (separate from /app, which only has the frontend/ subtree) -
|
||||||
|
|
@ -23,7 +47,7 @@ function gitCommit() {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue(), handleUrlFallback()],
|
||||||
define: {
|
define: {
|
||||||
__GIT_COMMIT__: JSON.stringify(gitCommit())
|
__GIT_COMMIT__: JSON.stringify(gitCommit())
|
||||||
},
|
},
|
||||||
|
|
@ -44,6 +68,10 @@ export default defineConfig({
|
||||||
//'Upgrade-Insecure-Requests': '1',
|
//'Upgrade-Insecure-Requests': '1',
|
||||||
'Content-Security-Policy': 'default-src \'self\';'
|
'Content-Security-Policy': 'default-src \'self\';'
|
||||||
+ ' script-src \'self\' \'wasm-unsafe-eval\' \'unsafe-eval\' \'unsafe-inline\';'
|
+ ' 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\';'
|
+ ' style-src \'self\' \'unsafe-inline\';'
|
||||||
+ ' img-src \'self\' * data: blob:;'
|
+ ' img-src \'self\' * data: blob:;'
|
||||||
+ ' connect-src * data:',
|
+ ' connect-src * data:',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue