Compare commits

...

10 commits

Author SHA1 Message Date
906adbe0db stash 2024-01-07 17:51:38 +01:00
b7a582a86f stash 2024-01-07 17:51:38 +01:00
b00976e0fa stash 2024-01-07 17:51:38 +01:00
b8c3bcfa3b stash 2024-01-07 17:51:38 +01:00
674106a8a5 stash 2024-01-07 17:51:38 +01:00
81f1f97a6b stash 2024-01-07 17:51:38 +01:00
125232d1c5 stash 2024-01-07 17:51:35 +01:00
2c09540a1b stash 2024-01-07 17:51:26 +01:00
b6ed492382 stash 2024-01-07 17:51:26 +01:00
6c69948c44 stash 2024-01-07 17:51:26 +01:00
30 changed files with 769 additions and 292 deletions

26
TODO.md
View file

@ -71,8 +71,30 @@
* [ ] concept: purge old tickets * [ ] concept: purge old tickets
* [ ] concept: purge old items * [ ] concept: purge old items
* [ ] concept: auto email stale after x days * [ ] concept: auto email stale after x days
* [ ] frontend: reply email field multiline
* [ ] concept: customisable autoreply (message, title) + ticket id
* [ ] frontend: show from address in issueThread or internal user
* [ ] manual ticket creation
* [ ] hide citation for incoming mails
* [ ] frontend: different icons for send and received mails
* [ ] frontend: change ticket status
* new mail -> new,
* op reply ->
* waiting for detail
* waiting for address / shipment information
* customer reply -> open
* needs physical confirmation
* needs to be shipped
* dhl mail -> closed: shipped
* closed: not found
* closed: not our problem
* closed: timeout (no reply)
* closed: duplicate
* closed: spam
* [ ] concept: split ticket for multiple items
* [ ] mail signature
* [ ] guru api integration
## Priority: TODO ## Priority: TODO
* send mails from web frontend * manual ticket creation
* login / user management

View file

