Compare commits

...

2 commits

Author SHA1 Message Date
4787acd8eb stash 2026-08-19 16:45:20 +02:00
6d2167ac66 add dns recursor to dev setup 2026-08-19 14:14:17 +02:00
24 changed files with 1254 additions and 27 deletions

View file

@ -1,7 +1,7 @@
from django.contrib import admin
from authentication.models import ToolshedUser, KnownIdentity, FriendRequestOutgoing, FriendRequestIncoming, \
AccountPreference
AccountPreference, Group, GroupInvite, GroupInviteIncoming
class ToolshedUserAdmin(admin.ModelAdmin):
@ -29,8 +29,30 @@ class FriendRequestIncomingAdmin(admin.ModelAdmin):
search_fields = ('secret', 'befriender_username', 'befriender_domain', 'befriendee_user', 'befriender_public_key')
class GroupAdmin(admin.ModelAdmin):
list_display = ('name', 'domain', 'get_members')
search_fields = ('name', 'domain', 'members__username')
@admin.display(description='Members')
def get_members(self, obj):
return ', '.join(str(member) for member in obj.members.all())
class GroupInviteAdmin(admin.ModelAdmin):
list_display = ('secret', 'group', 'invitee_username', 'invitee_domain')
search_fields = ('secret', 'group__name', 'invitee_username', 'invitee_domain')
class GroupInviteIncomingAdmin(admin.ModelAdmin):
list_display = ('secret', 'group_name', 'group_domain', 'inviter_username', 'inviter_domain', 'invitee_user')
search_fields = ('secret', 'group_name', 'group_domain', 'inviter_username', 'inviter_domain', 'invitee_user')
admin.site.register(ToolshedUser, ToolshedUserAdmin)
admin.site.register(KnownIdentity, KnownIdentityAdmin)
admin.site.register(FriendRequestOutgoing, FriendRequestOutgoingAdmin)
admin.site.register(FriendRequestIncoming, FriendRequestIncomingAdmin)
admin.site.register(AccountPreference, AccountPreferenceAdmin)
admin.site.register(Group, GroupAdmin)
admin.site.register(GroupInvite, GroupInviteAdmin)
admin.site.register(GroupInviteIncoming, GroupInviteIncomingAdmin)

View file

@ -0,0 +1,56 @@
# Generated by Django 4.2.2 on 2026-08-19 13:44
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('authentication', '0004_alter_accountpreference_id'),
]
operations = [
migrations.CreateModel(
name='Group',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('domain', models.CharField(max_length=255)),
('members', models.ManyToManyField(related_name='member_of_groups', to='authentication.knownidentity')),
],
),
migrations.CreateModel(
name='GroupInviteIncoming',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('secret', models.CharField(max_length=255)),
('group_name', models.CharField(max_length=255)),
('group_domain', models.CharField(max_length=255)),
('inviter_username', models.CharField(max_length=255)),
('inviter_domain', models.CharField(max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('invitee_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='group_invites_incoming', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='GroupInvite',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('secret', models.CharField(max_length=255)),
('invitee_username', models.CharField(max_length=255)),
('invitee_domain', models.CharField(max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='authentication.group')),
],
),
migrations.AddIndex(
model_name='group',
index=models.Index(fields=['name', 'domain'], name='group_idx'),
),
migrations.AlterUniqueTogether(
name='group',
unique_together={('name', 'domain')},
),
]

View file

@ -144,3 +144,42 @@ class FriendRequestIncoming(models.Model):
befriender_public_key = models.CharField(max_length=255)
befriendee_user = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='friend_requests_incoming')
created_at = models.DateTimeField(auto_now_add=True)
class Group(models.Model):
name = models.CharField(max_length=255)
domain = models.CharField(max_length=255)
members = models.ManyToManyField(KnownIdentity, related_name='member_of_groups')
class Meta:
unique_together = ('name', 'domain')
indexes = [
models.Index(fields=['name', 'domain'], name='group_idx'),
]
def __str__(self):
return f"#{self.name}@{self.domain}"
def is_member(self, identity):
return self.members.filter(pk=identity.pk).exists()
class GroupInvite(models.Model):
"""A pending invite tracked on the group's own home backend, checked when the invitee's
accept request arrives (see GroupInviteIncoming for the mirror record on the invitee's own
backend, and docs/design-in-progress/groups-mvp.md for the full invite/accept dance)."""
secret = models.CharField(max_length=255)
group = models.ForeignKey(Group, on_delete=models.CASCADE, related_name='invites')
invitee_username = models.CharField(max_length=255)
invitee_domain = models.CharField(max_length=255)
created_at = models.DateTimeField(auto_now_add=True)
class GroupInviteIncoming(models.Model):
secret = models.CharField(max_length=255)
group_name = models.CharField(max_length=255)
group_domain = models.CharField(max_length=255)
inviter_username = 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')
created_at = models.DateTimeField(auto_now_add=True)

