diff --git a/backend/requirements/base.txt b/backend/requirements/base.txt
index 22821bc10..bc96ffff0 100644
--- a/backend/requirements/base.txt
+++ b/backend/requirements/base.txt
@@ -3,3 +3,4 @@ django-cors-headers==3.0.2
 djangorestframework==3.9.4
 djangorestframework-jwt==1.11.0
 psycopg2==2.8.3
+ipython==7.7.0
diff --git a/backend/src/baserow/api/v0/groups/serializers.py b/backend/src/baserow/api/v0/groups/serializers.py
new file mode 100644
index 000000000..4cbb355ba
--- /dev/null
+++ b/backend/src/baserow/api/v0/groups/serializers.py
@@ -0,0 +1,29 @@
+from rest_framework import serializers
+
+from baserow.core.models import Group, GroupUser
+
+
+class GroupSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = Group
+        fields = ('id', 'name',)
+        extra_kwargs = {
+            'id': {
+                'read_only': True
+            }
+        }
+
+
+class GroupUserSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = GroupUser
+        fields = ('order',)
+
+    def to_representation(self, instance):
+        data = super().to_representation(instance)
+        data.update(GroupSerializer(instance.group).data)
+        return data
+
+
+class OrderGroupsSerializer(serializers.Serializer):
+    groups = serializers.ListField(child=serializers.IntegerField())
diff --git a/backend/src/baserow/api/v0/groups/urls.py b/backend/src/baserow/api/v0/groups/urls.py
new file mode 100644
index 000000000..ce4c5a984
--- /dev/null
+++ b/backend/src/baserow/api/v0/groups/urls.py
@@ -0,0 +1,12 @@
+from django.conf.urls import url
+
+from .views import GroupsView, GroupView, GroupOrderView
+
+
+app_name = 'baserow.api.v0.group'
+
+urlpatterns = [
+    url(r'^$', GroupsView.as_view(), name='list'),
+    url(r'(?P<group_id>[0-9]+)/$', GroupView.as_view(), name='item'),
+    url(r'order/$', GroupOrderView.as_view(), name='order')
+]
diff --git a/backend/src/baserow/api/v0/groups/views.py b/backend/src/baserow/api/v0/groups/views.py
new file mode 100644
index 000000000..3167910ac
--- /dev/null
+++ b/backend/src/baserow/api/v0/groups/views.py
@@ -0,0 +1,73 @@
+from django.shortcuts import get_object_or_404
+from django.db import transaction
+
+from rest_framework.views import APIView
+from rest_framework.response import Response
+from rest_framework.permissions import IsAuthenticated
+
+from baserow.core.models import GroupUser
+from baserow.core.handler import CoreHandler
+
+from .serializers import GroupSerializer, GroupUserSerializer, OrderGroupsSerializer
+
+
+class GroupsView(APIView):
+    permission_classes = (IsAuthenticated,)
+    core_handler = CoreHandler()
+
+    def get(self, request):
+        """Responds with a list of groups where the users takes part in."""
+        groups = GroupUser.objects.filter(user=request.user).select_related('group')
+        serializer = GroupUserSerializer(groups, many=True)
+        return Response(serializer.data)
+
+    @transaction.atomic
+    def post(self, request):
+        """Creates a new group for a user."""
+        serializer = GroupSerializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+
+        data = serializer.data
+        group_user = self.core_handler.create_group(request.user, name=data['name'])
+
+        return Response(GroupUserSerializer(group_user).data)
+
+
+class GroupView(APIView):
+    permission_classes = (IsAuthenticated,)
+    core_handler = CoreHandler()
+
+    @transaction.atomic
+    def patch(self, request, group_id):
+        """Updates the group if it belongs to a user."""
+        group_user = get_object_or_404(GroupUser, group_id=group_id, user=request.user)
+
+        serializer = GroupSerializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+
+        data = serializer.data
+        group_user.group = self.core_handler.update_group(
+            request.user,  group_user.group, name=data['name'])
+
+        return Response(GroupUserSerializer(group_user).data)
+
+    @transaction.atomic
+    def delete(self, request, group_id):
+        """Deletes an existing group if it belongs to a user."""
+        group_user = get_object_or_404(GroupUser, group_id=group_id, user=request.user)
+        self.core_handler.delete_group(request.user,  group_user.group)
+        return Response(status=204)
+
+
+class GroupOrderView(APIView):
+    permission_classes = (IsAuthenticated,)
+    core_handler = CoreHandler()
+
+    def post(self, request):
+        """Updates to order of some groups for a user."""
+        serializer = OrderGroupsSerializer(data=request.data)
+        serializer.is_valid(raise_exception=True)
+
+        self.core_handler.order_groups(request.user, serializer.data['groups'])
+
+        return Response(status=204)
diff --git a/backend/src/baserow/api/v0/urls.py b/backend/src/baserow/api/v0/urls.py
index 6f5a4a39d..e434d41b2 100644
--- a/backend/src/baserow/api/v0/urls.py
+++ b/backend/src/baserow/api/v0/urls.py
@@ -1,10 +1,12 @@
 from django.urls import path, include
 
 from .user import urls as user_urls
+from .groups import urls as group_urls
 
 
 app_name = 'baserow.api.v0'
 
 urlpatterns = [
-    path('user/', include(user_urls, namespace='user'))
+    path('user/', include(user_urls, namespace='user')),
+    path('groups/', include(group_urls, namespace='groups'))
 ]
diff --git a/backend/src/baserow/config/settings/base.py b/backend/src/baserow/config/settings/base.py
index 5a0e2b881..aae81bca5 100644
--- a/backend/src/baserow/config/settings/base.py
+++ b/backend/src/baserow/config/settings/base.py
@@ -25,6 +25,7 @@ INSTALLED_APPS = [
     'rest_framework',
     'corsheaders',
 
+    'baserow.core',
     'baserow.api.v0'
 ]
 
diff --git a/backend/src/baserow/config/urls.py b/backend/src/baserow/config/urls.py
index 4d6cc869c..6de44ae02 100644
--- a/backend/src/baserow/config/urls.py
+++ b/backend/src/baserow/config/urls.py
@@ -3,5 +3,5 @@ from django.conf.urls import url
 
 
 urlpatterns = [
-    url(r'^api/v0/', include('baserow.api.v0.urls', namespace='api')),
+    url(r'^api/v0/', include('baserow.api.v0.urls', namespace='api_v0')),
 ]