@ -7,6 +7,7 @@ from django.contrib.auth import login
from django.urls import path from django.urls import path
from django.dispatch import receiver from django.dispatch import receiver
from django.db.models.signals import post_save from django.db.models.signals import post_save
from django.contrib.auth.models import Group
from knox.models import AuthToken from knox.models import AuthToken
from knox.views import LoginView as KnoxLoginView from knox.views import LoginView as KnoxLoginView
@ -14,9 +15,16 @@ from authentication.models import ExtendedUser
class UserSerializer(serializers.ModelSerializer): class UserSerializer(serializers.ModelSerializer):
permissions = serializers.SerializerMethodField()
groups = serializers.SlugRelatedField(many=True, read_only=True, slug_field='name')
class Meta: class Meta:
model = ExtendedUser model = ExtendedUser
fields = ('id', 'username', 'email', 'first_name', 'last_name') fields = ('id', 'username', 'email', 'first_name', 'last_name', 'permissions', 'groups')
read_only_fields = ('id', 'username', 'email', 'first_name', 'last_name', 'permissions', 'groups')
def get_permissions(self, obj):
return list(set(obj.get_permissions()))
@receiver(post_save, sender=ExtendedUser) @receiver(post_save, sender=ExtendedUser)
@ -30,7 +38,27 @@ class UserViewSet(viewsets.ModelViewSet):
serializer_class = UserSerializer serializer_class = UserSerializer
@api_view(['POST']) class GroupSerializer(serializers.ModelSerializer):
permissions = serializers.SerializerMethodField()
members = serializers.SerializerMethodField()
class Meta:
model = Group
fields = ('id', 'name', 'permissions', 'members')
def get_permissions(self, obj):
return ["*:" + p.codename for p in obj.permissions.all()]
def get_members(self, obj):
return [u.username for u in obj.user_set.all()]
class GroupViewSet(viewsets.ModelViewSet):
queryset = Group.objects.all()
serializer_class = GroupSerializer
@api_view(['GET'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
def selfUser(request): def selfUser(request):
serializer = UserSerializer(request.user) serializer = UserSerializer(request.user)
@ -79,6 +107,7 @@ class LoginView(KnoxLoginView):
router = routers.SimpleRouter() router = routers.SimpleRouter()
router.register(r'users', UserViewSet, basename='users') router.register(r'users', UserViewSet, basename='users')
router.register(r'groups', GroupViewSet, basename='groups')
urlpatterns = router.urls + [ urlpatterns = router.urls + [
path('self/', selfUser), path('self/', selfUser),

View file

@ -1,20 +0,0 @@
# Generated by Django 4.2.7 on 2023-12-13 02:29
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('inventory', '0001_initial'),
('authentication', '0004_legacy_user'),
]
operations = [
migrations.AlterField(
model_name='eventpermission',
name='event',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='inventory.event'),
),
]

View file

@ -65,7 +65,6 @@ class PermissionsTestCase(TestCase):
user.event_permissions.create(permission=Permission.objects.get(codename='view_item'), event=Event.objects.get(slug='testevent2')) user.event_permissions.create(permission=Permission.objects.get(codename='view_item'), event=Event.objects.get(slug='testevent2'))
user.event_permissions.create(permission=Permission.objects.get(codename='add_item'), event=Event.objects.get(slug='testevent1')) user.event_permissions.create(permission=Permission.objects.get(codename='add_item'), event=Event.objects.get(slug='testevent1'))
user.save() user.save()
print(user.get_all_permissions())
#self.assertTrue(user.has_perm('inventory.view_event', Event.objects.get(slug='testevent1'))) #self.assertTrue(user.has_perm('inventory.view_event', Event.objects.get(slug='testevent1')))
#self.assertTrue(user.has_perm('inventory.view_event', Event.objects.get(slug='testevent2'))) #self.assertTrue(user.has_perm('inventory.view_event', Event.objects.get(slug='testevent2')))
#self.assertFalse(user.has_perm('inventory.add_event', Event.objects.get(slug='testevent1'))) #self.assertFalse(user.has_perm('inventory.add_event', Event.objects.get(slug='testevent1')))

View file

@ -1,17 +1,30 @@
from django.test import TestCase, Client from django.test import TestCase, Client
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission, Group
from knox.models import AuthToken from knox.models import AuthToken
from authentication.models import ExtendedUser from authentication.models import ExtendedUser, EventPermission
from core import settings from core import settings
from inventory.models import Event
class UserApiTest(TestCase): class UserApiTest(TestCase):
def setUp(self): def setUp(self):
self.event = Event.objects.create(name='testevent', slug='testevent')
self.group1 = Group.objects.create(name='testgroup1')
self.group2 = Group.objects.create(name='testgroup2')
self.group1.permissions.add(Permission.objects.get(codename='add_item'))
self.group1.permissions.add(Permission.objects.get(codename='view_item'))
self.group2.permissions.add(Permission.objects.get(codename='view_event'))
self.group2.permissions.add(Permission.objects.get(codename='view_item'))
self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test') self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test')
self.user.user_permissions.add(*Permission.objects.all()) self.user.user_permissions.add(Permission.objects.get(codename='add_event'))
self.user.groups.add(self.group1)
self.user.groups.add(self.group2)
self.user.save()
EventPermission.objects.create(event=self.event, user=self.user,
permission=Permission.objects.get(codename='delete_item'))
self.user.save() self.user.save()
self.token = AuthToken.objects.create(user=self.user) self.token = AuthToken.objects.create(user=self.user)
self.client = Client(headers={'Authorization': 'Token ' + self.token[1]}) self.client = Client(headers={'Authorization': 'Token ' + self.token[1]})
@ -25,18 +38,28 @@ class UserApiTest(TestCase):
self.assertEqual(response.json()[0]['first_name'], '') self.assertEqual(response.json()[0]['first_name'], '')
self.assertEqual(response.json()[0]['last_name'], '') self.assertEqual(response.json()[0]['last_name'], '')
self.assertEqual(response.json()[0]['id'], 1) self.assertEqual(response.json()[0]['id'], 1)
self.assertEqual(response.json()[0]['groups'], [])
self.assertEqual(response.json()[1]['username'], 'testuser') self.assertEqual(response.json()[1]['username'], 'testuser')
self.assertEqual(response.json()[1]['email'], 'test') self.assertEqual(response.json()[1]['email'], 'test')
self.assertEqual(response.json()[1]['first_name'], '') self.assertEqual(response.json()[1]['first_name'], '')
self.assertEqual(response.json()[1]['last_name'], '') self.assertEqual(response.json()[1]['last_name'], '')
self.assertEqual(response.json()[1]['id'], 2)
self.assertEqual(response.json()[1]['groups'], ['testgroup1', 'testgroup2'])
def test_self_user(self): def test_self_user(self):
response = self.client.post('/api/2/self/') response = self.client.get('/api/2/self/')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['username'], 'testuser') self.assertEqual(response.json()['username'], 'testuser')
self.assertEqual(response.json()['email'], 'test') self.assertEqual(response.json()['email'], 'test')
self.assertEqual(response.json()['first_name'], '') self.assertEqual(response.json()['first_name'], '')
self.assertEqual(response.json()['last_name'], '') self.assertEqual(response.json()['last_name'], '')
permissions = response.json()['permissions']
self.assertEqual(len(permissions), 5)
self.assertTrue('*:add_item' in permissions)
self.assertTrue('*:view_item' in permissions)
self.assertTrue('*:view_event' in permissions)
self.assertTrue('testevent:delete_item' in permissions)
self.assertTrue('*:add_event' in permissions)
def test_register_user(self): def test_register_user(self):
anonymous = Client() anonymous = Client()
@ -112,3 +135,49 @@ class UserApiTest(TestCase):
content_type='application/json') content_type='application/json')
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertTrue('token' in response.json()) self.assertTrue('token' in response.json())
class GroupApiTest(TestCase):
def setUp(self):
self.event = Event.objects.create(name='testevent', slug='testevent')
# Admin, Orga, Team, User are created by default
self.group1 = Group.objects.create(name='testgroup1')
self.group2 = Group.objects.create(name='testgroup2')
self.group1.permissions.add(Permission.objects.get(codename='add_item'))
self.group1.permissions.add(Permission.objects.get(codename='view_item'))
self.group2.permissions.add(Permission.objects.get(codename='view_event'))
self.group2.permissions.add(Permission.objects.get(codename='view_item'))
self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test')
self.user.user_permissions.add(Permission.objects.get(codename='add_event'))
self.user.groups.add(self.group1)
self.user.groups.add(self.group2)
self.user.save()
EventPermission.objects.create(event=self.event, user=self.user,
permission=Permission.objects.get(codename='delete_item'))
self.user.save()
self.token = AuthToken.objects.create(user=self.user)
self.client = Client(headers={'Authorization': 'Token ' + self.token[1]})
def test_groups(self):
response = self.client.get('/api/2/groups/')
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.json()), 6)
self.assertEqual(response.json()[0]['name'], 'Admin')
self.assertEqual(response.json()[1]['name'], 'Orga')
self.assertEqual(response.json()[2]['name'], 'Team')
self.assertEqual(response.json()[3]['name'], 'User')
self.assertEqual(response.json()[4]['name'], 'testgroup1')
self.assertEqual(response.json()[5]['name'], 'testgroup2')
def test_group(self):
response = self.client.get('/api/2/groups/5/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['name'], 'testgroup1')
permissions = response.json()['permissions']
self.assertEqual(len(permissions), 2)
self.assertTrue('*:add_item' in permissions)
self.assertTrue('*:view_item' in permissions)
members = response.json()['members']
self.assertEqual(len(members), 1)
self.assertEqual(members[0], 'testuser')

View file

@ -29,7 +29,7 @@ SECRET_KEY = 'django-insecure-tm*$w_14iqbiy-!7(8#ba7j+_@(7@rf2&a^!=shs&$03b%2*rv
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True
ALLOWED_HOSTS = [os.getenv('HTTP_HOST', 'localhost')] ALLOWED_HOSTS = [os.getenv('HTTP_HOST', 'localhost'), 'c3lf.de']
MAIL_DOMAIN = os.getenv('MAIL_DOMAIN', 'localhost') MAIL_DOMAIN = os.getenv('MAIL_DOMAIN', 'localhost')

View file

@ -97,7 +97,7 @@ class ItemSerializer(serializers.ModelSerializer):
@api_view(['GET']) @api_view(['GET'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
@permission_required('inventory.view_item', raise_exception=True) @permission_required('view_item', raise_exception=True)
def search_items(request, event_slug, query): def search_items(request, event_slug, query):
try: try:
event = Event.objects.get(slug=event_slug) event = Event.objects.get(slug=event_slug)
@ -117,11 +117,11 @@ def item(request, event_slug):
try: try:
event = Event.objects.get(slug=event_slug) event = Event.objects.get(slug=event_slug)
if request.method == 'GET': if request.method == 'GET':
if not request.user.has_event_perm(event, 'inventory.view_item'): if not request.user.has_event_perm(event, 'view_item'):
return Response(status=403) return Response(status=403)
return Response(ItemSerializer(Item.objects.filter(event=event), many=True).data) return Response(ItemSerializer(Item.objects.filter(event=event), many=True).data)
elif request.method == 'POST': elif request.method == 'POST':
if not request.user.has_event_perm(event, 'inventory.add_item'): if not request.user.has_event_perm(event, 'add_item'):
return Response(status=403) return Response(status=403)
validated_data = ItemSerializer(data=request.data) validated_data = ItemSerializer(data=request.data)
if validated_data.is_valid(): if validated_data.is_valid():
@ -131,25 +131,34 @@ def item(request, event_slug):
return Response(status=404) return Response(status=404)
@api_view(['GET', 'PUT', 'DELETE']) @api_view(['GET', 'PUT', 'DELETE', 'PATCH'])
@permission_classes([IsAuthenticated]) @permission_classes([IsAuthenticated])
def item_by_id(request, event_slug, id): def item_by_id(request, event_slug, id):
try: try:
event = Event.objects.get(slug=event_slug) event = Event.objects.get(slug=event_slug)
item = Item.objects.get(event=event, uid=id) item = Item.objects.get(event=event, uid=id)
if request.method == 'GET': if request.method == 'GET':
if not request.user.has_event_perm(event, 'inventory.view_item'): if not request.user.has_event_perm(event, 'view_item'):
return Response(status=403) return Response(status=403)
return Response(ItemSerializer(item).data) return Response(ItemSerializer(item).data)
elif request.method == 'PUT': elif request.method == 'PUT':
if not request.user.has_event_perm(event, 'inventory.change_item'): if not request.user.has_event_perm(event, 'change_item'):
return Response(status=403) return Response(status=403)
validated_data = ItemSerializer(item, data=request.data) validated_data = ItemSerializer(item, data=request.data)
if validated_data.is_valid(): if validated_data.is_valid():
validated_data.save() validated_data.save()
return Response(validated_data.data) return Response(validated_data.data)
return Response(validated_data.errors, status=400)
elif request.method == 'PATCH':
if not request.user.has_event_perm(event, 'change_item'):
return Response(status=403)
validated_data = ItemSerializer(item, data=request.data, partial=True)
if validated_data.is_valid():
validated_data.save()
return Response(validated_data.data)
return Response(validated_data.errors, status=400)
elif request.method == 'DELETE': elif request.method == 'DELETE':
if not request.user.has_event_perm(event, 'inventory.delete_item'): if not request.user.has_event_perm(event, 'delete_item'):
return Response(status=403) return Response(status=403)
item.delete() item.delete()
return Response(status=204) return Response(status=204)

View file

@ -1,16 +1,18 @@
import logging import logging
from django.urls import path from django.urls import re_path
from django.contrib.auth.decorators import permission_required from django.contrib.auth.decorators import permission_required
from rest_framework import routers, viewsets, serializers, status from rest_framework import routers, viewsets, serializers, status
from rest_framework.decorators import api_view, permission_classes from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from core.settings import MAIL_DOMAIN from core.settings import MAIL_DOMAIN
from mail.models import Email from mail.models import Email
from mail.protocol import send_smtp, make_reply, collect_references from mail.protocol import send_smtp, make_reply, collect_references
from notify_sessions.models import SystemEvent
from tickets.models import IssueThread from tickets.models import IssueThread
@ -80,9 +82,44 @@ def reply(request, pk):
return Response({'status': 'ok'}, status=status.HTTP_201_CREATED) return Response({'status': 'ok'}, status=status.HTTP_201_CREATED)
@api_view(['POST'])
@permission_classes([IsAuthenticated])
@permission_required('tickets.add_issuethread_manual', raise_exception=True)
def manual_ticket(request):
if 'name' not in request.data:
return Response({'status': 'error', 'message': 'missing name'}, status=status.HTTP_400_BAD_REQUEST)
if 'sender' not in request.data:
return Response({'status': 'error', 'message': 'missing sender'}, status=status.HTTP_400_BAD_REQUEST)
if 'recipient' not in request.data:
return Response({'status': 'error', 'message': 'missing recipient'}, status=status.HTTP_400_BAD_REQUEST)
if 'body' not in request.data:
return Response({'status': 'error', 'message': 'missing body'}, status=status.HTTP_400_BAD_REQUEST)
issue = IssueThread.objects.create(
name=request.data['name'],
manually_created=True,
)
email = Email.objects.create(
issue_thread=issue,
sender=request.data['sender'],
recipient=request.data['recipient'],
subject=request.data['name'],
body=request.data['body'],
)
systemevent = SystemEvent.objects.create(type='email received', reference=email.id)
channel_layer = get_channel_layer()
async_to_sync(channel_layer.group_send)(
'general', {"type": "generic.event", "name": "send_message_to_frontend", "event_id": systemevent.id,
"message": "email received"}
)
return Response(IssueSerializer(issue).data, status=status.HTTP_201_CREATED)
router = routers.SimpleRouter() router = routers.SimpleRouter()
router.register(r'tickets', IssueViewSet, basename='issues') router.register(r'tickets', IssueViewSet, basename='issues')
urlpatterns = router.urls + [ urlpatterns = ([
path('tickets/<int:pk>/reply/', reply, name='reply'), re_path(r'^tickets/(?P<pk>\d+)/reply/$', reply, name='reply'),
] re_path(r'^tickets/manual/$', manual_ticket, name='manual_ticket'),
] + router.urls)

View file

@ -0,0 +1,22 @@
# Generated by Django 4.2.7 on 2023-12-22 20:57
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('tickets', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='issuethread',
options={'permissions': [('send_mail', 'Can send mail'), ('add_issuethread_manual', 'Can add issue thread manually')]},
),
migrations.AddField(
model_name='issuethread',
name='manually_created',
field=models.BooleanField(default=False),
),
]

View file

@ -10,10 +10,12 @@ class IssueThread(SoftDeleteModel):
state = models.CharField(max_length=255, default='new') state = models.CharField(max_length=255, default='new')
assigned_to = models.CharField(max_length=255, null=True) assigned_to = models.CharField(max_length=255, null=True)
last_activity = models.DateTimeField(auto_now=True) last_activity = models.DateTimeField(auto_now=True)
manually_created = models.BooleanField(default=False)
class Meta: class Meta:
permissions = [ permissions = [
('send_mail', 'Can send mail'), ('send_mail', 'Can send mail'),
('add_issuethread_manual', 'Can add issue thread manually'),
] ]

View file

@ -5,7 +5,7 @@ from django.test import TestCase, Client
from authentication.models import ExtendedUser from authentication.models import ExtendedUser
from mail.models import Email from mail.models import Email
from tickets.models import IssueThread, StateChange, Comment from tickets.models import IssueThread, StateChange, Comment
from django.contrib.auth.models import User from django.contrib.auth.models import Permission
from knox.models import AuthToken from knox.models import AuthToken
@ -14,6 +14,7 @@ class IssueApiTest(TestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test') self.user = ExtendedUser.objects.create_user('testuser', 'test', 'test')
self.user.user_permissions.add(*Permission.objects.all())
self.user.save() self.user.save()
self.token = AuthToken.objects.create(user=self.user) self.token = AuthToken.objects.create(user=self.user)
self.client = Client(headers={'Authorization': 'Token ' + self.token[1]}) self.client = Client(headers={'Authorization': 'Token ' + self.token[1]})
@ -91,3 +92,19 @@ class IssueApiTest(TestCase):
self.assertEqual(response.json()[0]['timeline'][3]['comment'], 'test') self.assertEqual(response.json()[0]['timeline'][3]['comment'], 'test')
self.assertEqual(response.json()[0]['timeline'][3]['timestamp'], self.assertEqual(response.json()[0]['timeline'][3]['timestamp'],
comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ')) comment.timestamp.strftime('%Y-%m-%dT%H:%M:%S.%fZ'))
def test_manual_creation(self):
response = self.client.post('/api/2/tickets/manual/', {'name': 'test issue', 'sender': 'test',
'recipient': 'test', 'body': 'test'})
self.assertEqual(response.status_code, 201)
self.assertEqual(response.json()['state'], 'new')
self.assertEqual(response.json()['name'], 'test issue')
self.assertEqual(response.json()['assigned_to'], None)
timeline = response.json()['timeline']
self.assertEqual(len(timeline), 1)
self.assertEqual(timeline[0]['type'], 'mail')
self.assertEqual(timeline[0]['sender'], 'test')
self.assertEqual(timeline[0]['recipient'], 'test')
self.assertEqual(timeline[0]['subject'], 'test issue')
self.assertEqual(timeline[0]['body'], 'test')

View file

@ -1,12 +1,13 @@
<template> <template>
<div id="app"> <div id="app">
<AddItemModal v-if="addModalOpen && isLoggedIn" @close="closeAddModal()" isModal="true"/> <AddItemModal v-if="addItemModalOpen && isLoggedIn" @close="closeAddItemModal()" isModal="true"/>
<Navbar v-if="isLoggedIn" @addClicked="openAddModal()"/> <AddTicketModal v-if="addTicketModalOpen && isLoggedIn" @close="closeAddTicketModal()" isModal="true"/>
<Navbar v-if="isLoggedIn" @addItemClicked="openAddItemModal()" @addTicketClicked="openAddTicketModal()"/>
<router-view/> <router-view/>
<div aria-live="polite" aria-atomic="true" v-if="isLoggedIn" <div aria-live="polite" aria-atomic="true" v-if="isLoggedIn"
class="d-flex justify-content-end align-items-start fixed-top mx-1 my-5 py-3" class="d-flex justify-content-end align-items-start fixed-top mx-1 my-5 py-3"
style="min-height: 200px; z-index: 100000; pointer-events: none"> style="min-height: 200px; z-index: 100000; pointer-events: none">
<Toast v-for="toast in toasts" :key="toast" :title="toast.title" :message="toast.message" <Toast v-for="(toast , index) in toasts" :key="index" :title="toast.title" :message="toast.message"
:color="toast.color" :color="toast.color"
@close="removeToast(toast.key)" style="pointer-events: auto"/> @close="removeToast(toast.key)" style="pointer-events: auto"/>
</div> </div>
@ -18,27 +19,35 @@ import Navbar from '@/components/Navbar';
import AddItemModal from '@/components/AddItemModal'; import AddItemModal from '@/components/AddItemModal';
import Toast from './components/Toast'; import Toast from './components/Toast';
import {mapState, mapMutations, mapActions, mapGetters} from 'vuex'; import {mapState, mapMutations, mapActions, mapGetters} from 'vuex';
import AddTicketModal from "@/components/AddTicketModal.vue";
export default { export default {
name: 'app', name: 'app',
components: {Toast, Navbar, AddItemModal}, components: {Toast, Navbar, AddItemModal, AddTicketModal},
computed: { computed: {
...mapState(['loadedItems', 'layout', 'toasts']), ...mapState(['loadedItems', 'layout', 'toasts']),
...mapGetters(['isLoggedIn']), ...mapGetters(['isLoggedIn']),
}, },
data: () => ({ data: () => ({
addModalOpen: false, addItemModalOpen: false,
addTicketModalOpen: false,
notify_socket: null, notify_socket: null,
socket_toast: null, socket_toast: null,
}), }),
methods: { methods: {
...mapMutations(['removeToast', 'createToast']), ...mapMutations(['removeToast', 'createToast']),
...mapActions(['loadEventItems', 'loadTickets']), ...mapActions(['loadEventItems', 'loadTickets']),
openAddModal() { openAddItemModal() {
this.addModalOpen = true; this.addItemModalOpen = true;
}, },
closeAddModal() { openAddTicketModal() {
this.addModalOpen = false; this.addTicketModalOpen = true;
},
closeAddItemModal() {
this.addItemModalOpen = false;
},
closeAddTicketModal() {
this.addTicketModalOpen = false;
}, },
tryConnect() { tryConnect() {
if (!this.notify_socket || this.notify_socket.readyState !== WebSocket.OPEN) { if (!this.notify_socket || this.notify_socket.readyState !== WebSocket.OPEN) {

View file

@ -0,0 +1,49 @@
<template>
<div>
<Modal v-if="isModal" title="Add Item" @close="$emit('close')">
<template #body>
<div>
<input type="text" class="form-control" placeholder="Sender" v-model="ticket.sender">
<input type="text" class="form-control" placeholder="Title" v-model="ticket.title">
<textarea class="form-control" placeholder="Message" v-model="ticket.message"></textarea>
</div>
</template>
<template #buttons>
<button type="button" class="btn btn-secondary" @click="$emit('close')">Cancel</button>
<button type="button" class="btn btn-success" @click="saveNewTicket()">Save new Item</button>
</template>
</Modal>
</div>
</template>
<script>
import Modal from '@/components/Modal';
import EditItem from '@/components/EditItem';
export default {
name: 'AddTicketModal',
components: {Modal, EditItem},
props: ['isModal'],
data: () => ({
ticket: {
sender: '',
message: '',
title: '',
}
}),
created() {
this.ticket = {box: this.$store.state.lastUsed.box || '', cid: this.$store.state.lastUsed.cid || ''};
},
methods: {
saveNewTicket() {
this.$store.dispatch('postManualTicket', this.ticket).then(() => {
this.$emit('close');
});
}
}
};
</script>
<style scoped>
</style>

View file

@ -0,0 +1,45 @@
<template>
<table class="table table-striped table-dark" style="table-layout: fixed">
<thead>
<tr>
<th scope="col"></th>
<th scope="col" v-for="(column, index) in columns" :key="index">
{{ column }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in rows" :key="i">
<td>{{ row }}</td>
<td v-for="(column, j) in columns" :key="j">
<font-awesome-icon v-if="items[j][i]" icon="check" class="text-success"/>
<font-awesome-icon v-else icon="times" class="text-danger"/>
</td>
</tr>
</tbody>
</table>
</template>
<script>
export default {
name: 'Matrix2D',
props: {
items: {
type: Array,
required: true
},
columns: {
type: Array,
required: true
},
rows: {
type: Array,
required: true
}
},
};
</script>
<style>
</style>

View file

@ -0,0 +1,61 @@
<template>
<table class="table table-striped table-dark" style="table-layout: fixed">
<thead>
<tr>
<th scope="col"></th>
<th scope="col" v-for="(column, index) in columns" :key="index">
{{ column }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in rows" :key="i">
<td>{{ row }}</td>
<td v-for="(column, j) in columns" :key="j">
<font-awesome-icon v-if="triState(items[j][i]) === 'on'" icon="check" class="text-success"/>
<font-awesome-icon v-else-if="triState(items[j][i]) === 'off'" icon="times" class="text-danger"/>
<font-awesome-icon v-else icon="minus" class="text-warning"/>
</td>
</tr>
</tbody>
</table>
</template>
<script>
export default {
name: 'Matrix3D',
props: {
items: {
type: Array,
required: true
},
columns: {
type: Array,
required: true
},
rows: {
type: Array,
required: true
},
foldedDimension: {
type: Array,
required: true
}
},
methods: {
triState(list) {
if(list.every(e => e === true)) {
return 'on';
} else if(list.every(e => e === false)) {
return 'off';
} else {
return 'partial';
}
}
},
};
</script>
<style>
</style>

View file

@ -11,25 +11,25 @@
</div> </div>
</div> </div>
<ul class="nav nav-tabs flex-nowrap"> <ul class="nav nav-tabs flex-nowrap">
<li class="nav-item"> <li class="nav-item" v-if="checkPermission(getEventSlug, 'view_item')">
<router-link :to="{name: 'items', params: {event: getEventSlug}}" <router-link :to="{name: 'items', params: {event: getEventSlug}}"
:class="['nav-link', { active: getActiveView === 'items' || getActiveView === 'item' }]"> :class="['nav-link', { active: isItemView() }]">
Items Items
</router-link> </router-link>
</li> </li>
<li class="nav-item" v-if="checkRole('team')"> <li class="nav-item" v-if="checkPermission(getEventSlug, 'view_issuethread')">
<router-link :to="{name: 'tickets', params: {event: getEventSlug}}" <router-link :to="{name: 'tickets', params: {event: getEventSlug}}"
:class="['nav-link', { active: getActiveView === 'tickets' || getActiveView === 'ticket' }]"> :class="['nav-link', { active: isTicketView() }]">
Tickets Tickets
</router-link> </router-link>
</li> </li>
<li class="nav-item" v-if="checkRole('admin')"> <li class="nav-item" v-if="checkPermission(getEventSlug, 'delete_event')">
<router-link :to="{name: 'admin'}" :class="['nav-link', { active: getActiveView === 'admin' }]"> <router-link :to="{name: 'admin'}" class="nav-link" active-class="active">
Admin Admin
</router-link> </router-link>
</li> </li>
</ul> </ul>
<form class="form-inline mt-1 my-lg-auto my-xl-auto w-100 d-inline"> <form class="form-inline mt-1 my-lg-auto my-xl-auto w-100 d-inline mr-1" v-if="hasPermissions">
<input <input
class="form-control w-100" class="form-control w-100"
type="search" type="search"
@ -39,8 +39,8 @@
disabled disabled
> >
</form> </form>
<div class="custom-control-inline mr-1"> <div class="custom-control-inline mr-1" v-if="hasPermissions">
<div class="btn-group btn-group-toggle mx-1"> <div class="btn-group btn-group-toggle mr-1" v-if="isItemView()">
<button :class="['btn', 'btn-info', { active: layout === 'cards' }]" @click="setLayout('cards')"> <button :class="['btn', 'btn-info', { active: layout === 'cards' }]" @click="setLayout('cards')">
<font-awesome-icon icon="th"/> <font-awesome-icon icon="th"/>
</button> </button>
@ -48,9 +48,15 @@
<font-awesome-icon icon="list"/> <font-awesome-icon icon="list"/>
</button> </button>
</div> </div>
<button type="button" class="btn text-nowrap btn-success" @click="$emit('addClicked')"> <button type="button" class="btn text-nowrap btn-success mr-1" @click="$emit('addItemClicked')"
v-if="isItemView()">
<font-awesome-icon icon="plus"/> <font-awesome-icon icon="plus"/>
<span class="d-none d-md-inline">&nbsp;Add</span> <span class="d-none d-md-inline">&nbsp;Add Item</span>
</button>
<button type="button" class="btn text-nowrap btn-success mr-1" @click="$emit('addTicketClicked')"
v-if="isTicketView()">
<font-awesome-icon icon="plus"/>
<span class="d-none d-md-inline">&nbsp;Add Ticket</span>
</button> </button>
</div> </div>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent"
@ -77,9 +83,13 @@
{{ link.title }} {{ link.title }}
</a> </a>
</li> </li>
<li class="nav-item">
<a class="nav-link text-nowrap" href="/logout" @click.prevent="logout()">
Logout
</a>
</li>
</ul> </ul>
</div> </div>
</nav> </nav>
</template> </template>
@ -99,14 +109,21 @@ export default {
] ]
}), }),
computed: { computed: {
...mapState(['events', 'activeEvent', 'layout']), ...mapState(['events', 'layout']),
...mapGetters(['getEventSlug', 'getActiveView', "checkRole"]), ...mapGetters(['getEventSlug', 'getActiveView', "checkPermission", "hasPermissions"]),
}, },
methods: { methods: {
...mapActions(['changeEvent', 'changeView', 'searchEventItems']), ...mapActions(['changeEvent', 'changeView', 'searchEventItems']),
...mapMutations(['setLayout']), ...mapMutations(['setLayout', 'logout']),
navigateTo(link) { navigateTo(link) {
if (this.$router.currentRoute.path !== link)
this.$router.push(link); this.$router.push(link);
},
isItemView() {
return this.getActiveView === 'items' || this.getActiveView === 'item';
},
isTicketView() {
return this.getActiveView === 'tickets' || this.getActiveView === 'ticket';
} }
} }
}; };

View file

@ -25,8 +25,8 @@
</span> </span>
<div class="new-comment"> <div class="new-comment">
<div class="input-group"> <div class="input-group">
<input type="text" placeholder="Add a comment..." v-model="newMail"> <input type="text" placeholder="reply mail..." v-model="newMail">
<button class="btn" @click="sendMail"> <button class="btn btn-primary" @click="sendMail">
Send Send
</button> </button>
</div> </div>

View file

@ -13,13 +13,13 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<p><span v-html="body"></span></p> <p><span v-html="body"></span></p>
<button class="button">👏 2</button> <!--button class="button">👏 2</button>
<button class="button | square"> <button class="button | square">
<font-awesome-icon icon="user"/> <font-awesome-icon icon="user"/>
</button> </button-->
</div> </div>
</div> </div>
<button class="show-replies"> <!--button class="show-replies">
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-arrow-forward" <svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-arrow-forward"
width="44" height="44" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" width="44" height="44" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round"> stroke-linecap="round" stroke-linejoin="round">
@ -38,7 +38,7 @@
<font-awesome-icon icon="user"/> <font-awesome-icon icon="user"/>
</i> </i>
</span> </span>
</button> </button-->
</div> </div>
</template> </template>

View file

@ -34,14 +34,17 @@ import {
faComment, faComment,
faEnvelope, faEnvelope,
faUser, faUser,
faComments faComments,
faArchive,
faMinus,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import {FontAwesomeIcon} from '@fortawesome/vue-fontawesome'; import {FontAwesomeIcon} from '@fortawesome/vue-fontawesome';
import vueDebounce from 'vue-debounce'; import vueDebounce from 'vue-debounce';
library.add(faPlus, faCheckCircle, faEdit, faTrash, faCat, faSyncAlt, faSort, faSortUp, faSortDown, faTh, faList, library.add(faPlus, faCheckCircle, faEdit, faTrash, faCat, faSyncAlt, faSort, faSortUp, faSortDown, faTh, faList,
faWindowClose, faCamera, faStop, faPen, faCheck, faTimes, faSave, faEye, faComment, faUser, faComments, faEnvelope); faWindowClose, faCamera, faStop, faPen, faCheck, faTimes, faSave, faEye, faComment, faUser, faComments, faEnvelope,
faArchive, faMinus);
Vue.component('font-awesome-icon', FontAwesomeIcon); Vue.component('font-awesome-icon', FontAwesomeIcon);
//import VueQRCodeComponent from 'vue-qrcode-component' //import VueQRCodeComponent from 'vue-qrcode-component'

View file

@ -1,7 +1,6 @@
import Items from './views/Items'; import Items from './views/Items';
import Boxes from './views/Boxes'; import Boxes from './views/Boxes';
import Files from './views/Files'; import Files from './views/Files';
import Events from './views/Events';
import Error from './views/Error'; import Error from './views/Error';
import HowTo from './views/HowTo'; import HowTo from './views/HowTo';
import VueRouter from 'vue-router'; import VueRouter from 'vue-router';
@ -13,25 +12,51 @@ import Tickets from "@/views/Tickets.vue";
import Ticket from "@/views/Ticket.vue"; import Ticket from "@/views/Ticket.vue";
import Admin from "@/views/admin/Admin.vue"; import Admin from "@/views/admin/Admin.vue";
import store from "@/store"; import store from "@/store";
import Empty from "@/views/Empty.vue";
import Events from "@/views/admin/Events.vue";
import AccessControl from "@/views/admin/AccessControl.vue";
Vue.use(VueRouter); Vue.use(VueRouter);
const routes = [ const routes = [
{path: '/', redirect: '/Camp23/items', meta: {requiresAuth: false}}, {path: '/', redirect: '/Camp23/items', meta: {requiresAuth: false}},
{path: '/login', name: 'login', component: Login, meta: {requiresAuth: false}}, {path: '/login/', name: 'login', component: Login, meta: {requiresAuth: false}},
{path: '/register', name: 'register', component: Register, meta: {requiresAuth: false}}, {path: '/register/', name: 'register', component: Register, meta: {requiresAuth: false}},
{path: '/howto', name: 'howto', component: HowTo, meta: {requiresAuth: true}}, {path: '/howto/', name: 'howto', component: HowTo, meta: {requiresAuth: true}},
{path: '/:event/boxes', name: 'boxes', component: Boxes, meta: {requiresAuth: true}}, {path: '/:event/items/', name: 'items', component: Items, meta:
{path: '/:event/items', name: 'items', component: Items, meta: {requiresAuth: true}}, {requiresAuth: true, requiresPermission: 'view_item'}},
{path: '/:event/box/:uid', name: 'box', component: Boxes, meta: {requiresAuth: true}}, {path: '/:event/item/:uid/', name: 'item', component: Items, meta:
{path: '/:event/item/:uid', name: 'item', component: Items, meta: {requiresAuth: true}}, {requiresAuth: true, requiresPermission: 'view_item'}},
{path: '/:event/tickets', name: 'tickets', component: Tickets, meta: {requiresAuth: true}}, {path: '/:event/boxes/', name: 'boxes', component: Boxes, meta:
{path: '/:event/ticket/:id', name: 'ticket', component: Ticket, meta: {requiresAuth: true}}, {requiresAuth: true, requiresPermission: 'view_container'}},
{path: '/admin', name: 'admin', component: Admin, meta: {requiresAuth: true}}, {path: '/:event/box/:uid/', name: 'box', component: Boxes, meta:
{path: '/admin/files', name: 'files', component: Files, meta: {requiresAuth: true}}, {requiresAuth: true, requiresPermission: 'view_container'}},
{path: '/admin/events', name: 'events', component: Events, meta: {requiresAuth: true}}, {path: '/:event/tickets/', name: 'tickets', component: Tickets, meta:
{path: '/admin/debug', name: 'debug', component: Debug, meta: {requiresAuth: true}}, {requiresAuth: true, requiresPermission: 'view_issuethread'}},
{path: '/admin/users', name: 'users', component: Events, meta: {requiresAuth: true}}, {path: '/:event/ticket/:id/', name: 'ticket', component: Ticket, meta:
{requiresAuth: true, requiresPermission: 'view_issuethread'}},
{path: '/admin/', component: Admin, meta:
{requiresAuth: true, requiresPermission: 'delete_event'},
children: [
{
path: 'files/', name: 'files', component: Files, meta:
{requiresAuth: true, requiresPermission: 'delete_event'}
},
{
path: 'events/', name: 'events', component: Events, meta:
{requiresAuth: true, requiresPermission: 'delete_event'}
},
{
path: '', name: 'admin', component: Debug, meta:
{requiresAuth: true, requiresPermission: 'delete_event'}
},
{
path: 'users/', name: 'users', component: AccessControl, meta:
{requiresAuth: true, requiresPermission: 'delete_event'}
},
]
},
{path: '/user', name: 'user', component: Empty, meta: {requiresAuth: true}},
{path: '*', component: Error}, {path: '*', component: Error},
]; ];
@ -57,11 +82,16 @@ const router = new VueRouter({
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth && !store.getters.isLoggedIn) { if (to.meta.requiresAuth && !store.getters.isLoggedIn) {
//console.log("Not logged in, redirecting to login page") console.log("Not logged in, redirecting to login page")
next({ next({
name: 'login', name: 'login',
query: {redirect: to.fullPath}, query: {redirect: to.fullPath},
}) })
} else if (to.meta.requiresPermission && !store.getters.checkPermission(to.params.event || "*", to.meta.requiresPermission)) {
console.log("Not enough permissions, redirecting to empty page")
next({
path: '/user',
})
} else { } else {
next() next()
} }

View file

@ -63,14 +63,18 @@ const store = new Vuex.Store({
events: [], events: [],
layout: 'cards', layout: 'cards',
loadedItems: [], loadedItems: [],
itemCache: {},
loadedBoxes: [], loadedBoxes: [],
toasts: [], toasts: [],
tickets: [], tickets: [],
userRoles: ['admin', 'team', 'orga', 'user'], users: [],
groups: [],
lastEvent: localStorage.getItem('lf_lastEvent') || '36C3', lastEvent: localStorage.getItem('lf_lastEvent') || '36C3',
lastUsed: JSON.parse(localStorage.getItem('lf_lastUsed') || '{}'), lastUsed: JSON.parse(localStorage.getItem('lf_lastUsed') || '{}'),
remember: false, remember: false,
user: null, user: null,
password: null,
userPermissions: [],
token: null, token: null,
token_expiry: null, token_expiry: null,
local_loaded: false, local_loaded: false,
@ -80,11 +84,13 @@ const store = new Vuex.Store({
getActiveView: state => state.route.name || 'items', getActiveView: state => state.route.name || 'items',
getFilters: state => state.route.query, getFilters: state => state.route.query,
getBoxes: state => state.loadedBoxes, getBoxes: state => state.loadedBoxes,
checkRole: state => role => state.userRoles.includes(role), checkPermission: state => (event, perm) => state.userPermissions.includes(`${event}:${perm}`) || state.userPermissions.includes(`*:${perm}`),
hasPermissions: state => state.userPermissions.length > 0,
isLoggedIn(state) { isLoggedIn(state) {
if (!state.local_loaded) { if (!state.local_loaded) {
state.remember = localStorage.getItem('remember') === 'true' state.remember = localStorage.getItem('remember') === 'true'
state.user = localStorage.getItem('user') state.user = localStorage.getItem('user')
state.userPermissions = JSON.parse(localStorage.getItem('permissions') || '[]')
state.token = localStorage.getItem('token') state.token = localStorage.getItem('token')
state.token_expiry = localStorage.getItem('token_expiry') state.token_expiry = localStorage.getItem('token_expiry')
state.local_loaded = true state.local_loaded = true
@ -111,6 +117,9 @@ const store = new Vuex.Store({
replaceLoadedItems(state, newItems) { replaceLoadedItems(state, newItems) {
state.loadedItems = newItems; state.loadedItems = newItems;
}, },
setItemCache(state, {slug, items}) {
state.itemCache[slug] = items;
},
setLayout(state, layout) { setLayout(state, layout) {
state.layout = layout; state.layout = layout;
}, },
@ -130,6 +139,12 @@ const store = new Vuex.Store({
replaceTickets(state, tickets) { replaceTickets(state, tickets) {
state.tickets = tickets; state.tickets = tickets;
}, },
replaceUsers(state, users) {
state.users = users;
},
replaceGroups(state, groups) {
state.groups = groups;
},
updateTicket(state, updatedTicket) { updateTicket(state, updatedTicket) {
const ticket = state.tickets.filter(({id}) => id === updatedTicket.id)[0]; const ticket = state.tickets.filter(({id}) => id === updatedTicket.id)[0];
Object.assign(ticket, updatedTicket); Object.assign(ticket, updatedTicket);
@ -152,6 +167,14 @@ const store = new Vuex.Store({
if (user) if (user)
localStorage.setItem('user', user); localStorage.setItem('user', user);
}, },
setPassword(state, password) {
state.password = password;
},
setPermissions(state, permissions) {
state.userPermissions = permissions;
if (permissions)
localStorage.setItem('permissions', JSON.stringify(permissions));
},
setToken(state, {token, expiry}) { setToken(state, {token, expiry}) {
state.token = token; state.token = token;
state.token_expiry = expiry; state.token_expiry = expiry;
@ -163,8 +186,10 @@ const store = new Vuex.Store({
state.user = null; state.user = null;
state.token = null; state.token = null;
localStorage.removeItem('user'); localStorage.removeItem('user');
localStorage.removeItem('permissions');
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('token_expiry'); localStorage.removeItem('token_expiry');
if (router.currentRoute.name !== 'login')
router.push('/login'); router.push('/login');
}, },
}, },
@ -181,6 +206,7 @@ const store = new Vuex.Store({
if (data.token) { if (data.token) {
commit('setToken', data); commit('setToken', data);
commit('setUser', username); commit('setUser', username);
commit('setPassword', password);
axios.defaults.headers.common['Authorization'] = `Token ${data.token}`; axios.defaults.headers.common['Authorization'] = `Token ${data.token}`;
dispatch('afterLogin'); dispatch('afterLogin');
return true; return true;
@ -194,10 +220,11 @@ const store = new Vuex.Store({
}, },
async reloadToken({commit, state}) { async reloadToken({commit, state}) {
try { try {
if (data.password) {
const data = await fetch('/api/2/login/', { const data = await fetch('/api/2/login/', {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username: state.user, password: state.token}), body: JSON.stringify({username: state.user, password: state.password}),
credentials: 'omit' credentials: 'omit'
}).then(r => r.json()) }).then(r => r.json())
if (data.token) { if (data.token) {
@ -205,28 +232,34 @@ const store = new Vuex.Store({
axios.defaults.headers.common['Authorization'] = `Token ${data.token}`; axios.defaults.headers.common['Authorization'] = `Token ${data.token}`;
return true; return true;
} }
}
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} }
//credentials failed, logout //credentials failed, logout
store.commit('logout'); store.commit('logout');
router.push('/login');
}, },
async afterLogin({dispatch}) { async afterLogin({dispatch}) {
await dispatch('loadBoxes'); const boxes = dispatch('loadBoxes');
await dispatch('loadEventItems'); const items = dispatch('loadEventItems');
await dispatch('loadTickets'); const tickets = dispatch('loadTickets');
const user = dispatch('loadUserInfo');
await Promise.all([boxes, items, tickets, user]);
}, },
async fetchImage({state}, url) { async fetchImage({state}, url) {
return await fetch(url, {headers: {'Authorization': `Token ${state.token}`}}); return await fetch(url, {headers: {'Authorization': `Token ${state.token}`}});
}, },
async loadUserInfo({commit}) {
const {data} = await axios.get('/2/self/');
commit('setUser', data.username);
commit('setPermissions', data.permissions);
},
async loadEvents({commit}) { async loadEvents({commit}) {
const {data} = await axios.get('/2/events/'); const {data} = await axios.get('/2/events/');
commit('replaceEvents', data); commit('replaceEvents', data);
}, },
changeEvent({dispatch, getters, commit}, eventName) { changeEvent({dispatch, getters, commit}, eventName) {
router.push({path: `/${eventName.slug}/${getters.getActiveView}/`}); router.push({path: `/${eventName.slug}/${getters.getActiveView}/`});
commit('replaceLoadedItems', []);
dispatch('loadEventItems'); dispatch('loadEventItems');
}, },
changeView({getters}, link) { changeView({getters}, link) {
@ -235,10 +268,16 @@ const store = new Vuex.Store({
showBoxContent({getters}, box) { showBoxContent({getters}, box) {
router.push({path: `/${getters.getEventSlug}/items/`, query: {box}}); router.push({path: `/${getters.getEventSlug}/items/`, query: {box}});
}, },
async loadEventItems({commit, getters}) { async loadEventItems({commit, getters, state}) {
try { try {
const {data} = await axios.get(`/2/${getters.getEventSlug}/items/`); commit('replaceLoadedItems', []);
const slug = getters.getEventSlug;
if (slug in state.itemCache) {
commit('replaceLoadedItems', state.itemCache[slug]);
}
const {data} = await axios.get(`/2/${slug}/items/`);
commit('replaceLoadedItems', data); commit('replaceLoadedItems', data);
commit('setItemCache', {slug, items: data});
} catch (e) { } catch (e) {
console.error("Error loading items"); console.error("Error loading items");
} }
@ -259,7 +298,7 @@ const store = new Vuex.Store({
commit('updateItem', data); commit('updateItem', data);
}, },
async markItemReturned({commit, getters}, item) { async markItemReturned({commit, getters}, item) {
await axios.put(`/2/${getters.getEventSlug}/item/${item.uid}/`, {returned: true}); await axios.patch(`/2/${getters.getEventSlug}/item/${item.uid}/`, {returned: true});
commit('removeItem', item); commit('removeItem', item);
}, },
async deleteItem({commit, getters}, item) { async deleteItem({commit, getters}, item) {
@ -278,7 +317,19 @@ const store = new Vuex.Store({
async sendMail({commit, dispatch}, {id, message}) { async sendMail({commit, dispatch}, {id, message}) {
const {data} = await axios.post(`/2/tickets/${id}/reply/`, {message}); const {data} = await axios.post(`/2/tickets/${id}/reply/`, {message});
await dispatch('loadTickets'); await dispatch('loadTickets');
} },
async postManualTicket({commit, dispatch}, {sender, message, title,}) {
const {data} = await axios.post(`/2/tickets/manual/`, {name: title, sender, body: message, recipient: 'mail@c3lf.de'});
await dispatch('loadTickets');
},
async loadUsers({commit}) {
const {data} = await axios.get('/2/users/');
commit('replaceUsers', data);
},
async loadGroups({commit}) {
const {data} = await axios.get('/2/groups/');
commit('replaceGroups', data);
},
} }
}); });

29
web/src/views/Empty.vue Normal file
View file

@ -0,0 +1,29 @@
<template>
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
<div class="card bg-dark text-light mb-2" id="filters">
<div class="card-header">
<h3 class="text-center">User: {{user}}</h3>
</div>
<div class="card-body">
<p>Your Account is not yet activated. Please contact an admin.</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import {mapState} from "vuex";
export default {
name: 'Empty',
computed: mapState(['user']),
};
</script>
<style scoped>
</style>

View file

@ -1,5 +1,18 @@
<template> <template>
<p>Error</p> <div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
<div class="card bg-dark text-light mb-2" id="filters">
<div class="card-header">
<h3 class="text-center">Error</h3>
</div>
<div class="card-body">
<p>Something went wrong. <a href="/">Go back to the start page </a>or contact an admin.</p>
</div>
</div>
</div>
</div>
</div>
</template> </template>
<script> <script>

View file

@ -1,41 +0,0 @@
<template>
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
<Table
:columns="['slug', 'name']"
:items="events"
:keyName="'slug'"
v-slot="{ item }"
>
<div class="btn-group">
<button class="btn btn-secondary" @click.stop="changeEvent(item)">
<font-awesome-icon icon="archive"/>
use
</button>
<button class="btn btn-danger" @click.stop="">
<font-awesome-icon icon="trash"/>
delete
</button>
</div>
</Table>
</div>
</div>
</div>
</template>
<script>
import {mapActions, mapState} from 'vuex';
import Table from '@/components/Table';
export default {
name: 'Events',
components: {Table},
computed: mapState(['events']),
methods: mapActions(['changeEvent']),
};
</script>
<style scoped>
</style>

View file

@ -0,0 +1,68 @@
<template>
<div>
<h3 class="text-center">Users</h3>
<Table :items="users" :columns="['username']" :key-name="'id'">
<template v-slot:default="{item}">
<span>
{{item.groups.join(', ')}}
</span>
</template>
</Table>
<h3 class="text-center">Groups</h3>
<Table :items="groups" :columns="['name']" :key-name="'id'">
<template v-slot:default="{item}">
<span>
{{item.members.join(', ')}}
</span>
</template>
</Table>
<Matrix2D :items="groupPermissions" :columns="groupNames" :rows="permissionNames"/>
<h3 class="text-center">Permissions</h3>
<Matrix3D :items="userPermissions" :columns="userNames" :rows="permissionNames"
:folded-dimension="eventsNames"/>
</div>
</template>
<script>
import {mapActions, mapState} from 'vuex';
import Table from "@/components/Table.vue";
import Matrix3D from "@/components/Matrix3D.vue";
import Matrix2D from "@/components/Matrix2D.vue";
export default {
name: 'AccessControl',
components: {Matrix3D, Matrix2D, Table},
computed: {
...mapState(['users', 'groups', 'events']),
permissionNames() {
return this.groups.map(g => g.permissions).flat().map(p => p.split(":")[1])
},
groupNames() {
return this.groups.map(g => g.name)
},
eventsNames() {
return this.events.map(e => e.slug)
},
userNames() {
return this.users.map(u => u.username)
},
groupPermissions() {
return this.groups.map(g => this.permissionNames.map(p => g.permissions.includes("*:" + p)))
},
userPermissions() {
return this.users.map(u => this.permissionNames.map(p => this.events.map(e =>
u.permissions.includes("*:" + p) || u.permissions.includes(e.slug + ":" + p)
)))
},
},
methods: mapActions(['loadUsers', 'loadGroups']),
mounted() {
this.loadUsers();
this.loadGroups();
}
};
</script>
<style scoped>
</style>

View file

@ -3,22 +3,23 @@
<div class="row"> <div class="row">
<div class="col-xl-8 offset-xl-2"> <div class="col-xl-8 offset-xl-2">
<div class="card bg-dark text-light mb-2" id="filters"> <div class="card bg-dark text-light mb-2" id="filters">
<h3 class="text-center">Admin</h3> <div class="card-header">
<ul> <ul class="nav nav-tabs card-header-tabs">
<li> <li class="nav-item">
<router-link :to="{name: 'debug'}">Debug</router-link> <router-link class="nav-link" :to="{name: 'admin'}" active-class="active" exact>Dashboard</router-link>
</li> </li>
<li> <li class="nav-item">
<router-link :to="{name: 'boxes', params: {event: getEventSlug}}">Boxes</router-link> <router-link class="nav-link" :to="{name: 'events'}" active-class="active">Events</router-link>
</li> </li>
<li> <li class="nav-item">
<router-link :to="{name: 'events'}">Events</router-link> <router-link class="nav-link" :to="{name: 'users'}" active-class="active">Access Control</router-link>
</li>
<li>
<router-link :to="{name: 'users'}">Users</router-link>
</li> </li>
</ul> </ul>
</div> </div>
<div class="card-body">
<router-view></router-view>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -26,9 +27,11 @@
<script> <script>
import {mapGetters} from 'vuex'; import {mapGetters} from 'vuex';
import Cards from "@/components/Cards.vue";
export default { export default {
name: 'Admin', name: 'Admin',
components: {Cards},
computed: { computed: {
...mapGetters(['getEventSlug']), ...mapGetters(['getEventSlug']),
}, },

View file

@ -1,7 +1,4 @@
<template> <template>
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
<Table <Table
:columns="['cid', 'name','itemCount']" :columns="['cid', 'name','itemCount']"
:items="loadedBoxes" :items="loadedBoxes"
@ -17,9 +14,6 @@
</button> </button>
</div> </div>
</Table> </Table>
</div>
</div>
</div>
</template> </template>
<script> <script>

View file

@ -1,7 +1,5 @@
<template> <template>
<div class="container-fluid px-xl-5 mt-3"> <div>
<div class="row">
<div class="col-xl-8 offset-xl-2">
<!--qr-code :text="qr_url" color="#000" bg-color="#fff" error-level="H" class="qr-code"></qr-code--> <!--qr-code :text="qr_url" color="#000" bg-color="#fff" error-level="H" class="qr-code"></qr-code-->
<h3 class="text-center">Events</h3> <h3 class="text-center">Events</h3>
<!--p>{{ events }}</p--> <!--p>{{ events }}</p-->
@ -46,18 +44,15 @@
</li> </li>
</ul> </ul>
</div> </div>
</div>
</div>
</template> </template>
<script> <script>
import {mapActions, mapState} from 'vuex'; import {mapActions, mapState} from 'vuex';
import Table from '@/components/Table'; import Table from '@/components/Table';
import Events from "@/views/Events.vue";
export default { export default {
name: 'Debug', name: 'Debug',
components: {Events, Table}, components: {Table},
computed: { computed: {
...mapState(['events', 'loadedItems', 'loadedBoxes', 'mails', 'issues', 'systemEvents']), ...mapState(['events', 'loadedItems', 'loadedBoxes', 'mails', 'issues', 'systemEvents']),
qr_url() { qr_url() {

View file

@ -1,7 +1,4 @@
<template> <template>
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
<Table <Table
:columns="['slug', 'name']" :columns="['slug', 'name']"
:items="events" :items="events"
@ -19,9 +16,6 @@
</button> </button>
</div> </div>
</Table> </Table>
</div>
</div>
</div>
</template> </template>
<script> <script>

View file

@ -1,29 +0,0 @@
<template>
<div class="container-fluid px-xl-5 mt-3">
<div class="row">
<div class="col-xl-8 offset-xl-2">
<h3 class="text-center">Users</h3>
<p>{{ users }}</p>
</div>
</div>
</div>
</template>
<script>
import {mapActions, mapState} from 'vuex';
import Table from '@/components/Table';
import Events from "@/views/Events.vue";
export default {
name: 'Users',
computed: mapState(['users']),
methods: mapActions(['loadUsers']),
mounted() {
this.loadUsers();
}
};
</script>
<style scoped>
</style>