View file

@ -1,6 +1,6 @@
from rest_framework import serializers
from authentication.models import ToolshedUser
from authentication.models import ToolshedUser, Group
class OwnerSerializer(serializers.ReadOnlyField):
@ -10,3 +10,12 @@ class OwnerSerializer(serializers.ReadOnlyField):
def to_representation(self, value):
return value.username + '@' + value.domain
class GroupOwnerSerializer(serializers.ReadOnlyField):
class Meta:
model = Group
fields = ['name', 'domain']
def to_representation(self, value):
return f"#{value.name}@{value.domain}"

View file

@ -16,6 +16,12 @@ def split_userhandle_or_throw(userhandle):
return username, domain
def split_grouphandle_or_throw(grouphandle):
if not grouphandle.startswith('#'):
raise ValueError('Group handle must be in the format #name@domain')
return split_userhandle_or_throw(grouphandle[1:])
def verify_request(request, raw_request_body):
authentication_header = request.META.get('HTTP_AUTHORIZATION')
@ -74,6 +80,36 @@ def verify_incoming_friend_request(request, raw_request_body):
return False
def verify_incoming_group_invite(request, raw_request_body, handle_field, key_field):
"""Self-certifying verifier for the two legs of the group invite/accept dance that land on a
backend which doesn't have the caller cached as a KnownIdentity yet (see
docs/design-in-progress/groups-mvp.md): the inviter delivering an invite to the invitee's own
backend (handle_field='inviter', key_field='inviter_key'), and the invitee accepting on the
group's home backend (handle_field='invitee', key_field='invitee_key'). Mirrors
verify_incoming_friend_request exactly, just with configurable field names."""
try:
username, domain, signed_data, signature_bytes_hex = verify_request(request, raw_request_body)
except ValueError:
return False
try:
claimed_handle = request.data[handle_field]
claimed_key = request.data[key_field]
except KeyError:
return False
if not claimed_handle or not claimed_key:
return False
if username + "@" + domain != claimed_handle:
return False
if len(claimed_key) != 64:
return False
verify_key = VerifyKey(bytes.fromhex(claimed_key))
try:
verify_key.verify(signed_data.encode('utf-8'), bytes.fromhex(signature_bytes_hex))
return True
except BadSignatureError:
return False
def authenticate_request_against_known_identities(request, raw_request_body):
try:
username, domain, signed_data, signature_bytes_hex = verify_request(request, raw_request_body)

View file

@ -3,7 +3,7 @@ import json
from django.test import TestCase, Client
from nacl.encoding import HexEncoder
from authentication.models import ToolshedUser, KnownIdentity
from authentication.models import ToolshedUser, KnownIdentity, Group
from hostadmin.models import Domain
from nacl.signing import SigningKey
@ -86,3 +86,9 @@ class UserTestMixin:
domain=self.f['example_com'].name)
self.f['ext_user1'] = DummyExternalUser('extuser1', 'external.org')
self.f['ext_user2'] = DummyExternalUser('extuser2', 'external.org')
class GroupTestMixin:
def prepare_groups(self):
self.f['group1'] = Group.objects.create(name='group1', domain=self.f['example_com'].name)
self.f['group1'].members.add(self.f['local_user1'].public_identity)

View file

@ -35,6 +35,7 @@ urlpatterns = [
path('auth/', include('authentication.api')),
path('admin/', include('hostadmin.api')),
path('api/', include('toolshed.api.friend')),
path('api/', include('toolshed.api.group')),
path('api/', include('toolshed.api.inventory')),
path('api/', include('toolshed.api.info')),
path('api/', include('toolshed.api.files')),

View file

@ -19,9 +19,9 @@ class ItemPropertyInline(admin.TabularInline):
class InventoryItemAdmin(admin.ModelAdmin):
list_display = ('name', 'description', 'category', 'availability_policy', 'owned_quantity', 'owner',
'storage_location', 'get_tags', 'get_properties')
'owner_group', 'storage_location', 'get_tags', 'get_properties')
search_fields = ('name', 'description', 'category__name', 'availability_policy', 'owner__username',
'storage_location__name', 'tags__name', 'itemproperty__property__name')
'owner_group__name', 'storage_location__name', 'tags__name', 'itemproperty__property__name')
inlines = (ItemTagInline, ItemPropertyInline)
def get_queryset(self, request):

View file