diff --git a/backend/src/baserow/core/__init__.py b/backend/src/baserow/core/__init__.py
new file mode 100644
index 000000000..1a371c919
--- /dev/null
+++ b/backend/src/baserow/core/__init__.py
@@ -0,0 +1 @@
+app_name = 'baserow.group'
diff --git a/backend/src/baserow/core/apps.py b/backend/src/baserow/core/apps.py
new file mode 100644
index 000000000..b2ef0f46f
--- /dev/null
+++ b/backend/src/baserow/core/apps.py
@@ -0,0 +1,5 @@
+from django.apps import AppConfig
+
+
+class ApiConfig(AppConfig):
+    name = 'baserow.core'
diff --git a/backend/src/baserow/core/exceptions.py b/backend/src/baserow/core/exceptions.py
new file mode 100644
index 000000000..6a8d1ffa0
--- /dev/null
+++ b/backend/src/baserow/core/exceptions.py
@@ -0,0 +1,2 @@
+class UserNotIngroupError(Exception):
+    pass
diff --git a/backend/src/baserow/core/handler.py b/backend/src/baserow/core/handler.py
new file mode 100644
index 000000000..cb8071b98
--- /dev/null
+++ b/backend/src/baserow/core/handler.py
@@ -0,0 +1,79 @@
+from .models import Group, GroupUser
+from .exceptions import UserNotIngroupError
+
+
+class CoreHandler:
+    def create_group(self, user, **kwargs):
+        """Creates a new group for an existing user.
+
+        :param user: The user that must be in the group.
+        :type user: User
+        :return: The newly created GroupUser object
+        :rtype: GroupUser
+        """
+
+        allowed_fields = ['name']
+
+        group_values = {}
+        for field in allowed_fields:
+            if field in allowed_fields:
+                group_values[field] = kwargs[field]
+
+        group = Group.objects.create(**group_values)
+        last_order = GroupUser.get_last_order(user)
+        group_user = GroupUser.objects.create(group=group, user=user, order=last_order)
+
+        return group_user
+
+    def update_group(self, user, group, **kwargs):
+        """Updates fields of a group.
+
+        :param user:
+        :param group:
+        :return:
+        """
+
+        if not group.has_user(user):
+            raise UserNotIngroupError(f'The user {user} does not belong to the group '
+                                      f'{group}.')
+
+        allowed_fields = ['name']
+
+        for field in allowed_fields:
+            if field in kwargs:
+                setattr(group, field, kwargs[field])
+
+        group.save()
+
+        return group
+
+    def delete_group(self, user, group):
+        """Deletes an existing group.
+
+        :param user:
+        :type: user: User
+        :param group:
+        :type: group: Group
+        :return:
+        """
+
+        if not group.has_user(user):
+            raise UserNotIngroupError(f'The user {user} does not belong to the group '
+                                      f'{group}.')
+
+        group.delete()
+
+    def order_groups(self, user, group_ids):
+        """Changes the order of groups for a user.
+
+        :param user:
+        :type: user: User
+        :param group_ids: A list of group ids ordered the way they need to be ordered.
+        :type group_ids: List[int]
+        """
+
+        for index, group_id in enumerate(group_ids):
+            GroupUser.objects.filter(
+                user=user,
+                group_id=group_id
+            ).update(order=index + 1)
diff --git a/backend/src/baserow/core/managers.py b/backend/src/baserow/core/managers.py
new file mode 100644
index 000000000..edde479fe
--- /dev/null
+++ b/backend/src/baserow/core/managers.py
@@ -0,0 +1,8 @@
+from django.db import models
+
+
+class GroupQuerySet(models.QuerySet):
+    def of_user(self, user):
+        return self.filter(
+            users__exact=user
+        ).order_by('groupuser__order')
diff --git a/backend/src/baserow/core/migrations/0001_initial.py b/backend/src/baserow/core/migrations/0001_initial.py
new file mode 100644
index 000000000..6bb6214dd
--- /dev/null
+++ b/backend/src/baserow/core/migrations/0001_initial.py
@@ -0,0 +1,48 @@
+# Generated by Django 2.2.2 on 2019-07-28 13:08
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Group',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True,
+                                        serialize=False, verbose_name='ID')),
+                ('name', models.CharField(max_length=100)),
+            ],
+        ),
+        migrations.CreateModel(
+            name='GroupUser',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True,
+                                        serialize=False, verbose_name='ID')),
+                ('order', models.PositiveIntegerField()),
+                ('group', models.ForeignKey(
+                    on_delete=django.db.models.deletion.CASCADE, to='core.Group')),
+                ('user', models.ForeignKey(
+                    on_delete=django.db.models.deletion.CASCADE,
+                    to=settings.AUTH_USER_MODEL
+                )),
+            ],
+            options={
+                'ordering': ('order',),
+            },
+        ),
+        migrations.AddField(
+            model_name='group',
+            name='users',
+            field=models.ManyToManyField(through='core.GroupUser',
+                                         to=settings.AUTH_USER_MODEL),
+        ),
+    ]
diff --git a/web-frontend/components/.gitkeep b/backend/src/baserow/core/migrations/__init__.py
similarity index 100%
rename from web-frontend/components/.gitkeep
rename to backend/src/baserow/core/migrations/__init__.py
diff --git a/backend/src/baserow/core/models.py b/backend/src/baserow/core/models.py
new file mode 100644
index 000000000..b4e833b4c
--- /dev/null
+++ b/backend/src/baserow/core/models.py
@@ -0,0 +1,41 @@
+from django.db import models
+from django.contrib.auth import get_user_model
+
+from .managers import GroupQuerySet
+
+
+User = get_user_model()
+
+
+class Group(models.Model):
+    name = models.CharField(max_length=100)
+    users = models.ManyToManyField(User, through='GroupUser')
+
+    objects = GroupQuerySet.as_manager()
+
+    def has_user(self, user):
+        """Returns true is the user belongs to the group."""
+        return self.users.filter(id=user.id).exists()
+
+    def __str__(self):
+        return f'<Group id={self.id}, name={self.name}>'
+
+
+class GroupUser(models.Model):
+    user = models.ForeignKey(User, on_delete=models.CASCADE)
+    group = models.ForeignKey(Group, on_delete=models.CASCADE)
+    order = models.PositiveIntegerField()
+
+    class Meta:
+        ordering = ('order',)
+
+    @classmethod
+    def get_last_order(cls, user):
+        """Returns a new position that will be last for a new group."""
+        highest_order = cls.objects.filter(
+            user=user
+        ).aggregate(
+            models.Max('order')
+        ).get('order__max', 0) or 0
+
+        return highest_order + 1
diff --git a/backend/tests/baserow/api/v0/group/test_group_views.py b/backend/tests/baserow/api/v0/group/test_group_views.py
new file mode 100644
index 000000000..a5aeb1af8
--- /dev/null
+++ b/backend/tests/baserow/api/v0/group/test_group_views.py
@@ -0,0 +1,121 @@
+import pytest
+
+from django.shortcuts import reverse
+
+from baserow.core.models import Group, GroupUser
+
+
+@pytest.mark.django_db
+def test_list_groups(api_client, data_fixture):
+    user, token = data_fixture.create_user_and_token(
+        email='test@test.nl', password='password', first_name='Test1')
+    group_2 = data_fixture.create_user_group(user=user, order=2)
+    group_1 = data_fixture.create_user_group(user=user, order=1)
+
+    response = api_client.get(reverse('api_v0:groups:list'), **{
+        'HTTP_AUTHORIZATION': f'JWT {token}'
+    })
+    assert response.status_code == 200
+    response_json = response.json()
+    assert response_json[0]['id'] == group_1.id
+    assert response_json[0]['order'] == 1
+    assert response_json[1]['id'] == group_2.id
+    assert response_json[1]['order'] == 2
+
+
+@pytest.mark.django_db
+def test_create_group(api_client, data_fixture):
+    user, token = data_fixture.create_user_and_token()
+
+    response = api_client.post(reverse('api_v0:groups:list'), {
+        'name': 'Test 1'
+    }, format='json', HTTP_AUTHORIZATION=f'JWT {token}')
+    assert response.status_code == 200
+    json_response = response.json()
+    group_user = GroupUser.objects.filter(user=user.id).first()
+    assert group_user.order == 1
+    assert group_user.order == json_response['order']
+    assert group_user.group.id == json_response['id']
+    assert group_user.group.name == 'Test 1'
+    assert group_user.user == user
+
+    response = api_client.post(reverse('api_v0:groups:list'), {
+        'not_a_name': 'Test 1'
+    }, format='json', HTTP_AUTHORIZATION=f'JWT {token}')
+    assert response.status_code == 400
+
+
+@pytest.mark.django_db
+def test_update_group(api_client, data_fixture):
+    user, token = data_fixture.create_user_and_token()
+    group = data_fixture.create_group(user=user, name='Old name')
+
+    url = reverse('api_v0:groups:item', kwargs={'group_id': 99999})
+    response = api_client.patch(
+        url,
+        {'name': 'New name'},
+        format='json',
+        HTTP_AUTHORIZATION=f'JWT {token}'
+    )
+    assert response.status_code == 404
+
+    url = reverse('api_v0:groups:item', kwargs={'group_id': group.id})
+    response = api_client.patch(
+        url,
+        {'name': 'New name'},
+        format='json',
+        HTTP_AUTHORIZATION=f'JWT {token}'
+    )
+    assert response.status_code == 200
+    json_response = response.json()
+
+    group.refresh_from_db()
+
+    assert group.name == 'New name'
+    assert json_response['id'] == group.id
+    assert json_response['name'] == 'New name'
+
+
+@pytest.mark.django_db
+def test_delete_group(api_client, data_fixture):
+    user, token = data_fixture.create_user_and_token()
+    group = data_fixture.create_group(user=user, name='Old name')
+
+    url = reverse('api_v0:groups:item', kwargs={'group_id': 99999})
+    response = api_client.delete(
+        url,
+        HTTP_AUTHORIZATION=f'JWT {token}'
+    )
+    assert response.status_code == 404
+
+    url = reverse('api_v0:groups:item', kwargs={'group_id': group.id})
+    response = api_client.delete(
+        url,
+        HTTP_AUTHORIZATION=f'JWT {token}'
+    )
+    assert response.status_code == 204
+    assert Group.objects.all().count() == 0
+
+
+@pytest.mark.django_db
+def test_reorder_groups(api_client, data_fixture):
+    user, token = data_fixture.create_user_and_token()
+    group_user_1 = data_fixture.create_user_group(user=user)
+    group_user_2 = data_fixture.create_user_group(user=user)
+    group_user_3 = data_fixture.create_user_group(user=user)
+
+    url = reverse('api_v0:groups:order')
+    response = api_client.post(
+        url,
+        {'groups': [group_user_2.group.id, group_user_1.group.id,
+                    group_user_3.group.id]},
+        format='json',
+        HTTP_AUTHORIZATION=f'JWT {token}'
+    )
+    assert response.status_code == 204
+
+    group_user_1.refresh_from_db()
+    group_user_2.refresh_from_db()
+    group_user_3.refresh_from_db()
+
+    assert [1, 2, 3] == [group_user_2.order, group_user_1.order, group_user_3.order]
diff --git a/backend/tests/baserow/api/v0/user/test_token_auth.py b/backend/tests/baserow/api/v0/user/test_token_auth.py
index 740ef960d..c9f5a45fd 100644
--- a/backend/tests/baserow/api/v0/user/test_token_auth.py
+++ b/backend/tests/baserow/api/v0/user/test_token_auth.py
@@ -16,32 +16,32 @@ User = get_user_model()
 
 
 @pytest.mark.django_db
-def test_token_auth(client, data_fixture):
+def test_token_auth(api_client, data_fixture):
     data_fixture.create_user(email='test@test.nl', password='password',
                              first_name='Test1')
 
-    response = client.post(reverse('api:user:token_auth'), {
+    response = api_client.post(reverse('api_v0:user:token_auth'), {
         'username': 'no_existing@test.nl',
         'password': 'password'
-    })
+    }, format='json')
     json = response.json()
 
     assert response.status_code == 400
     assert len(json['non_field_errors']) > 0
 
-    response = client.post(reverse('api:user:token_auth'), {
+    response = api_client.post(reverse('api_v0:user:token_auth'), {
         'username': 'test@test.nl',
         'password': 'wrong_password'
-    })
+    }, format='json')
     json = response.json()
 
     assert response.status_code == 400
     assert len(json['non_field_errors']) > 0
 
-    response = client.post(reverse('api:user:token_auth'), {
+    response = api_client.post(reverse('api_v0:user:token_auth'), {
         'username': 'test@test.nl',
         'password': 'password'
-    })
+    },  format='json')
     json = response.json()
 
     assert response.status_code == 200
@@ -52,17 +52,16 @@ def test_token_auth(client, data_fixture):
 
 
 @pytest.mark.django_db
-def test_token_refresh(client, data_fixture):
-    user = data_fixture.create_user(email='test@test.nl', password='password',
-                                    first_name='Test1')
+def test_token_refresh(api_client, data_fixture):
+    user, token = data_fixture.create_user_and_token(
+        email='test@test.nl', password='password', first_name='Test1')
 
-    response = client.post(reverse('api:user:token_refresh'), {'token': 'WRONG_TOKEN'})
+    response = api_client.post(reverse('api_v0:user:token_refresh'),
+                               {'token': 'WRONG_TOKEN'}, format='json')
     assert response.status_code == 400
 
-    payload = jwt_payload_handler(user)
-    token = jwt_encode_handler(payload)
-
-    response = client.post(reverse('api:user:token_refresh'), {'token': token})
+    response = api_client.post(reverse('api_v0:user:token_refresh'),
+                               {'token': token}, format='json')
     assert response.status_code == 200
     assert 'token' in response.json()
 
@@ -71,5 +70,6 @@ def test_token_refresh(client, data_fixture):
         payload = jwt_payload_handler(user)
         token = jwt_encode_handler(payload)
 
-        response = client.post(reverse('api:user:token_refresh'), {'token': token})
+        response = api_client.post(reverse('api_v0:user:token_refresh'),
+                                   json={'token': token})
         assert response.status_code == 400
diff --git a/backend/tests/baserow/api/v0/user/test_user_views.py b/backend/tests/baserow/api/v0/user/test_user_views.py
index 25f9ba952..c0cde03c4 100644
--- a/backend/tests/baserow/api/v0/user/test_user_views.py
+++ b/backend/tests/baserow/api/v0/user/test_user_views.py
@@ -9,11 +9,11 @@ User = get_user_model()
 
 @pytest.mark.django_db
 def test_create_user(client):
-    response = client.post(reverse('api:user:index'), {
+    response = client.post(reverse('api_v0:user:index'), {
         'name': 'Test1',
         'email': 'test@test.nl',
         'password': 'test12'
-    })
+    }, format='json')
 
     assert response.status_code == 200
     user = User.objects.get(email='test@test.nl')