@ -0,0 +1,165 @@
import secrets
from django.urls import path
from rest_framework import status
from rest_framework.decorators import api_view, authentication_classes, permission_classes
from rest_framework.generics import get_object_or_404
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ViewSetMixin
from authentication.models import Group, GroupInvite, GroupInviteIncoming, KnownIdentity, ToolshedUser
from authentication.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
class Groups(APIView, ViewSetMixin):
authentication_classes = [SignatureAuthentication]
permission_classes = [IsAuthenticated]
def get(self, request, format=None): # /api/groups/
groups = request.user.member_of_groups.all()
return Response(GroupSerializer(groups, many=True).data)
def post(self, request, format=None): # /api/groups/
name = request.data.get('name')
if not name:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'name is required'})
if Group.objects.filter(name=name, domain=request.user.domain).exists():
return Response(status=status.HTTP_409_CONFLICT, data={'status': 'a group with this name already exists'})
group = Group.objects.create(name=name, domain=request.user.domain)
group.members.add(request.user)
return Response(status=status.HTTP_201_CREATED, data=GroupSerializer(group).data)
class GroupDetail(APIView, ViewSetMixin):
authentication_classes = [SignatureAuthentication]
permission_classes = [IsAuthenticated]
def get(self, request, pk, format=None): # /api/groups/<pk>/
group = get_object_or_404(Group, pk=pk)
if not group.is_member(request.user):
return Response(status=status.HTTP_404_NOT_FOUND)
return Response(GroupSerializer(group).data)
@api_view(['DELETE'])
@authentication_classes([SignatureAuthentication])
@permission_classes([IsAuthenticated])
def removeGroupMember(request, pk, identity_id, format=None): # /api/groups/<pk>/members/<identity_id>/
group = get_object_or_404(Group, pk=pk)
if not group.is_member(request.user):
return Response(status=status.HTTP_404_NOT_FOUND)
member = get_object_or_404(group.members, pk=identity_id)
if group.members.count() <= 1:
return Response(status=status.HTTP_400_BAD_REQUEST,
data={'status': "cannot remove the group's last member"})
group.members.remove(member)
return Response(status=status.HTTP_204_NO_CONTENT)
@api_view(['POST'])
@authentication_classes([SignatureAuthentication])
@permission_classes([IsAuthenticated])
def createGroupInvite(request, pk, format=None): # /api/groups/<pk>/invites/
group = get_object_or_404(Group, pk=pk)
if not group.is_member(request.user):
return Response(status=status.HTTP_404_NOT_FOUND)
invitee = request.data.get('invitee')
if not invitee:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invitee is required'})
try:
invitee_username, invitee_domain = split_userhandle_or_throw(invitee)
except ValueError:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid invitee handle'})
if group.members.filter(username=invitee_username, domain=invitee_domain).exists():
return Response(status=status.HTTP_208_ALREADY_REPORTED, data={'status': 'already a member'})
secret = secrets.token_hex(64)
GroupInvite.objects.create(group=group, invitee_username=invitee_username, invitee_domain=invitee_domain,
secret=secret)
return Response(status=status.HTTP_201_CREATED, data={'secret': secret, 'status': 'pending'})
class GroupInvitesIncoming(APIView, ViewSetMixin):
"""/api/groupinvites/ - the invitee's own view of their pending invites, and the delivery
endpoint an inviter's client posts to directly on the invitee's own home backend (see
docs/design-in-progress/groups-mvp.md's invite/accept dance)."""
def get(self, request, format=None): # /api/groupinvites/ - only ever a local user checking their own invites
raw_request = request.body.decode('utf-8')
if not (user := authenticate_request_against_local_users(request, raw_request)):
return Response(status=status.HTTP_401_UNAUTHORIZED, data={'status': 'unauthorized'})
invites = user.group_invites_incoming.all()
return Response(GroupInviteIncomingSerializer(invites, many=True).data)
def post(self, request, format=None): # /api/groupinvites/ - delivery, self-certified, caller isn't known here
raw_request = request.body.decode('utf-8')
for field in ('group', 'inviter', 'inviter_key', 'invitee', 'secret'):
if field not in request.data:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': f'missing {field}'})
if not verify_incoming_group_invite(request, raw_request, 'inviter', 'inviter_key'):
return Response(status=status.HTTP_401_UNAUTHORIZED, data={'status': 'unauthorized'})
try:
group_name, group_domain = split_grouphandle_or_throw(request.data['group'])
inviter_username, inviter_domain = split_userhandle_or_throw(request.data['inviter'])
invitee_username, invitee_domain = split_userhandle_or_throw(request.data['invitee'])
except ValueError:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid handle'})
try:
invitee_user = ToolshedUser.objects.get(username=invitee_username, domain=invitee_domain)
except ToolshedUser.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND, data={'status': 'invitee is not local to this server'})
GroupInviteIncoming.objects.create(
group_name=group_name, group_domain=group_domain,
inviter_username=inviter_username, inviter_domain=inviter_domain,
invitee_user=invitee_user, secret=request.data['secret'])
return Response(status=status.HTTP_201_CREATED, data={'status': 'delivered'})
@api_view(['DELETE'])
@authentication_classes([SignatureAuthenticationLocal])
@permission_classes([IsAuthenticated])
def declineGroupInvite(request, pk, format=None): # /api/groupinvites/<pk>/
get_object_or_404(request.user.group_invites_incoming, pk=pk).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@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')
for field in ('group', 'invitee', 'invitee_key', 'secret'):
if field not in request.data:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': f'missing {field}'})
if not verify_incoming_group_invite(request, raw_request, 'invitee', 'invitee_key'):
return Response(status=status.HTTP_401_UNAUTHORIZED, data={'status': 'unauthorized'})
try:
group_name, group_domain = split_grouphandle_or_throw(request.data['group'])
invitee_username, invitee_domain = split_userhandle_or_throw(request.data['invitee'])
except ValueError:
return Response(status=status.HTTP_400_BAD_REQUEST, data={'status': 'invalid handle'})
try:
group = Group.objects.get(name=group_name, domain=group_domain)
except Group.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND, data={'status': 'no such group here'})
invite = GroupInvite.objects.filter(group=group, invitee_username=invitee_username,
invitee_domain=invitee_domain, secret=request.data['secret'])
if not invite.exists():
return Response(status=status.HTTP_404_NOT_FOUND, data={'status': 'no matching invite'})
identity, _ = KnownIdentity.objects.get_or_create(
username=invitee_username, domain=invitee_domain, public_key=request.data['invitee_key'])
group.members.add(identity)
invite.delete()
return Response(status=status.HTTP_201_CREATED, data={'status': 'accepted'})
urlpatterns = [
path('groups/', Groups.as_view(), name='groups'),
path('groups/<int:pk>/', GroupDetail.as_view(), name='group_detail'),
path('groups/<int:pk>/members/<int:identity_id>/', removeGroupMember, name='remove_group_member'),
path('groups/<int:pk>/invites/', createGroupInvite, name='create_group_invite'),
path('groupinvites/', GroupInvitesIncoming.as_view(), name='group_invites_incoming'),
path('groupinvites/<int:pk>/', declineGroupInvite, name='decline_group_invite'),
path('group_invites/accept/', acceptGroupInvite, name='accept_group_invite'),
]

View file

@ -2,10 +2,11 @@ from django.db import transaction
from django.urls import path
from rest_framework import routers, viewsets, status
from rest_framework.decorators import authentication_classes, api_view, permission_classes, action
from rest_framework.exceptions import NotFound, PermissionDenied
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from authentication.models import ToolshedUser, KnownIdentity
from authentication.models import ToolshedUser, KnownIdentity, Group
from authentication.signature_auth import SignatureAuthentication, split_userhandle_or_throw
from files.models import File
from toolshed.models import InventoryItem, StorageLocation, WorkflowInstance
@ -36,21 +37,61 @@ class InventoryItemViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
def get_queryset(self):
if type(self.request.user) == KnownIdentity and self.request.user.user.exists():
return InventoryItem.objects.filter(owner=self.request.user.user.get())
# A KnownIdentity acting purely as a group member (e.g. a remote member on a group
# hosted on this backend) never has a local ToolshedUser account here - group-owned
# items must stay reachable for such an identity, only personal ("owner=...") items
# require .user.exists().
if type(self.request.user) != KnownIdentity:
return InventoryItem.objects.none()
identity = self.request.user
group_items = InventoryItem.objects.filter(owner_group__in=identity.member_of_groups.all())
if self.action != 'list':
# retrieve/update/destroy: anything the caller may act on - their own items, or any
# group they're currently a member of. The narrower per-group listing below is only
# for the list action, so the main Inventory page stays scoped to personal items.
if identity.user.exists():
return InventoryItem.objects.filter(owner=identity.user.get()) | group_items
return group_items
group_id = self.request.query_params.get('group')
if group_id:
try:
group = Group.objects.get(pk=group_id)
except Group.DoesNotExist:
return InventoryItem.objects.none()
if not group.is_member(identity):
return InventoryItem.objects.none()
return InventoryItem.objects.filter(owner_group=group)
if identity.user.exists():
return InventoryItem.objects.filter(owner=identity.user.get())
return InventoryItem.objects.none()
def perform_create(self, serializer):
group_id = self.request.data.get('owner_group')
with transaction.atomic():
if group_id:
try:
group = Group.objects.get(pk=group_id)
except Group.DoesNotExist:
raise NotFound('No such group')
if not group.is_member(self.request.user):
raise PermissionDenied('Not a member of this group')
serializer.save(owner=None, owner_group=group).clean()
else:
serializer.save(owner=self.request.user.user.get()).clean()
@staticmethod
def _is_authorized(request, instance):
if instance.owner_id:
return request.user.user.filter(pk=instance.owner_id).exists()
return instance.owner_group.is_member(request.user)
def perform_update(self, serializer):
with transaction.atomic():
if serializer.instance.owner == self.request.user.user.get():
if self._is_authorized(self.request, serializer.instance):
serializer.save().clean()
def perform_destroy(self, instance):
if instance.owner == self.request.user.user.get():
if self._is_authorized(self.request, instance):
instance.delete()