@@ -21,17 +21,17 @@ def test_create_user(client):
     assert user.email == 'test@test.nl'
     assert user.password != ''
 
-    response_failed = client.post(reverse('api:user:index'), {
+    response_failed = client.post(reverse('api_v0:user:index'), {
         'name': 'Test1',
         'email': 'test@test.nl',
         'password': 'test12'
-    })
+    }, format='json')
 
     assert response_failed.status_code == 400
     assert response_failed.json()['error'] == 'ERROR_ALREADY_EXISTS'
 
-    response_failed_2 = client.post(reverse('api:user:index'), {
+    response_failed_2 = client.post(reverse('api_v0:user:index'), {
         'email': 'test'
-    })
+    }, format='json')
 
     assert response_failed_2.status_code == 400
diff --git a/backend/tests/baserow/core/test_core_handler.py b/backend/tests/baserow/core/test_core_handler.py
new file mode 100644
index 000000000..8d69d57cc
--- /dev/null
+++ b/backend/tests/baserow/core/test_core_handler.py
@@ -0,0 +1,94 @@
+import pytest
+
+from baserow.core.handler import CoreHandler
+from baserow.core.models import Group, GroupUser
+from baserow.core.exceptions import UserNotIngroupError
+
+
+@pytest.mark.django_db
+def test_create_group(data_fixture):
+    user = data_fixture.create_user()
+
+    handler = CoreHandler()
+    handler.create_group(user=user, name='Test group')
+
+    group = Group.objects.all().first()
+    user_group = GroupUser.objects.all().first()
+
+    assert group.name == 'Test group'
+    assert user_group.user == user
+    assert user_group.group == group
+    assert user_group.order == 1
+
+    handler.create_group(user=user, name='Test group 2')
+
+    assert Group.objects.all().count() == 2
+    assert GroupUser.objects.all().count() == 2
+
+
+@pytest.mark.django_db
+def test_update_group(data_fixture):
+    user_1 = data_fixture.create_user()
+    user_2 = data_fixture.create_user()
+    group = data_fixture.create_group(user=user_1)
+
+    handler = CoreHandler()
+    handler.update_group(user=user_1, group=group, name='New name')
+
+    group.refresh_from_db()
+
+    assert group.name == 'New name'
+
+    with pytest.raises(UserNotIngroupError):
+        handler.update_group(user=user_2, group=group, name='New name')
+
+
+@pytest.mark.django_db
+def test_delete_group(data_fixture):
+    user = data_fixture.create_user()
+    group_1 = data_fixture.create_group(user=user)
+    group_2 = data_fixture.create_group(user=user)
+
+    user_2 = data_fixture.create_user()
+    group_3 = data_fixture.create_group(user=user_2)
+
+    handler = CoreHandler()
+    handler.delete_group(user, group_1)
+
+    assert Group.objects.all().count() == 2
+    assert GroupUser.objects.all().count() == 2
+
+    with pytest.raises(UserNotIngroupError):
+        handler.delete_group(user, group_3)
+
+    handler.delete_group(user_2, group_3)
+
+    assert Group.objects.all().count() == 1
+    assert GroupUser.objects.all().count() == 1
+
+
+@pytest.mark.django_db
+def test_order_groups(data_fixture):
+    user = data_fixture.create_user()
+    ug_1 = data_fixture.create_user_group(user=user, order=1)
+    ug_2 = data_fixture.create_user_group(user=user, order=2)
+    ug_3 = data_fixture.create_user_group(user=user, order=3)
+
+    assert [1, 2, 3] == [ug_1.order, ug_2.order, ug_3.order]
+
+    handler = CoreHandler()
+    handler.order_groups(user, [ug_3.id, ug_2.id, ug_1.id])
+
+    ug_1.refresh_from_db()
+    ug_2.refresh_from_db()
+    ug_3.refresh_from_db()
+
+    assert [1, 2, 3] == [ug_3.order, ug_2.order, ug_1.order]
+
+    handler.order_groups(user, [ug_2.id, ug_1.id, ug_3.id])
+
+    ug_1.refresh_from_db()
+    ug_2.refresh_from_db()
+    ug_3.refresh_from_db()
+
+    assert [1, 2, 3] == [ug_2.order, ug_1.order, ug_3.order]
diff --git a/backend/tests/baserow/core/test_core_managers.py b/backend/tests/baserow/core/test_core_managers.py
new file mode 100644
index 000000000..aeea1da2d
--- /dev/null
+++ b/backend/tests/baserow/core/test_core_managers.py
@@ -0,0 +1,24 @@
+import pytest
+
+from baserow.core.models import Group
+
+
+@pytest.mark.django_db
+def test_groups_of_user(data_fixture):
+    user_1 = data_fixture.create_user()
+    group_user_1 = data_fixture.create_user_group(user=user_1, order=1)
+    group_user_2 = data_fixture.create_user_group(user=user_1, order=2)
+    group_user_3 = data_fixture.create_user_group(user=user_1, order=0)
+
+    user_2 = data_fixture.create_user()
+    group_user_4 = data_fixture.create_user_group(user=user_2, order=0)
+
+    groups_user_1 = Group.objects.of_user(user=user_1)
+    assert len(groups_user_1) == 3
+    assert groups_user_1[0].id == group_user_3.id
+    assert groups_user_1[1].id == group_user_1.id
+    assert groups_user_1[2].id == group_user_2.id
+
+    groups_user_2 = Group.objects.of_user(user=user_2)
+    assert len(groups_user_2) == 1
+    assert groups_user_2[0].id == group_user_4.id
diff --git a/backend/tests/baserow/core/test_core_models.py b/backend/tests/baserow/core/test_core_models.py
new file mode 100644
index 000000000..4abcf464d
--- /dev/null
+++ b/backend/tests/baserow/core/test_core_models.py
@@ -0,0 +1,26 @@
+import pytest
+
+from baserow.core.models import GroupUser
+
+
+@pytest.mark.django_db
+def test_group_user_get_next_order(data_fixture):
+    user = data_fixture.create_user()
+
+    assert GroupUser.get_last_order(user) == 1
+
+    group_user_1 = data_fixture.create_user_group(order=0)
+    group_user_2_1 = data_fixture.create_user_group(order=10)
+    group_user_2_2 = data_fixture.create_user_group(user=group_user_2_1.user, order=11)
+
+    assert GroupUser.get_last_order(group_user_1.user) == 1
+    assert GroupUser.get_last_order(group_user_2_1.user) == 12
+
+
+@pytest.mark.django_db
+def test_group_has_user(data_fixture):
+    user = data_fixture.create_user()
+    user_group = data_fixture.create_user_group()
+
+    assert user_group.group.has_user(user_group.user)
+    assert not user_group.group.has_user(user)
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index f6224f5b4..15dce07ff 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -5,3 +5,9 @@ import pytest
 def data_fixture():
     from .fixtures import Fixtures
     return Fixtures()
+
+
+@pytest.fixture()
+def api_client():
+    from rest_framework.test import APIClient
+    return APIClient()
diff --git a/backend/tests/fixtures/__init__.py b/backend/tests/fixtures/__init__.py
index 1b2f9b50e..13a51aaef 100644
--- a/backend/tests/fixtures/__init__.py
+++ b/backend/tests/fixtures/__init__.py
@@ -1,5 +1,8 @@
+from faker import Faker
+
 from .user import UserFixtures
+from .group import GroupFixtures
 
 
-class Fixtures(UserFixtures):
-    pass
+class Fixtures(UserFixtures, GroupFixtures):
+    fake = Faker()
diff --git a/backend/tests/fixtures/group.py b/backend/tests/fixtures/group.py
new file mode 100644
index 000000000..bcb3d2fec
--- /dev/null
+++ b/backend/tests/fixtures/group.py
@@ -0,0 +1,32 @@
+from baserow.core.models import Group, GroupUser
+
+
+class GroupFixtures:
+    def create_group(self, **kwargs):
+        user = kwargs.pop('user', None)
+        users = kwargs.pop('users', [])
+
+        if user:
+            users.insert(0, user)
+
+        if 'name' not in kwargs:
+            kwargs['name'] = self.fake.name()
+
+        group = Group.objects.create(**kwargs)
+
+        for user in users:
+            self.create_user_group(group=group, user=user, order=0)
+
+        return group
+
+    def create_user_group(self, **kwargs):
+        if 'group' not in kwargs:
+            kwargs['group'] = self.create_group()
+
+        if 'user' not in kwargs:
+            kwargs['user'] = self.create_user()
+
+        if 'order' not in kwargs:
+            kwargs['order'] = 0
+
+        return GroupUser.objects.create(**kwargs)
diff --git a/backend/tests/fixtures/user.py b/backend/tests/fixtures/user.py
index 2b58a1c68..d6b5b8e48 100644
--- a/backend/tests/fixtures/user.py
+++ b/backend/tests/fixtures/user.py
@@ -1,20 +1,39 @@
 from django.contrib.auth import get_user_model
-from faker import Faker
+
+from rest_framework_jwt.settings import api_settings
 
 
-fake = Faker()
 User = get_user_model()
+jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
+jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
 
 
 class UserFixtures:
+    def generate_token(self, user):
+        payload = jwt_payload_handler(user)
+        token = jwt_encode_handler(payload)
+        return token
+
     def create_user(self, **kwargs):
-        kwargs.setdefault('email', fake.email())
-        kwargs.setdefault('username', kwargs['email'])
-        kwargs.setdefault('first_name', fake.name())
-        kwargs.setdefault('password', 'password')
+        if 'email' not in kwargs:
+            kwargs['email'] = self.fake.email()
+
+        if 'username' not in kwargs:
+            kwargs['username'] = kwargs['email']
+
+        if 'first_name' not in kwargs:
+            kwargs['first_name'] = self.fake.name()
+
+        if 'password' not in kwargs:
+            kwargs['password'] = 'password'
 
         user = User(**kwargs)
         user.set_password(kwargs['password'])
         user.save()
 
         return user