View file

@ -0,0 +1,27 @@
# Generated by Django 4.2.2 on 2026-08-19 13:44
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('authentication', '0005_group_groupinviteincoming_groupinvite_and_more'),
('toolshed', '0011_workflowinstance_staged_files'),
]
operations = [
migrations.AddField(
model_name='inventoryitem',
name='owner_group',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inventory_items', to='authentication.group'),
),
migrations.AlterField(
model_name='inventoryitem',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='inventory_items', to=settings.AUTH_USER_MODEL),
),
]

View file

@ -3,7 +3,7 @@ from django.core.validators import MinValueValidator, MaxValueValidator
from django_softdelete.models import SoftDeleteModel
from rest_framework.exceptions import ValidationError
from authentication.models import ToolshedUser, KnownIdentity
from authentication.models import ToolshedUser, KnownIdentity, Group
from files.models import File
@ -97,7 +97,10 @@ class InventoryItem(SoftDeleteModel):
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, related_name='inventory_items')
availability_policy = models.CharField(max_length=20, choices=AVAILABILITY_POLICY_CHOICES, default='private')
owned_quantity = models.IntegerField(default=1, validators=[MinValueValidator(0)])
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='inventory_items')
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, null=True, blank=True,
related_name='inventory_items')
owner_group = models.ForeignKey(Group, on_delete=models.CASCADE, null=True, blank=True,
related_name='inventory_items')
created_at = models.DateTimeField(auto_now_add=True)
tags = models.ManyToManyField(Tag, through='ItemTag', related_name='inventory_items')
properties = models.ManyToManyField(Property, through='ItemProperty')
@ -108,6 +111,8 @@ class InventoryItem(SoftDeleteModel):
def clean(self):
if (self.name is None or self.name == "") and self.files.count() == 0:
raise ValidationError("Name or at least one file must be set")
if (self.owner is None) == (self.owner_group is None):
raise ValidationError("Exactly one of owner or owner_group must be set")
class ItemProperty(models.Model):

View file

@ -1,6 +1,6 @@
from rest_framework import serializers
from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming
from authentication.serializers import OwnerSerializer
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
from toolshed.models import Category, Property, ItemProperty, InventoryItem, Tag, StorageLocation, WorkflowInstance
@ -68,6 +68,45 @@ class FriendRequestSerializer(serializers.ModelSerializer):
return obj.befriender_username + '@' + obj.befriender_domain
class GroupMemberSerializer(serializers.ModelSerializer):
username = serializers.SerializerMethodField()
class Meta:
model = KnownIdentity
fields = ['id', 'username', 'public_key']
def get_username(self, obj):
return obj.username + '@' + obj.domain
class GroupSerializer(serializers.ModelSerializer):
handle = serializers.SerializerMethodField()
members = GroupMemberSerializer(many=True, read_only=True)
class Meta:
model = Group
fields = ['id', 'name', 'domain', 'handle', 'members']
read_only_fields = ['domain', 'handle', 'members']
def get_handle(self, obj):
return str(obj)
class GroupInviteIncomingSerializer(serializers.ModelSerializer):
group = serializers.SerializerMethodField()
inviter = serializers.SerializerMethodField()
class Meta:
model = GroupInviteIncoming
fields = ['id', 'group', 'inviter', 'secret']
def get_group(self, obj):
return f"#{obj.group_name}@{obj.group_domain}"
def get_inviter(self, obj):
return obj.inviter_username + '@' + obj.inviter_domain
class PropertySerializer(serializers.ModelSerializer):
category = serializers.SerializerMethodField()
handle = serializers.SerializerMethodField()
@ -139,6 +178,7 @@ class ItemPropertySerializer(serializers.ModelSerializer):
class InventoryItemSerializer(serializers.ModelSerializer):
owner = OwnerSerializer(read_only=True)
owner_group = GroupOwnerSerializer(read_only=True)
tags = serializers.SerializerMethodField()
tags_input = serializers.ListField(child=serializers.CharField(), write_only=True, required=False)
properties = ItemPropertySerializer(many=True, required=False, source='itemproperty_set')
@ -147,8 +187,8 @@ class InventoryItemSerializer(serializers.ModelSerializer):
class Meta:
model = InventoryItem
fields = ['id', 'name', 'description', 'owner', 'category', 'availability_policy', 'owned_quantity', 'owner',
'tags', 'tags_input', 'properties', 'files', 'storage_location']
fields = ['id', 'name', 'description', 'owner', 'owner_group', 'category', 'availability_policy',
'owned_quantity', 'tags', 'tags_input', 'properties', 'files', 'storage_location']
def get_tags(self, obj):
return [tag.name for tag in obj.tags.all()]