+
+    def create_user_and_token(self, **kwargs):
+        user = self.create_user(**kwargs)
+        token = self.generate_token(user)
+        return user, token
diff --git a/start_osx.sh b/start_osx.sh
new file mode 100755
index 000000000..a5ed3151f
--- /dev/null
+++ b/start_osx.sh
@@ -0,0 +1,36 @@
+#!/bin/bash
+
+tabname() {
+  printf "\e]1;$1\a"
+}
+
+new_tab() {
+  TAB_NAME=$1
+  COMMAND=$2
+  CONTAINER_COMMAND=$3
+
+  osascript \
+      -e "tell application \"Terminal\"" \
+      -e "tell application \"System Events\" to keystroke \"t\" using {command down}" \
+      -e "do script \"printf '\\\e]1;$TAB_NAME\\\a'; $COMMAND\" in front window" \
+      -e "do script \"$CONTAINER_COMMAND\" in front window" \
+      -e "end tell" > /dev/null
+}
+
+docker-compose up -d
+
+new_tab "Backend" \
+        "docker exec -it baserow bash" \
+        "cd backend; python src/baserow/manage.py runserver 0.0.0.0:8000"
+
+new_tab "Web frontend" \
+        "docker exec -it baserow bash" \
+        "cd web-frontend; yarn run dev"
+
+new_tab "Web frontend eslint" \
+        "docker exec -it baserow bash" \
+        "cd web-frontend; yarn run eslint --fix"
+
+new_tab "Old web frontend" \
+        "docker exec -it baserow bash" \
+        "cd old-web-frontend; yarn run dev"
diff --git a/stop_osx.sh b/stop_osx.sh
new file mode 100755
index 000000000..eabe86329
--- /dev/null
+++ b/stop_osx.sh
@@ -0,0 +1,3 @@
+#!/bin/bash
+
+docker-compose kill
diff --git a/web-frontend/assets/scss/abstracts/_mixins.scss b/web-frontend/assets/scss/abstracts/_mixins.scss
index f17260724..e4fa6f735 100644
--- a/web-frontend/assets/scss/abstracts/_mixins.scss
+++ b/web-frontend/assets/scss/abstracts/_mixins.scss
@@ -39,6 +39,14 @@
   .alert-content {
     color: $text;
   }
+
+  .alert-close {
+    color: $title;
+
+    &:hover {
+      color: $text;
+    }
+  }
 }
 
 @mixin fixed-height($height, $font-size) {
@@ -54,6 +62,18 @@
   text-align: center;
 }
 
+@mixin loading($size: 1.4em) {
+  position: relative;
+  display: block;
+  width: $size;
+  height: $size;
+  border-radius: 50%;
+  border: 0.25em solid;
+  border-color: $color-primary-500 transparent $color-primary-500 transparent;
+  animation: spin infinite 1800ms;
+  animation-timing-function: cubic-bezier(0.785, 0.135, 0.15, 0.86);
+}
+
 // The basically works the same as padding: 10px 0 0 10px or padding: 10px; only then
 // resulting position: absolute; with corresponding top, right, bottom, left values.
 // -------
diff --git a/web-frontend/assets/scss/abstracts/_variables.scss b/web-frontend/assets/scss/abstracts/_variables.scss
index e1812d75d..b59188bf3 100644
--- a/web-frontend/assets/scss/abstracts/_variables.scss
+++ b/web-frontend/assets/scss/abstracts/_variables.scss
@@ -63,4 +63,15 @@ $z-index-layout-col-2: 2;
 $z-index-layout-col-3: 1;
 $z-index-layout-col-3-1: 5;
 $z-index-layout-col-3-2: 4;
-$z-index-modal: 6;
+
+// The z-index of modal and context must always be the same because they can be nested
+// inside each other. The order in html decided which must be shown over the other.
+$z-index-modal: 7;
+$z-index-context: 7;
+
+// The notifications will be on top of anything else, because the message is temporary
+// and must always be visible.
+$z-index-notifications: 8;
+
+// normalize overrides
+$base-font-family: $text-font-stack;
diff --git a/web-frontend/assets/scss/base/_base.scss b/web-frontend/assets/scss/base/_base.scss
index 52b9444b5..1501f6d02 100644
--- a/web-frontend/assets/scss/base/_base.scss
+++ b/web-frontend/assets/scss/base/_base.scss
@@ -11,6 +11,10 @@ body {
   background-color: $color-neutral-100;
 }
 
+a {
+  cursor: pointer;
+}
+
 *,
 *::before,
 *::after {
diff --git a/web-frontend/assets/scss/base/_helpers.scss b/web-frontend/assets/scss/base/_helpers.scss
index 8e74fb1e2..878ef3703 100644
--- a/web-frontend/assets/scss/base/_helpers.scss
+++ b/web-frontend/assets/scss/base/_helpers.scss
@@ -8,6 +8,10 @@
   display: none;
 }
 
+.visibility-hidden {
+  visibility: hidden;
+}
+
 .align-right {
   text-align: right;
 }
diff --git a/web-frontend/assets/scss/components/_alert.scss b/web-frontend/assets/scss/components/_alert.scss
index 3f36ec74e..68af751fc 100644
--- a/web-frontend/assets/scss/components/_alert.scss
+++ b/web-frontend/assets/scss/components/_alert.scss
@@ -10,6 +10,10 @@
     position: relative;
     padding-left: 72px;
   }
+
+  &.alert-with-shadow {
+    box-shadow: 0 2px 6px 0 rgba($black, 0.16);
+  }
 }
 
 .alert-icon {
@@ -35,6 +39,10 @@
   margin: 0;
 }
 
+.alert-close {
+  @include absolute(10px, 10px, auto, auto);
+}
+
 .alert-success {
   @include alert-style($color-success-100, $color-success-300, $color-success-900, $color-success-700, $color-success-400);
 }
diff --git a/web-frontend/assets/scss/components/_context.scss b/web-frontend/assets/scss/components/_context.scss
index b3114459b..f95ce3106 100644
--- a/web-frontend/assets/scss/components/_context.scss
+++ b/web-frontend/assets/scss/components/_context.scss
@@ -1,6 +1,6 @@
 .context {
   position: absolute;
-  z-index: 1;
+  z-index: $z-index-context;
   white-space: nowrap;
   background-color: $white;
   border-radius: 6px;
@@ -8,6 +8,17 @@
   box-shadow: 0 2px 6px 0 rgba($black, 0.16);
 }
 
+.context-loading {
+  display: flex;
+  justify-content: center;
+  padding: 32px 0;
+}
+
+.context-description {
+  padding: 32px 0;
+  text-align: center;
+}
+
 .context-menu-title {
   color: $color-neutral-600;
   padding: 12px 8px 2px 8px;
diff --git a/web-frontend/assets/scss/components/_form.scss b/web-frontend/assets/scss/components/_form.scss
index 8e9db3bbe..66ad07731 100644
--- a/web-frontend/assets/scss/components/_form.scss
+++ b/web-frontend/assets/scss/components/_form.scss
@@ -57,6 +57,10 @@
   > div {
     width: 100%;
   }
+
+  &.actions-right {
+    justify-content: right;
+  }
 }
 
 .action-links {
diff --git a/web-frontend/assets/scss/components/_loading.scss b/web-frontend/assets/scss/components/_loading.scss
new file mode 100644
index 000000000..6bff294a5
--- /dev/null
+++ b/web-frontend/assets/scss/components/_loading.scss
@@ -0,0 +1,3 @@
+.loading {
+  @include loading();
+}
diff --git a/web-frontend/assets/scss/components/_notifications.scss b/web-frontend/assets/scss/components/_notifications.scss
new file mode 100644
index 000000000..b9411f7ae
--- /dev/null
+++ b/web-frontend/assets/scss/components/_notifications.scss
@@ -0,0 +1,7 @@
+.notifications {
+  position: fixed;
+  top: 0;
+  right: 30px;
+  width: 320px;
+  z-index: $z-index-notifications;
+}
diff --git a/web-frontend/assets/scss/components/_select.scss b/web-frontend/assets/scss/components/_select.scss
index f441f6e8b..bdda6739e 100644
--- a/web-frontend/assets/scss/components/_select.scss
+++ b/web-frontend/assets/scss/components/_select.scss
@@ -82,6 +82,17 @@
       display: none;
     }
   }
+
+  &.select-item-loading {
+    background-color: $color-neutral-100;
+
+    &::before {
+      content: " ";
+
+      @include loading(14px);
+      @include absolute(9px, 9px, auto, auto);
+    }
+  }
 }
 
 .select-item-link {
@@ -114,7 +125,7 @@
     color: $color-neutral-700;
   }
 
-  :hover > & {
+  :not(.select-item-loading):hover > & {
     display: block;
   }
 }
diff --git a/web-frontend/assets/scss/default.scss b/web-frontend/assets/scss/default.scss
index a1ad1f74e..087151fe7 100755
--- a/web-frontend/assets/scss/default.scss
+++ b/web-frontend/assets/scss/default.scss
@@ -26,3 +26,5 @@
 @import 'components/views/grid/boolean';
 @import 'components/views/grid/number';
 @import 'components/box_page';
+@import 'components/loading';
+@import 'components/notifications';
diff --git a/web-frontend/components/Context.vue b/web-frontend/components/Context.vue
new file mode 100644
index 000000000..6fb984bba
--- /dev/null
+++ b/web-frontend/components/Context.vue
@@ -0,0 +1,184 @@
+<template>
+  <div class="context" :class="{ 'visibility-hidden': !open }">
+    <slot></slot>
+  </div>
+</template>
+
+<script>
+import { isElement } from '@/utils/dom'
+import MoveToBody from '@/mixins/moveToBody'
+
+export default {
+  name: 'Context',
+  mixins: [MoveToBody],
+  data() {
+    return {
+      open: false,
+      opener: null,
+      children: []
+    }
+  },
+  methods: {
+    /**
+     * Toggles the open state of the context menu.
+     *
+     * @param target      The original element element that changed the state of the
+     *                    context, this will be used to calculate the correct position.
+     * @param vertical    Bottom positions the context under the target.
+     *                    Top positions the context above the target.
+     *                    Over-bottom positions the context over and under the target.
+     *                    Over-top positions the context over and above the target
+     * @param horizontal  Left aligns the context with the left side of the target.
+     *                    Right aligns the context with the right side of the target.
+     * @param offset      The distance between the target element and the context.
+     * @param value       True if context must be shown, false if not and undefine
+     *                    will invert the current state.
+     */
+    toggle(
+      target,
+      vertical = 'bottom',
+      horizontal = 'left',
+      offset = 10,
+      value
+    ) {
+      if (value === undefined) {
+        value = !this.open
+      }
+
+      if (value) {
+        this.show(target, vertical, horizontal, offset)
+      } else {
+        this.hide()
+      }
+    },
+    /**
+     * Calculate the position, show the context menu and register a click event on the
+     * body to check if the user has clicked outside the context.
+     */
+    show(target, vertical, horizontal, offset) {
+      const css = this.calculatePosition(target, vertical, horizontal, offset)
+
+      // Set the calculated positions of the context.
+      for (const key in css) {
+        const value = css[key] !== null ? Math.ceil(css[key]) + 'px' : 'auto'
+        this.$el.style[key] = value
+      }
+
+      // If we store the element who opened the context menu we can exclude the element
+      // when clicked outside of this element.
+      this.opener = target
+      this.open = true
+
+      this.$el.clickOutsideEvent = event => {
+        if (
+          // Check if the context menu is still open
+          this.open &&
+          // If the click was outside the context element because we want to ignore
+          // clicks inside it.s
+          !isElement(this.$el, event.target) &&
+          // If the click was not on the opener because he can trigger the toggle
+          // method.
+          !isElement(this.opener, event.target) &&
+          // If the click was not inside one of the context children of this context
+          // menu.
+          !this.children.some(component =>
+            isElement(component.$el, event.target)
+          )
+        ) {
+          this.hide()
+        }
+      }
+      document.body.addEventListener('click', this.$el.clickOutsideEvent)
+    },
+    /**
+     * Hide the context menu and make sure the body event is removed.
+     */
+    hide() {
+      this.opener = null
+      this.open = false
+
+      document.body.removeEventListener('click', this.$el.clickOutsideEvent)
+    },
+    /**
+     * Calculates the absolute position of the context based on the original clicked
+     * element.
+     */
+    calculatePosition(target, vertical, horizontal, offset) {
+      const targetRect = target.getBoundingClientRect()
+      const contextRect = this.$el.getBoundingClientRect()
+      const positions = { top: null, right: null, bottom: null, left: null }
+
+      // Calculate if top, bottom, left and right positions are possible.
+      const canTop = targetRect.top - contextRect.height - offset > 0
+      const canBottom =
+        window.innerHeight - targetRect.bottom - contextRect.height - offset > 0
+      const canOverTop = targetRect.bottom - contextRect.height - offset > 0
+      const canOverBottom =
+        window.innerHeight - targetRect.bottom - contextRect.height - offset > 0
+      const canRight = targetRect.right - contextRect.width > 0
+      const canLeft =
+        window.innerWidth - targetRect.left - contextRect.width > 0
+
+      // If bottom, top, left or right doesn't fit, but their opposite does we switch to
+      // that.
+      if (vertical === 'bottom' && !canBottom && canTop) {
+        vertical = 'top'
+      }
+
+      if (vertical === 'top' && !canTop) {
+        vertical = 'bottom'
+      }
+
+      if (vertical === 'over-bottom' && !canOverBottom && canOverTop) {
+        vertical = 'over-top'
+      }
+
+      if (vertical === 'over-top' && !canOverTop) {
+        vertical = 'over-bottom'
+      }
+
+      if (horizontal === 'left' && !canLeft && canRight) {
+        horizontal = 'right'
+      }
+
+      if (horizontal === 'right' && !canRight) {
+        horizontal = 'left'
+      }
+
+      // Calculate the correct positions for horizontal and vertical values.
+      if (horizontal === 'left') {
+        positions.left = targetRect.left
+      }
+
+      if (horizontal === 'right') {
+        positions.right = window.innerWidth - targetRect.right
+      }
+
+      if (vertical === 'bottom') {
+        positions.top = targetRect.bottom + offset
+      }
+
+      if (vertical === 'top') {
+        positions.bottom = window.innerHeight - targetRect.top + offset
+      }
+
+      if (vertical === 'over-bottom') {
+        positions.top = targetRect.top + offset
+      }
+
+      if (vertical === 'over-top') {
+        positions.bottom = window.innerHeight - targetRect.bottom + offset
+      }
+
+      return positions
+    },
+    /**
+     * A child context can register itself with the parent to prevent closing of the
+     * parent when clicked inside the child.
+     */
+    registerContextChild(element) {
+      this.children.push(element)
+    }
+  }
+}
+</script>
diff --git a/web-frontend/components/Editable.vue b/web-frontend/components/Editable.vue
new file mode 100644
index 000000000..bd02769aa
--- /dev/null
+++ b/web-frontend/components/Editable.vue
@@ -0,0 +1,103 @@
+<template>
+  <span
+    ref="editable"
+    :contenteditable="editing"
+    @input="update"
+    @keydown="keydown"
+    @focusout="change"
+    @paste="paste"
+  ></span>
+</template>
+
+<script>
+import { focusEnd } from '@/utils/dom'
+
+export default {
+  name: 'Editable',
+  props: {
+    value: {
+      type: String,
+      required: true
+    }
+  },
+  data() {
+    return {
+      editing: false,
+      oldValue: '',
+      newValue: ''
+    }
+  },
+  mounted() {
+    this.set(this.value)
+  },
+  methods: {
+    /**
+     * This method must be called when the is going to be edited. It will enable the
+     * contenteditable state and will focus the element.
+     */
+    edit() {
+      this.editing = true
+      this.$nextTick(() => {
+        focusEnd(this.$refs.editable)
+      })
+    },
+    /**
+     * This method is called when the value has changed and needs to be saved. It will
+     * change the editing state and will emit a change event if the new value has
+     * changed.
+     */
+    change() {
+      this.editing = false
+
+      if (this.oldValue === this.newValue) {
+        return
+      }
+
+      this.$emit('change', {
+        oldValue: this.value,
+        value: this.newValue
+      })
+      this.oldValue = this.newValue
+    },
+    /**
+     * Everytime a key is pressed inside the editable this event will be trigger which
+     * will update the new value.
+     */
+    update(event) {
+      const target = event.target
+      const text = target.textContent
+      this.newValue = text
+    },
+    /**
+     * When someone pastes something we want to only insert the plain text instead of
+     * the styled content.
+     */
+    paste(event) {
+      event.preventDefault()
+      const text = (event.originalEvent || event).clipboardData.getData(
+        'text/plain'
+      )
+      document.execCommand('insertHTML', false, text)
+    },
+    /**
+     * If a key is pressed and it is an enter or esc key the change event will be called
+     * to end the editing and save the value.
+     */
+    keydown(event) {
+      if (event.keyCode === 13 || event.keyCode === 27) {
+        event.preventDefault()
+        this.change()
+        return false
+      }
+    },
+    /**
+     *
+     */
+    set(value) {
+      this.oldValue = this.value
+      this.newValue = this.value
+      this.$refs.editable.innerText = this.value
+    }
+  }
+}
+</script>
diff --git a/web-frontend/components/Modal.vue b/web-frontend/components/Modal.vue
new file mode 100644
index 000000000..67784f817
--- /dev/null
+++ b/web-frontend/components/Modal.vue
@@ -0,0 +1,81 @@
+<template>
+  <transition name="fade">
+    <div
+      v-if="open"
+      ref="modalWrapper"
+      class="modal-wrapper"
+      @click="outside($event)"
+    >
+      <div class="modal-box">
+        <a class="modal-close" @click="hide()">
+          <i class="fas fa-times"></i>
+        </a>
+        <slot></slot>
+      </div>
+    </div>
+  </transition>
+</template>
+
+<script>
+import MoveToBody from '@/mixins/moveToBody'
+
+export default {
+  name: 'Modal',
+  mixins: [MoveToBody],
+  data() {
+    return {
+      open: false
+    }
+  },
+  destroyed() {
+    window.removeEventListener('keyup', this.keyup)
+  },
+  methods: {
+    /**
+     * Toggle the open state of the modal.
+     */
+    toggle(value) {
+      if (value === undefined) {
+        value = !this.open
+      }
+
+      if (value) {
+        this.show()
+      } else {
+        this.hide()
+      }
+    },
+    /**
+     * Show the modal.
+     */
+    show() {
+      this.open = true
+      window.addEventListener('keyup', this.keyup)
+    },
+    /**
+     * Hide the modal.
+     */
+    hide() {
+      this.open = false
+      window.removeEventListener('keyup', this.keyup)
+    },
+    /**
+     * If someone actually clicked on the modal wrapper and not one of his children the
+     * modal should be closed.
+     */
+    outside(event) {
+      if (event.target === this.$refs.modalWrapper) {
+        this.hide()
+      }
+    },
+    /**
+     *
+     */
+    keyup(event) {
+      if (event.keyCode === 27) {
+        this.hide()
+      }
+    }
+  }
+}
+</script>
diff --git a/web-frontend/components/group/CreateGroupModal.vue b/web-frontend/components/group/CreateGroupModal.vue
new file mode 100644
index 000000000..70de9d35a
--- /dev/null
+++ b/web-frontend/components/group/CreateGroupModal.vue
@@ -0,0 +1,49 @@
+<template>
+  <Modal>
+    <h2 class="box-title">Create new group</h2>
+    <GroupForm ref="groupForm" @submitted="submitted">
+      <div class="actions">
+        <div class="align-right">
+          <button
+            class="button button-large"
+            :class="{ 'button-loading': loading }"
+            :disabled="loading"
+          >
+            Add group
+          </button>
+        </div>
+      </div>
+    </GroupForm>
+  </Modal>
+</template>
+
+<script>
+import GroupForm from './GroupForm'
+
+import modal from '@/mixins/modal'
+
+export default {
+  name: 'CreateGroupModal',
+  components: { GroupForm },
+  mixins: [modal],
+  data() {
+    return {
+      loading: false
+    }
+  },
+  methods: {
+    submitted(values) {
+      this.loading = true
+      this.$store
+        .dispatch('group/create', values)
+        .then(() => {
+          this.loading = false
+          this.hide()
+        })
+        .catch(() => {
+          this.loading = false
+        })
+    }
+  }
+}
+</script>
diff --git a/web-frontend/components/group/GroupForm.vue b/web-frontend/components/group/GroupForm.vue
new file mode 100644
index 000000000..ad46180e6
--- /dev/null
+++ b/web-frontend/components/group/GroupForm.vue
@@ -0,0 +1,47 @@
+<template>
+  <form @submit.prevent="submit">
+    <div class="control">
+      <label class="control-label">
+        <i class="fas fa-font"></i>
+        Name
+      </label>
+      <div class="control-elements">
+        <input
+          ref="name"
+          v-model="values.name"
+          :class="{ 'input-error': $v.values.name.$error }"
+          type="text"
+          class="input input-large"
+          @blur="$v.values.name.$touch()"
+        />
+        <div v-if="$v.values.name.$error" class="error">
+          This field is required.
+        </div>
+      </div>
+    </div>
+    <slot></slot>
+  </form>
+</template>
+
+<script>
+import { required } from 'vuelidate/lib/validators'
+
+import form from '@/mixins/form'
+
+export default {
+  name: 'GroupForm',
+  mixins: [form],
+  data() {
+    return {
+      values: {
+        name: ''
+      }
+    }
+  },
+  validations: {
+    values: {
+      name: { required }
+    }
+  }
+}
+</script>
diff --git a/web-frontend/components/group/GroupsContext.vue b/web-frontend/components/group/GroupsContext.vue
new file mode 100644
index 000000000..6982a5a46
--- /dev/null
+++ b/web-frontend/components/group/GroupsContext.vue
@@ -0,0 +1,154 @@
+<template>
+  <Context class="select">
+    <div class="select-search">
+      <i class="select-search-icon fas fa-search"></i>
+      <input
+        v-model="query"
+        type="text"
+        class="select-search-input"
+        placeholder="Search views"
+      />
+    </div>
+    <div v-if="isLoading" class="context-loading">
+      <div class="loading"></div>
+    </div>
+    <ul v-if="!isLoading && isLoaded && groups.length > 0" class="select-items">
+      <li
+        v-for="group in searchAndSort(groups)"
+        :key="group.id"
+        :ref="'groupSelect' + group.id"
+        class="select-item"
+      >
+        <div class="loading-overlay"></div>
+        <a class="select-item-link">
+          <Editable
+            :ref="'groupRename' + group.id"
+            :value="group.name"
+            @change="renameGroup(group, $event)"
+          ></Editable>
+        </a>
+        <a
+          :ref="'groupOptions' + group.id"
+          class="select-item-options"
+          @click="toggleContext(group.id)"
+        >
+          <i class="fas fa-ellipsis-v"></i>
+        </a>
+      </li>
+    </ul>
+    <div
+      v-if="!isLoading && isLoaded && groups.length == 0"
+      class="context-description"
+    >
+      No results found
+    </div>
+    <Context ref="groupsItemContext">
+      <ul class="context-menu">
+        <li>
+          <a @click="toggleRename(contextId)">
+            <i class="context-menu-icon fas fa-fw fa-pen"></i>
+            Rename group
+          </a>
+        </li>
+        <li>
+          <a @click="deleteGroup(contextId)">
+            <i class="context-menu-icon fas fa-fw fa-trash"></i>
+            Delete group
+          </a>
+        </li>
+      </ul>
+    </Context>
+    <div class="select-footer">
+      <a class="select-footer-button" @click="$refs.createGroupModal.show()">
+        <i class="fas fa-plus"></i>
+        Create group
+      </a>
+    </div>
+    <CreateGroupModal ref="createGroupModal"></CreateGroupModal>
+  </Context>
+</template>
+
+<script>
+import { mapGetters, mapState } from 'vuex'
+
+import CreateGroupModal from '@/components/group/CreateGroupModal'
+import context from '@/mixins/context'
+
+export default {
+  name: 'GroupsItemContext',
+  components: {
+    CreateGroupModal
+  },
+  mixins: [context],
+  data() {
+    return {
+      query: '',
+      contextId: -1
+    }
+  },
+  computed: {
+    ...mapState({
+      groups: state => state.group.items
+    }),
+    ...mapGetters({
+      isLoading: 'group/isLoading',
+      isLoaded: 'group/isLoaded'
+    })
+  },
+  methods: {
+    toggle(...args) {
+      this.$store.dispatch('group/loadAll')
+      this.getRootContext().toggle(...args)
+    },
+    toggleContext(groupId) {
+      const target = this.$refs['groupOptions' + groupId][0]
+      this.contextId = groupId
+      this.$refs.groupsItemContext.toggle(target, 'bottom', 'right', 0)
+    },
+    searchAndSort(groups) {
+      const query = this.query
+
+      return groups.filter(function(group) {
+        const regex = new RegExp('(' + query + ')', 'i')
+        return group.name.match(regex)
+      })
+      // .sort((a, b) => {
+      //   return a.order - b.order
+      // })
+    },
+    toggleRename(id) {
+      this.$refs.groupsItemContext.hide()
+      this.$refs['groupRename' + id][0].edit()
+    },
+    renameGroup(group, event) {
+      const select = this.$refs['groupSelect' + group.id][0]
+      select.classList.add('select-item-loading')
+
+      this.$store
+        .dispatch('group/update', {
+          id: group.id,
+          values: {
+            name: event.value
+          }
+        })
+        .catch(() => {
+          // If something is going wrong we will reset the original value
+          const rename = this.$refs['groupRename' + group.id][0]
+          rename.set(event.oldValue)
+        })
+        .then(() => {
+          select.classList.remove('select-item-loading')
+        })
+    },
+    deleteGroup(id) {
+      this.$refs.groupsItemContext.hide()
+      const select = this.$refs['groupSelect' + id][0]
+      select.classList.add('select-item-loading')
+
+      this.$store.dispatch('group/delete', id).catch(() => {
+        select.classList.remove('select-item-loading')
+      })
+    }
+  }
+}
+</script>
diff --git a/web-frontend/components/notifications/Notification.vue b/web-frontend/components/notifications/Notification.vue
new file mode 100644
index 000000000..b35cb6ffc
--- /dev/null
+++ b/web-frontend/components/notifications/Notification.vue
@@ -0,0 +1,42 @@
+<template>
+  <div
+    class="alert alert-with-shadow alert-has-icon"
+    :class="notificationClass"
+  >
+    <a class="alert-close" @click="close(notification)">
+      <i class="fas fa-times"></i>
+    </a>
+    <div class="alert-icon">
+      <i class="fas fa-exclamation"></i>
+    </div>
+    <div class="alert-title">{{ notification.title }}</div>
+    <p class="alert-content">{{ notification.message }}</p>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'Notification',
+  props: {
+    notification: {
+      type: Object,
+      required: true
+    }
+  },
+  computed: {
+    notificationClass() {
+      return 'alert-' + this.notification.type
+    }
+  },
+  mounted() {
+    setTimeout(() => {
+      this.close(this.notification)
+    }, 5000)
+  },
+  methods: {
+    close(notification) {
+      this.$store.dispatch('notification/remove', notification)
+    }
+  }
+}
+</script>
diff --git a/web-frontend/components/notifications/Notifications.vue b/web-frontend/components/notifications/Notifications.vue
new file mode 100644
index 000000000..78926df8b
--- /dev/null
+++ b/web-frontend/components/notifications/Notifications.vue
@@ -0,0 +1,25 @@
+<template>
+  <div class="notifications">
+    <Notification
+      v-for="notification in notifications"
+      :key="notification.id"
+      :notification="notification"
+    ></Notification>
+  </div>
+</template>
+
+<script>
+import { mapState } from 'vuex'
+
+import Notification from '@/components/notifications/Notification'
+
+export default {
+  name: 'Notifications',
+  components: { Notification },
+  computed: {
+    ...mapState({
+      notifications: state => state.notification.items
+    })
+  }
+}
+</script>
diff --git a/web-frontend/config/nuxt.config.base.js b/web-frontend/config/nuxt.config.base.js
index 5bdb09e02..2e7bc2122 100644
--- a/web-frontend/config/nuxt.config.base.js
+++ b/web-frontend/config/nuxt.config.base.js
@@ -25,7 +25,12 @@ export default {
   /*
    ** Plugins to load before mounting the App
    */
-  plugins: [{ src: '@/plugins/auth.js' }, { src: '@/plugins/vuelidate.js' }],
+  plugins: [
+    { src: '@/plugins/global.js' },
+    { src: '@/plugins/client.js' },
+    { src: '@/plugins/auth.js' },
+    { src: '@/plugins/vuelidate.js' }
+  ],
 
   /*
    ** Nuxt.js modules
diff --git a/web-frontend/intellij-idea.webpack.config.js b/web-frontend/intellij-idea.webpack.config.js
new file mode 100644
index 000000000..a2fe6fbfc
--- /dev/null
+++ b/web-frontend/intellij-idea.webpack.config.js
@@ -0,0 +1,16 @@
+/** This file can be used in combination with intellij idea so the @ path resolves **/
+
+const path = require('path')
+
+module.exports = {
+  resolve: {
+    extensions: ['.js', '.json', '.vue', '.ts'],
+    root: path.resolve(__dirname),
+    alias: {
+      '@': path.resolve(__dirname),
+      '@@': path.resolve(__dirname),
+      '~': path.resolve(__dirname),
+      '~~': path.resolve(__dirname)
+    }
+  }
+}
diff --git a/web-frontend/layouts/app.vue b/web-frontend/layouts/app.vue
new file mode 100644
index 000000000..b6593b1f7
--- /dev/null
+++ b/web-frontend/layouts/app.vue
@@ -0,0 +1,106 @@
+<template>
+  <div>
+    <Notifications></Notifications>
+    <div :class="{ 'layout-collapsed': isCollapsed }" class="layout">
+      <div class="layout-col-1 menu">
+        <ul class="menu-items">
+          <li class="menu-item">
+            <nuxt-link :to="{ name: 'app' }" class="menu-link">
+              <i class="fas fa-tachometer-alt"></i>
+              <span class="menu-link-text">Dashboard</span>
+            </nuxt-link>
+          </li>
+          <li class="menu-item">
+            <a
+              ref="groupSelectToggle"
+              class="menu-link"
+              @click="$refs.groupSelect.toggle($refs.groupSelectToggle)"
+            >
+              <i class="fas fa-layer-group"></i>
+              <span class="menu-link-text">Groups</span>
+            </a>
+            <GroupsContext ref="groupSelect"></GroupsContext>
+          </li>
+        </ul>
+        <ul class="menu-items">
+          <li class="menu-item layout-uncollapse">
+            <a class="menu-link" @click="toggleCollapsed()">
+              <i class="menu-item-icon fas fa-angle-double-right"></i>
+              <span class="menu-link-text">Uncollapse</span>
+            </a>
+          </li>
+          <li class="menu-item">
+            <a
+              class="menu-link menu-user-item"
+              @click="$refs.userContext.toggle($event.target)"
+            >
+              {{ nameAbbreviation }}
+              <span class="menu-link-text">{{ name }}</span>
+            </a>
+            <Context ref="userContext">
+              <div class="context-menu-title">{{ name }}</div>
+              <ul class="context-menu">
+                <li>
+                  <a @click="logoff()">
+                    <i class="context-menu-icon fas fa-fw fa-sign-out-alt"></i>
+                    Logoff
+                  </a>
+                </li>
+              </ul>
+            </Context>
+          </li>
+        </ul>
+      </div>
+      <div class="layout-col-2 sidebar">
+        <div class="sidebar-content-wrapper">
+          <nav class="sidebar-content">
+            <div class="sidebar-title">
+              <img src="@/static/img/logo.svg" alt="" />
+            </div>
+          </nav>
+        </div>
+        <div class="sidebar-footer">
+          <a class="sidebar-collapse" @click="toggleCollapsed()">
+            <i class="fas fa-angle-double-left"></i>
+            Collapse sidebar
+          </a>
+        </div>
+      </div>
+      <div class="layout-col-3">
+        <nuxt />
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import { mapActions, mapGetters } from 'vuex'
+
+import Notifications from '@/components/notifications/Notifications'
+import GroupsContext from '@/components/group/GroupsContext'
+
+export default {
+  layout: 'default',
+  middleware: 'authenticated',
+  components: {
+    GroupsContext,
+    Notifications
+  },
+  computed: {
+    ...mapGetters({
+      isCollapsed: 'sidebar/isCollapsed',
+      name: 'auth/getName',
+      nameAbbreviation: 'auth/getNameAbbreviation'
+    })
+  },
+  methods: {
+    logoff() {
+      this.$store.dispatch('auth/logoff')
+      this.$nuxt.$router.replace({ name: 'login' })
+    },
+    ...mapActions({
+      toggleCollapsed: 'sidebar/toggleCollapsed'
+    })
+  }
+}
+</script>
diff --git a/web-frontend/layouts/default.vue b/web-frontend/layouts/default.vue
deleted file mode 100644
index 984257528..000000000
--- a/web-frontend/layouts/default.vue
+++ /dev/null
@@ -1,5 +0,0 @@
-<template>
-  <div>
-    <nuxt />
-  </div>
-</template>
diff --git a/web-frontend/layouts/login.vue b/web-frontend/layouts/login.vue
index 26a13a8d9..417806a42 100644
--- a/web-frontend/layouts/login.vue
+++ b/web-frontend/layouts/login.vue
@@ -1,5 +1,6 @@
 <template>
   <div>
+    <Notifications></Notifications>
     <div class="box-page-header"></div>
     <div class="box-page">
       <div class="box login-page login-page-login">
@@ -8,3 +9,11 @@
     </div>
   </div>
 </template>
+
+<script>
+import Notifications from '@/components/notifications/Notifications'
+
+export default {
+  components: { Notifications }
+}
+</script>
diff --git a/web-frontend/mixins/context.js b/web-frontend/mixins/context.js
new file mode 100644
index 000000000..784338d0f
--- /dev/null
+++ b/web-frontend/mixins/context.js
@@ -0,0 +1,25 @@
+/**
+ * This mixin is for components that have the Context component as root element.
+ * It will make it easier to call the root context specific functions.
+ */
+export default {
+  methods: {
+    getRootContext() {
+      if (
+        this.$children.length > 0 &&
+        this.$children[0].$options.name === 'Context'
+      ) {
+        return this.$children[0]
+      }
+    },
+    toggle(...args) {
+      this.getRootModal().toggle(...args)
+    },
+    show(...args) {
+      this.getRootModal().show(...args)
+    },
+    hide(...args) {
+      this.getRootModal().hide(...args)
+    }
+  }
+}
diff --git a/web-frontend/mixins/form.js b/web-frontend/mixins/form.js
new file mode 100644
index 000000000..e71c3c6ca
--- /dev/null
+++ b/web-frontend/mixins/form.js
@@ -0,0 +1,34 @@
+/**
+ * This mixin introduces some helper functions for form components where the
+ * whole component existence is based on being a form.
+ */
+export default {
+  props: {
+    defaultValues: {
+      type: Object,
+      required: false,
+      default: () => {
+        return {}
+      }
+    }
+  },
+  mounted() {
+    Object.assign(this.values, this.values, this.defaultValues)
+  },
+  methods: {
+    submit() {
+      this.$v.$touch()
+      if (!this.$v.$invalid) {
+        this.$emit('submitted', this.values)
+      }
+    },
+    reset() {
+      Object.assign(
+        this.values,
+        this.$options.data.call(this).values,
+        this.defaultValues
+      )
+      this.$v.$reset()
+    }
+  }
+}
diff --git a/web-frontend/mixins/modal.js b/web-frontend/mixins/modal.js
new file mode 100644
index 000000000..ef7da4efd
--- /dev/null
+++ b/web-frontend/mixins/modal.js
@@ -0,0 +1,25 @@
+/**
+ * This mixin is for components that have the Modal component as root element.
+ * It will make it easier to call the root modal specific functions.
+ */
+export default {
+  methods: {
+    getRootModal() {
+      if (
+        this.$children.length > 0 &&
+        this.$children[0].$options.name === 'Modal'
+      ) {
+        return this.$children[0]
+      }
+    },
+    toggle(...args) {
+      this.getRootModal().toggle(...args)
+    },
+    show(...args) {
+      this.getRootModal().show(...args)
+    },
+    hide(...args) {
+      this.getRootModal().hide(...args)
+    }
+  }
+}
diff --git a/web-frontend/mixins/moveToBody.js b/web-frontend/mixins/moveToBody.js
new file mode 100644
index 000000000..c5f715f2c
--- /dev/null
+++ b/web-frontend/mixins/moveToBody.js
@@ -0,0 +1,32 @@
+export default {
+  /**
+   * Because we don't want the parent context to close when a user clicks 'outside' that
+   * element and in the child element we need to register the child with their parent to
+   * prevent this.
+   */
+  mounted() {
+    let $parent = this.$parent
+    while ($parent !== undefined) {
+      if ($parent.registerContextChild) {
+        $parent.registerContextChild(this)
+      }
+      $parent = $parent.$parent
+    }
+
+    // Move the rendered element to the top of the body so it can be positioned over any
+    // other element.
+    const body = document.body
+    body.insertBefore(this.$el, body.firstChild)
+  },
+  /**
+   * Make sure the context menu is not open and all the events on the body are removed
+   * and that the element is removed from the body.
+   */
+  destroyed() {
+    this.hide()
+
+    if (this.$el.parentNode) {
+      this.$el.parentNode.removeChild(this.$el)
+    }
+  }
+}
diff --git a/web-frontend/package.json b/web-frontend/package.json
index ca7438f21..0abf5e240 100644
--- a/web-frontend/package.json
+++ b/web-frontend/package.json
@@ -46,6 +46,7 @@
     "eslint-plugin-vue": "^5.2.2",
     "jest": "^24.1.0",
     "jsdom": "^15.1.1",
+    "jsdom-global": "^3.0.2",
     "moxios": "^0.4.0",
     "node-mocks-http": "^1.7.6",
     "nodemon": "^1.18.9",
diff --git a/web-frontend/pages/app/index.vue b/web-frontend/pages/app/index.vue
index d0a7363c8..5b4df3975 100644
--- a/web-frontend/pages/app/index.vue
+++ b/web-frontend/pages/app/index.vue
@@ -1,6 +1,9 @@
 <template>
   <div>
     <h1>Welcome {{ user }}</h1>
+    <p>
+      {{ groups }}
+    </p>
   </div>
 </template>
 
@@ -8,10 +11,11 @@
 import { mapState } from 'vuex'
 
 export default {
-  middleware: 'authenticated',
+  layout: 'app',
   computed: {
     ...mapState({
-      user: state => state.auth.user
+      user: state => state.auth.user,
+      groups: state => state.group.items
     })
   }
 }
diff --git a/web-frontend/pages/login/index.vue b/web-frontend/pages/login/index.vue
index 91ebb00fb..73403b6be 100644
--- a/web-frontend/pages/login/index.vue
+++ b/web-frontend/pages/login/index.vue
@@ -56,6 +56,7 @@
         <button
           :class="{ 'button-loading': loading }"
           class="button button-large"
+          :disabled="loading"
         >
           Sign in
           <i class="fas fa-lock-open"></i>
@@ -104,11 +105,14 @@ export default {
           .then(() => {
             this.$nuxt.$router.replace({ name: 'app' })
           })
-          .catch(() => {
-            this.invalid = true
-            this.credentials.password = ''
-            this.$v.$reset()
-            this.$refs.password.focus()
+          .catch(error => {
+            // If the status code is 400 the provided email or password is incorrect.
+            if (error.response && error.response.status === 400) {
+              this.invalid = true
+              this.credentials.password = ''
+              this.$v.$reset()
+              this.$refs.password.focus()
+            }
           })
           .then(() => {
             this.loading = false
diff --git a/web-frontend/pages/login/signup.vue b/web-frontend/pages/login/signup.vue
index e88d58735..2774114fb 100644
--- a/web-frontend/pages/login/signup.vue
+++ b/web-frontend/pages/login/signup.vue
@@ -86,6 +86,7 @@
         <button
           :class="{ 'button-loading': loading }"
           class="button button-large"
+          :disabled="loading"
         >
           Sign up
           <i class="fas fa-user-plus"></i>
diff --git a/web-frontend/plugins/auth.js b/web-frontend/plugins/auth.js
index 7ee29cba2..73e70a920 100644
--- a/web-frontend/plugins/auth.js
+++ b/web-frontend/plugins/auth.js
@@ -1,16 +1,4 @@
-import { client } from '@/services/client'
-
 export default function({ store }) {
-  // Create a request interceptor to add the authorization token to every
-  // request if the user is authenticated.
-  client.interceptors.request.use(config => {
-    if (store.getters['auth/isAuthenticated']) {
-      const token = store.getters['auth/token']
-      config.headers.Authorization = `JWT: ${token}`
-    }
-    return config
-  })
-
   // If the user is authenticated, but is not refreshing in the browser means
   // that the refresh was done on the server side, so we need to manually start
   // the refreshing timeout here.
diff --git a/web-frontend/plugins/client.js b/web-frontend/plugins/client.js
new file mode 100644
index 000000000..cdefb595a
--- /dev/null
+++ b/web-frontend/plugins/client.js
@@ -0,0 +1,45 @@
+import { client } from '@/services/client'
+
+export default function({ store }) {
+  // Create a request interceptor to add the authorization token to every
+  // request if the user is authenticated.
+  client.interceptors.request.use(config => {
+    if (store.getters['auth/isAuthenticated']) {
+      const token = store.getters['auth/token']
+      config.headers.Authorization = `JWT ${token}`
+    }
+    return config
+  })
+
+  // Create a response interceptor to add more detail tot the error message
+  // and to create a notification when there is a network error.
+  client.interceptors.response.use(
+    response => {
+      return response
+    },
+    error => {
+      error.responseError = undefined
+      error.responseDetail = undefined
+
+      // Add the error message in the response to the error object.
+      if (
+        error.response &&
+        'error' in error.response.data &&
+        'detail' in error.response.data
+      ) {
+        error.responseError = error.response.data.error
+        error.responseDetail = error.response.data.detail
+      }
+
+      // Network error, the server could not reached
+      if (!error.response) {
+        store.dispatch('notification/error', {
+          title: 'Network error',
+          message: 'Could not connect to the API server.'
+        })
+      }
+
+      return Promise.reject(error)
+    }
+  )
+}
diff --git a/web-frontend/plugins/global.js b/web-frontend/plugins/global.js
new file mode 100644
index 000000000..b000c73f9
--- /dev/null
+++ b/web-frontend/plugins/global.js
@@ -0,0 +1,9 @@
+import Vue from 'vue'
+
+import Context from '@/components/Context'
+import Modal from '@/components/Modal'
+import Editable from '@/components/Editable'
+
+Vue.component('Context', Context)
+Vue.component('Modal', Modal)
+Vue.component('Editable', Editable)
diff --git a/web-frontend/services/client.js b/web-frontend/services/client.js
index 52b9cc1ff..2315c1f32 100644
--- a/web-frontend/services/client.js
+++ b/web-frontend/services/client.js
@@ -8,24 +8,3 @@ export const client = axios.create({
     'Content-Type': 'application/json'
   }
 })
-
-client.interceptors.response.use(
-  response => {
-    return response
-  },
-  error => {
-    error.responseError = undefined
-    error.responseDetail = undefined
-
-    if (
-      error.response &&
-      'error' in error.response.data &&
-      'detail' in error.response.data
-    ) {
-      error.responseError = error.response.data.error
-      error.responseDetail = error.response.data.detail
-    }
-
-    return Promise.reject(error)
-  }
-)
diff --git a/web-frontend/services/group.js b/web-frontend/services/group.js
new file mode 100644
index 000000000..62e377bc4
--- /dev/null
+++ b/web-frontend/services/group.js
@@ -0,0 +1,16 @@
+import { client } from './client'
+
+export default {
+  fetchAll() {
+    return client.get('/groups/')
+  },
+  create(values) {
+    return client.post('/groups/', values)
+  },
+  update(id, values) {
+    return client.patch(`/groups/${id}/`, values)
+  },
+  delete(id) {
+    return client.delete(`/groups/${id}/`)
+  }
+}
diff --git a/web-frontend/store/auth.js b/web-frontend/store/auth.js
index 86dca60db..ceb43ab44 100644
--- a/web-frontend/store/auth.js
+++ b/web-frontend/store/auth.js
@@ -49,6 +49,14 @@ export const actions = {
       }
     )
   },
+  /**
+   * Logs off the user by removing the token as a cookie and clearing the user
+   * data.
+   */
+  logoff({ commit }) {
+    unsetToken(this.app.$cookies)
+    commit('CLEAR_USER_DATA')
+  },
   /**
    * Refresh the existing token. If successful commit the new token and start a
    * new refresh timeout. If unsuccessful the existing cookie and user data is
@@ -100,10 +108,19 @@ export const getters = {
   token(state) {
     return state.token
   },
+  getName(state) {
+    return state.user ? state.user.first_name : ''
+  },
+  getNameAbbreviation(state) {
+    return state.user ? state.user.first_name.split('')[0] : ''
+  },
+  getEmail(state) {
+    return state.user ? state.user.email : ''
+  },
   /**
    * Returns the amount of seconds it will take before the tokes expires.
-   * @TODO figure out what happens if the browser and server time and not very
-   *       much in sync.
+   * @TODO figure out what happens if the browser and server time are not in
+   *       sync.
    */
   tokenExpireSeconds(state) {
     const now = Math.ceil(new Date().getTime() / 1000)
diff --git a/web-frontend/store/group.js b/web-frontend/store/group.js
new file mode 100644
index 000000000..4d4ab10a1
--- /dev/null
+++ b/web-frontend/store/group.js
@@ -0,0 +1,80 @@
+// import { set } from 'vue'
+
+import GroupService from '@/services/group'
+
+export const state = () => ({
+  loaded: false,
+  loading: false,
+  items: []
+})
+
+export const mutations = {
+  SET_LOADED(state, loaded) {
+    state.loaded = loaded
+  },
+  SET_LOADING(state, loading) {
+    state.loading = loading
+  },
+  SET_ITEMS(state, items) {
+    state.items = items
+  },
+  ADD_ITEM(state, item) {
+    state.items.push(item)
+  },
+  UPDATE_ITEM(state, values) {
+    const index = state.items.findIndex(item => item.id === values.id)
+    Object.assign(state.items[index], state.items[index], values)
+  },
+  DELETE_ITEM(state, id) {
+    const index = state.items.findIndex(item => item.id === id)
+    state.items.splice(index, 1)
+  }
+}
+
+export const actions = {
+  loadAll({ state, dispatch }) {
+    if (!state.loaded && !state.loading) {
+      dispatch('fetchAll')
+    }
+  },
+  fetchAll({ commit }) {
+    commit('SET_LOADING', true)
+
+    return GroupService.fetchAll()
+      .then(({ data }) => {
+        commit('SET_LOADED', true)
+        commit('SET_ITEMS', data)
+      })
+      .catch(() => {
+        commit('SET_ITEMS', [])
+      })
+      .then(() => {
+        commit('SET_LOADING', false)
+      })
+  },
+  create({ commit }, values) {
+    return GroupService.create(values).then(({ data }) => {
+      commit('ADD_ITEM', data)
+    })
+  },
+  update({ commit }, { id, values }) {
+    return GroupService.update(id, values).then(({ data }) => {
+      commit('UPDATE_ITEM', data)
+    })
+  },
+  delete({ commit }, id) {
+    return GroupService.delete(id).then(() => {
+      console.log(id)
+      commit('DELETE_ITEM', id)
+    })
+  }
+}
+
+export const getters = {
+  isLoaded(state) {
+    return state.loaded
+  },
+  isLoading(state) {
+    return state.loading
+  }
+}
diff --git a/web-frontend/store/notification.js b/web-frontend/store/notification.js
new file mode 100644
index 000000000..b1746c03f
--- /dev/null
+++ b/web-frontend/store/notification.js
@@ -0,0 +1,43 @@
+import { uuid } from '@/utils/string'
+
+export const state = () => ({
+  items: []
+})
+
+export const mutations = {
+  ADD(state, notification) {
+    state.items.unshift(notification)
+  },
+  REMOVE(state, notification) {
+    const index = state.items.indexOf(notification)
+    state.items.splice(index, 1)
+  }
+}
+
+export const actions = {
+  add({ commit }, { type, title, message }) {
+    commit('ADD', {
+      id: uuid(),
+      type: type,
+      title: title,
+      message: message
+    })
+  },
+  info({ dispatch }, { title, message }) {
+    dispatch('add', { type: 'info', title, message })
+  },
+  error({ dispatch }, { title, message }) {
+    dispatch('add', { type: 'error', title, message })
+  },
+  warning({ dispatch }, { title, message }) {
+    dispatch('add', { type: 'warning', title, message })
+  },
+  success({ dispatch }, { title, message }) {
+    dispatch('add', { type: 'success', title, message })
+  },
+  remove({ commit }, notification) {
+    commit('REMOVE', notification)
+  }
+}
+
+export const getters = {}
diff --git a/web-frontend/store/sidebar.js b/web-frontend/store/sidebar.js
new file mode 100644
index 000000000..0f3a33f8a
--- /dev/null
+++ b/web-frontend/store/sidebar.js
@@ -0,0 +1,24 @@
+export const state = () => ({
+  collapsed: false
+})
+
+export const mutations = {
+  SET_COLLAPSED(state, collapsed) {
+    state.collapsed = collapsed
+  }
+}
+
+export const actions = {
+  toggleCollapsed({ commit, getters }, value) {
+    if (value === undefined) {
+      value = !getters.isCollapsed
+    }
+    commit('SET_COLLAPSED', value)
+  }
+}
+
+export const getters = {
+  isCollapsed(state) {
+    return !!state.collapsed
+  }
+}
diff --git a/web-frontend/test/utils/string.spec.js b/web-frontend/test/utils/string.spec.js
new file mode 100644
index 000000000..f24d56267
--- /dev/null
+++ b/web-frontend/test/utils/string.spec.js
@@ -0,0 +1,8 @@
+import { uuid } from '@/utils/string'
+
+describe('test string utils', () => {
+  test('test uuid', () => {
+    const value = uuid()
+    expect(typeof value).toBe('string')
+  })
+})
diff --git a/web-frontend/utils/dom.js b/web-frontend/utils/dom.js
new file mode 100644
index 000000000..e93334b4f
--- /dev/null
+++ b/web-frontend/utils/dom.js
@@ -0,0 +1,35 @@
+/**
+ * Checks if the target is the same as the provided element of that the element
+ * contains the target. Returns true is this is the case.
+ *
+ * @returns boolean
+ */
+export const isElement = (element, target) => {
+  return element === target || element.contains(target)
+}
+
+/**
+ * This function will focus a contenteditable and place the cursor at the end.
+ *
+ * @param element
+ */
+export const focusEnd = element => {
+  element.focus()
+
+  if (
+    typeof window.getSelection !== 'undefined' &&
+    typeof document.createRange !== 'undefined'
+  ) {
+    const range = document.createRange()
+    range.selectNodeContents(element)
+    range.collapse(false)
+    const sel = window.getSelection()
+    sel.removeAllRanges()
+    sel.addRange(range)
+  } else if (typeof document.body.createTextRange !== 'undefined') {
+    const textRange = document.body.createTextRange()
+    textRange.moveToElementText(element)
+    textRange.collapse(false)
+    textRange.select()
+  }
+}
diff --git a/web-frontend/utils/string.js b/web-frontend/utils/string.js
new file mode 100644
index 000000000..14b0ffc06
--- /dev/null
+++ b/web-frontend/utils/string.js
@@ -0,0 +1,9 @@
+export const uuid = function() {
+  let dt = new Date().getTime()
+  const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
+    const r = (dt + Math.random() * 16) % 16 | 0
+    dt = Math.floor(dt / 16)
+    return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
+  })
+  return uuid
+}
diff --git a/web-frontend/yarn.lock b/web-frontend/yarn.lock
index 6dbbac02d..883eba71c 100644
--- a/web-frontend/yarn.lock
+++ b/web-frontend/yarn.lock
@@ -6035,6 +6035,11 @@ jsbn@~0.1.0:
   resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513"
   integrity sha1-peZUwuWi3rXyAdls77yoDA7y9RM=
 
+jsdom-global@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/jsdom-global/-/jsdom-global-3.0.2.tgz#6bd299c13b0c4626b2da2c0393cd4385d606acb9"
+  integrity sha1-a9KZwTsMRiay2iwDk81DhdYGrLk=
+
 jsdom@^11.5.1:
   version "11.12.0"
   resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-11.12.0.tgz#1a80d40ddd378a1de59656e9e6dc5a3ba8657bc8"