View file

@ -0,0 +1,191 @@
from django.test import Client
from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase, \
DummyExternalUser
from authentication.models import Group, GroupInvite, GroupInviteIncoming, KnownIdentity
client = SignatureAuthClient()
class GroupModelTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
def setUp(self):
super().setUp()
self.prepare_users()
self.prepare_groups()
def test_group_str(self):
self.assertEqual(str(self.f['group1']), '#group1@' + self.f['example_com'].name)
def test_is_member(self):
self.assertTrue(self.f['group1'].is_member(self.f['local_user1'].public_identity))
self.assertFalse(self.f['group1'].is_member(self.f['local_user2'].public_identity))
class GroupApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
def setUp(self):
super().setUp()
self.prepare_users()
def test_create_group(self):
reply = client.post('/api/groups/', self.f['local_user1'], {'name': 'workshop'})
self.assertEqual(reply.status_code, 201)
group = Group.objects.get(name='workshop')
self.assertEqual(group.domain, self.f['local_user1'].domain)
self.assertTrue(group.is_member(self.f['local_user1'].public_identity))
def test_create_group_duplicate_name(self):
client.post('/api/groups/', self.f['local_user1'], {'name': 'workshop'})
reply = client.post('/api/groups/', self.f['local_user1'], {'name': 'workshop'})
self.assertEqual(reply.status_code, 409)
def test_create_group_missing_name(self):
reply = client.post('/api/groups/', self.f['local_user1'], {})
self.assertEqual(reply.status_code, 400)
def test_list_groups(self):
self.prepare_groups()
reply = client.get('/api/groups/', self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 1)
reply2 = client.get('/api/groups/', self.f['local_user2'])
self.assertEqual(reply2.status_code, 200)
self.assertEqual(len(reply2.json()), 0)
def test_group_detail_member(self):
self.prepare_groups()
reply = client.get('/api/groups/{}/'.format(self.f['group1'].id), self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(reply.json()['handle'], str(self.f['group1']))
self.assertEqual(len(reply.json()['members']), 1)
def test_group_detail_non_member(self):
self.prepare_groups()
reply = client.get('/api/groups/{}/'.format(self.f['group1'].id), self.f['local_user2'])
self.assertEqual(reply.status_code, 404)
def test_remove_member(self):
self.prepare_groups()
self.f['group1'].members.add(self.f['local_user2'].public_identity)
reply = client.delete('/api/groups/{}/members/{}/'.format(
self.f['group1'].id, self.f['local_user2'].public_identity.id), self.f['local_user1'])
self.assertEqual(reply.status_code, 204)
self.assertEqual(self.f['group1'].members.count(), 1)
def test_remove_last_member_blocked(self):
self.prepare_groups()
reply = client.delete('/api/groups/{}/members/{}/'.format(
self.f['group1'].id, self.f['local_user1'].public_identity.id), self.f['local_user1'])
self.assertEqual(reply.status_code, 400)
self.assertEqual(self.f['group1'].members.count(), 1)
def test_remove_member_non_member_denied(self):
self.prepare_groups()
self.f['group1'].members.add(self.f['local_user2'].public_identity)
reply = client.delete('/api/groups/{}/members/{}/'.format(
self.f['group1'].id, self.f['local_user1'].public_identity.id), self.f['ext_user1'])
self.assertEqual(reply.status_code, 404)
self.assertEqual(self.f['group1'].members.count(), 2)
class GroupInviteApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
def setUp(self):
super().setUp()
self.prepare_users()
self.prepare_groups()
def test_invite_local_member_full_flow(self):
group = self.f['group1']
inviter = self.f['local_user1']
invitee = self.f['local_user2']
reply1 = client.post('/api/groups/{}/invites/'.format(group.id), inviter, {'invitee': str(invitee)})
self.assertEqual(reply1.status_code, 201)
secret = reply1.json()['secret']
self.assertEqual(GroupInvite.objects.count(), 1)
reply2 = client.post('/api/groupinvites/', inviter, {
'group': str(group), 'inviter': str(inviter), 'inviter_key': inviter.public_key(),
'invitee': str(invitee), 'secret': secret
})
self.assertEqual(reply2.status_code, 201)
incoming = GroupInviteIncoming.objects.get()
self.assertEqual(incoming.invitee_user, invitee)
reply3 = client.get('/api/groupinvites/', invitee)
self.assertEqual(reply3.status_code, 200)
self.assertEqual(len(reply3.json()), 1)
self.assertEqual(reply3.json()[0]['group'], str(group))
self.assertEqual(reply3.json()[0]['inviter'], str(inviter))
reply4 = client.post('/api/group_invites/accept/', invitee, {
'group': str(group), 'invitee': str(invitee), 'invitee_key': invitee.public_key(), 'secret': secret
})
self.assertEqual(reply4.status_code, 201)
self.assertTrue(group.is_member(invitee.public_identity))
self.assertEqual(GroupInvite.objects.count(), 0)
reply5 = client.delete('/api/groupinvites/{}/'.format(incoming.id), invitee)
self.assertEqual(reply5.status_code, 204)
self.assertEqual(GroupInviteIncoming.objects.count(), 0)
def test_invite_non_member_denied(self):
group = self.f['group1']
reply = client.post('/api/groups/{}/invites/'.format(group.id), self.f['local_user2'],
{'invitee': str(self.f['ext_user1'])})
self.assertEqual(reply.status_code, 404)
self.assertEqual(GroupInvite.objects.count(), 0)
def test_invite_already_member(self):
group = self.f['group1']
group.members.add(self.f['local_user2'].public_identity)
reply = client.post('/api/groups/{}/invites/'.format(group.id), self.f['local_user1'],
{'invitee': str(self.f['local_user2'])})
self.assertEqual(reply.status_code, 208)
def test_decline_invite(self):
invite = GroupInviteIncoming.objects.create(
group_name=self.f['group1'].name, group_domain=self.f['group1'].domain,
inviter_username=self.f['local_user1'].username, inviter_domain=self.f['local_user1'].domain,
invitee_user=self.f['local_user2'], secret='some-secret')
reply = client.delete('/api/groupinvites/{}/'.format(invite.id), self.f['local_user2'])
self.assertEqual(reply.status_code, 204)
self.assertEqual(GroupInviteIncoming.objects.count(), 0)
def test_accept_wrong_secret(self):
group = self.f['group1']
inviter = self.f['local_user1']
invitee = self.f['local_user2']
client.post('/api/groups/{}/invites/'.format(group.id), inviter, {'invitee': str(invitee)})
reply = client.post('/api/group_invites/accept/', invitee, {
'group': str(group), 'invitee': str(invitee), 'invitee_key': invitee.public_key(),
'secret': 'not-the-right-secret'
})
self.assertEqual(reply.status_code, 404)
self.assertFalse(group.is_member(invitee.public_identity))
def test_accept_remote_member(self):
group = self.f['group1']
inviter = self.f['local_user1']
invitee = DummyExternalUser('newmember', 'remote.example', known=False)
reply1 = client.post('/api/groups/{}/invites/'.format(group.id), inviter, {'invitee': str(invitee)})
secret = reply1.json()['secret']
reply = client.post('/api/group_invites/accept/', invitee, {
'group': str(group), 'invitee': str(invitee), 'invitee_key': invitee.public_key(), 'secret': secret
})
self.assertEqual(reply.status_code, 201)
identity = KnownIdentity.objects.get(username='newmember', domain='remote.example')
self.assertTrue(group.is_member(identity))
def test_accept_bad_signature(self):
group = self.f['group1']
inviter = self.f['local_user1']
invitee = self.f['local_user2']
reply1 = client.post('/api/groups/{}/invites/'.format(group.id), inviter, {'invitee': str(invitee)})
secret = reply1.json()['secret']
bad_signature_client = SignatureAuthClient(bad_signature=True)
reply = bad_signature_client.post('/api/group_invites/accept/', invitee, {
'group': str(group), 'invitee': str(invitee), 'invitee_key': invitee.public_key(), 'secret': secret
})
self.assertEqual(reply.status_code, 401)
self.assertFalse(group.is_member(invitee.public_identity))

View file

@ -1,4 +1,4 @@
from authentication.tests import SignatureAuthClient, UserTestMixin, ToolshedTestCase
from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase
from files.tests import FilesTestMixin
from toolshed.models import InventoryItem, Category
from toolshed.tests import InventoryTestMixin
@ -334,3 +334,86 @@ class TestInventoryItemWithFileApiTestCase(UserTestMixin, FilesTestMixin, Invent
'files': [{'data': self.f['encoded_content3']}]
})
self.assertEqual(reply.status_code, 400)
class GroupOwnedInventoryApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
def setUp(self):
super().setUp()
self.prepare_users()
self.prepare_groups()
self.f['group1'].members.add(self.f['local_user2'].public_identity)
def create_group_item(self, name='drill'):
return client.post('/api/inventory_items/', self.f['local_user1'], {
'name': name, 'owned_quantity': 1, 'availability_policy': 'private',
'owner_group': self.f['group1'].id,
})
def test_create_group_owned_item(self):
reply = self.create_group_item()
self.assertEqual(reply.status_code, 201)
item = InventoryItem.objects.get(name='drill')
self.assertIsNone(item.owner)
self.assertEqual(item.owner_group, self.f['group1'])
self.assertEqual(reply.json()['owner_group'], str(self.f['group1']))
self.assertIsNone(reply.json()['owner'])
def test_create_group_owned_item_non_member_denied(self):
reply = client.post('/api/inventory_items/', self.f['ext_user1'], {
'name': 'drill', 'owned_quantity': 1, 'availability_policy': 'private',
'owner_group': self.f['group1'].id,
})
self.assertEqual(reply.status_code, 403)
self.assertEqual(InventoryItem.objects.count(), 0)
def test_other_member_can_edit(self):
item_id = self.create_group_item().json()['id']
reply = client.patch('/api/inventory_items/{}/'.format(item_id), self.f['local_user2'], {
'name': 'drill-renamed'
})
self.assertEqual(reply.status_code, 200)
self.assertEqual(InventoryItem.objects.get(id=item_id).name, 'drill-renamed')
def test_other_member_can_delete(self):
item_id = self.create_group_item().json()['id']
reply = client.delete('/api/inventory_items/{}/'.format(item_id), self.f['local_user2'])
self.assertEqual(reply.status_code, 204)
self.assertEqual(InventoryItem.objects.filter(id=item_id).count(), 0)
def test_remote_member_without_local_account_can_edit(self):
# A remote member (no ToolshedUser row at all on this backend, only a KnownIdentity -
# see docs/design-in-progress/groups-mvp.md) must still be able to act on group-owned
# items here; it must not be treated as unauthorized just because .user.exists() is False.
self.f['group1'].members.add(self.f['ext_user1'].public_identity)
item_id = self.create_group_item().json()['id']
reply = client.get('/api/inventory_items/{}/'.format(item_id), self.f['ext_user1'])
self.assertEqual(reply.status_code, 200)
reply = client.patch('/api/inventory_items/{}/'.format(item_id), self.f['ext_user1'], {
'name': 'drill-renamed-by-remote-member'
})
self.assertEqual(reply.status_code, 200)
self.assertEqual(InventoryItem.objects.get(id=item_id).name, 'drill-renamed-by-remote-member')
def test_non_member_cannot_see_or_edit(self):
item_id = self.create_group_item().json()['id']
reply = client.get('/api/inventory_items/{}/'.format(item_id), self.f['ext_user1'])
self.assertEqual(reply.status_code, 404)
def test_group_items_excluded_from_personal_list(self):
self.create_group_item()
reply = client.get('/api/inventory_items/', self.f['local_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 0)
def test_group_items_listed_by_group_query_param(self):
self.create_group_item()
reply = client.get('/api/inventory_items/?group={}'.format(self.f['group1'].id), self.f['local_user2'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 1)
self.assertEqual(reply.json()[0]['name'], 'drill')
def test_group_items_not_listed_for_non_member_query_param(self):
self.create_group_item()
reply = client.get('/api/inventory_items/?group={}'.format(self.f['group1'].id), self.f['ext_user1'])
self.assertEqual(reply.status_code, 200)
self.assertEqual(len(reply.json()), 0)

View file

@ -3,6 +3,9 @@ import socketserver
import urllib.parse
import dnslib
import base64
import socket
UPSTREAM_DNS = ("8.8.8.8", 53)
try:
@ -11,6 +14,13 @@ try:
if record["name"] == qname and record["type"] == qtype and "value" in record:
return record["value"]
def resolve_recursive(raw_query):
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.settimeout(5)
sock.sendto(raw_query, UPSTREAM_DNS)
data, _ = sock.recvfrom(4096)
return data
class DnsHttpRequestHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
@ -40,15 +50,16 @@ try:
print("SRV record")
reply = dnslib.SRV(record["priority"], record["weight"], record["port"], record["target"])
response.add_answer(dnslib.RR(dns.q.qname, dns.q.qtype, rdata=reply))
pack = response.pack()
else:
response.header.rcode = dnslib.RCODE.NXDOMAIN
print(f"Recursively resolving {dns.q.qname}")
pack = resolve_recursive(raw)
print(response)
self.send_response(200)
self.send_header("Content-type", "application/dns-message")
self.end_headers()
pack = response.pack()
self.wfile.write(pack)
return
except Exception as e:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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