diff --git a/app/apps/api/serializers.py b/app/apps/api/serializers.py
index 705c455a951f9d37fee0902a23c30a87b918b976..a84389e0015fe1e983a303faefd8e3e032b7357e 100644
--- a/app/apps/api/serializers.py
+++ b/app/apps/api/serializers.py
@@ -151,7 +151,7 @@ class BlockSerializer(serializers.ModelSerializer):
 class LineTranscriptionSerializer(serializers.ModelSerializer):
     class Meta:
         model = LineTranscription
-        fields = ('pk', 'line', 'transcription', 'content',
+        fields = ('pk', 'line', 'transcription', 'content', 'graphs',
                   'versions', 'version_author', 'version_source', 'version_updated_at')
 
     def cleanup(self, data):
diff --git a/app/apps/api/views.py b/app/apps/api/views.py
index baea888e47c0dfc412fb693eab490deb1a67def6..cad44e8f1912bceeb03155464e8691a0ba3f8f4d 100644
--- a/app/apps/api/views.py
+++ b/app/apps/api/views.py
@@ -181,6 +181,34 @@ class PartViewSet(DocumentPermissionMixin, ModelViewSet):
         serializer = LineOrderSerializer(document_part.lines.all(), many=True)
         return Response({'status': 'done', 'lines': serializer.data}, status=200)
 
+    @action(detail=True, methods=['post'])
+    def rotate(self, request, document_pk=None, pk=None):
+        document_part = DocumentPart.objects.get(pk=pk)
+        angle = self.request.data.get('angle')
+        if angle:
+            document_part.rotate(angle)
+            return Response({'status': 'done'}, status=200)
+        else:
+            return Response({'error': "Post an angle."},
+                            status=status.HTTP_400_BAD_REQUEST)
+
+    @action(detail=True, methods=['post'])
+    def crop(self, request, document_pk=None, pk=None):
+        document_part = DocumentPart.objects.get(pk=pk)
+        x1 = self.request.data.get('x1')
+        y1 = self.request.data.get('y1')
+        x2 = self.request.data.get('x2')
+        y2 = self.request.data.get('y2')
+        if (x1 is not None
+            and y1 is not None
+            and x2 is not None
+            and y2 is not None):
+            document_part.crop(x1, y1, x2, y2)
+            return Response({'status': 'done'}, status=200)
+        else:
+            return Response({'error': "Post corners as x1, y1 (top left) and x2, y2 (bottom right)."},
+                            status=status.HTTP_400_BAD_REQUEST)
+
 
 class DocumentTranscriptionViewSet(ModelViewSet):
     # Note: there is no dedicated Transcription viewset, it's always in the context of a Document
diff --git a/app/apps/core/models.py b/app/apps/core/models.py
index 2ad1e89fb56c23fd5a349999c03f5d718e28269f..162a718152de1ee70bb08e8a5b8167dcefd991b1 100644
--- a/app/apps/core/models.py
+++ b/app/apps/core/models.py
@@ -8,6 +8,7 @@ import subprocess
 import uuid
 from PIL import Image
 from datetime import datetime
+from shapely import affinity
 from shapely.geometry import Polygon, LineString
 
 from django.db import models, transaction
@@ -715,6 +716,8 @@ class DocumentPart(OrderedModel):
                         line=line, transcription=trans)
                     for pred in it:
                         lt.content = pred.prediction
+                        lt.graphs = [(char, pred.cuts[i], float(pred.confidences[i]))
+                                     for i, char in enumerate(pred.prediction)]
                     lt.save()
         else:
             Transcription.objects.get_or_create(
@@ -781,6 +784,102 @@ class DocumentPart(OrderedModel):
 
         return to_calc
 
+    def rotate(self, angle):
+        """
+        Rotates everything in this document part around the center by a given angle (in degrees):
+        images, lines and regions.
+        Changes the file system image path to deal with browser cache.
+        """
+        angle_match = re.search(r'_rot(\d+)', self.image.name)
+        old_angle = angle_match and int(angle_match.group(1)) or 0
+        new_angle = (old_angle + angle) % 360
+
+        def update_name(fpath, old_angle=old_angle,  new_angle=new_angle):
+            # we need to change the name of the file to avoid all kind of cache issues
+            if old_angle:
+                if new_angle:
+                    new_name = re.sub(r'(_rot)'+str(old_angle), r'\g<1>'+str(new_angle), fpath)
+                else:
+                    new_name = re.sub(r'_rot'+str(old_angle), '', fpath)
+            else:
+                # if there was no angle before, there is one now
+                name, ext = os.path.splitext(fpath)
+                new_name = f'{name}_rot{new_angle}{ext}'
+            return new_name
+
+        # rotate image
+        with Image.open(self.image.file.name) as im:
+            # store center point while it's open with old bounds
+            center = (im.width/2, im.height/2)
+            rim = im.rotate(360-angle, expand=True, fillcolor=None)
+
+            # the image size is shifted so we need to calculate by which offset
+            # to update points accordingly
+            new_center = (rim.width/2, rim.height/2)
+            offset = (center[0]-new_center[0], center[1]-new_center[1])
+
+            # Note: self.image.file.name (full path) != self.image.name (relative path)
+            rim.save(update_name(self.image.file.name))
+            rim.close()
+            # save the updated file name in db
+            self.image = update_name(self.image.name)
+
+        # rotate bw image
+        if self.bw_image:
+            with Image.open(self.bw_image.file.name) as im:
+                rim = im.rotate(360-angle, expand=True)
+                rim.save(update_name(self.bw_image.file.name))
+                rim.close()
+                self.bw_image = update_name(self.bw_image.name)
+
+        self.save()
+
+        get_thumbnailer(self.image).get_thumbnail(settings.THUMBNAIL_ALIASES['']['large'])
+
+        # rotate lines
+        for line in self.lines.all():
+            if line.baseline:
+                poly = affinity.rotate(LineString(line.baseline), angle, origin=center)
+                line.baseline = [(int(x-offset[0]), int(y-offset[1])) for x, y in poly.coords]
+            if line.mask:
+                poly = affinity.rotate(Polygon(line.mask), angle, origin=center)
+                line.mask = [(int(x-offset[0]), int(y-offset[1])) for x, y in poly.exterior.coords]
+            line.save()
+
+        # rotate regions
+        for region in self.blocks.all():
+            poly = affinity.rotate(Polygon(region.box), angle, origin=center)
+            region.box = [(int(x-offset[0]), int(y-offset[1])) for x, y in poly.exterior.coords]
+            region.save()
+
+    def crop(self, x1, y1, x2, y2):
+        """
+        Crops the image ouside the rectangle defined
+        by top left (x1, y1) and bottom right (x2, y2) points.
+        Moves the lines and regions accordingly.
+        """
+        with Image.open(self.image.file.name) as im:
+            cim = im.crop((x1, y1, x2, y2))
+            cim.save(self.image.file.name)
+            cim.close()
+
+        if self.bw_image:
+            with Image.open(self.image.file.name) as im:
+                cim = im.crop((x1, y1, x2, y2))
+                cim.save(self.image.file.name)
+                cim.close()
+
+        for line in self.lines.all():
+            if line.baseline:
+                line.baseline = [(int(x-x1), int(y-y1)) for x, y in line.baseline]
+            if line.mask:
+                line.mask = [(int(x-x1), int(y-y1)) for x, y in line.mask]
+            line.save()
+
+        for region in self.blocks.all():
+            region.box = [(int(x-x1), int(y-y1)) for x, y in region.box]
+            region.save()
+
     def enforce_line_order(self):
         # django-ordered-model doesn't care about unicity and linearity...
         lines = self.lines.order_by('order', 'pk')
@@ -803,6 +902,8 @@ def validate_polygon(value):
 
 
 def validate_2_points(value):
+    if value is None:
+        return
     if len(value) < 2:
         raise ValidationError(
             _('Polygon needs to have at least 2 points, it has %(length)d: %(value)s.'),
@@ -810,6 +911,8 @@ def validate_2_points(value):
 
 
 def validate_3_points(value):
+    if value is None:
+        return
     if len(value) < 3:
         raise ValidationError(
             _('Polygon needs to have at least 3 points, it has %(length)d: %(value)s.'),
diff --git a/app/apps/core/static/js/edit/components/source_panel.js b/app/apps/core/static/js/edit/components/source_panel.js
index 050d8f856bfd26909091188ad1554cd36611113f..0b78d6213be97f2c2c5765009dab408c43fb3d37 100644
--- a/app/apps/core/static/js/edit/components/source_panel.js
+++ b/app/apps/core/static/js/edit/components/source_panel.js
@@ -12,5 +12,10 @@ const SourcePanel = BasePanel.extend({
         this.$parent.zoom.register(
             this.$el.querySelector('#source-zoom-container'),
             {map: true});
+    },
+    methods: {
+        rotate(angle) {
+            this.part.rotate(angle);
+        }
     }
 });
diff --git a/app/apps/core/static/js/edit/components/trans_modal.js b/app/apps/core/static/js/edit/components/trans_modal.js
index eebfeb4b73e939c8a69a52efcbfae0a4e08f4561..3dfae0550a4a45a0d89ea43865ffa7104fdd4d01 100644
--- a/app/apps/core/static/js/edit/components/trans_modal.js
+++ b/app/apps/core/static/js/edit/components/trans_modal.js
@@ -26,6 +26,9 @@ const TranscriptionModal = Vue.component('transcriptionmodal', {
         $(this.$el).draggable({handle: '.modal-header'});
         $(this.$el).resizable();
         this.computeStyles();
+
+        let input = this.$el.querySelector('#trans-input');
+        input.focus();
     },
     watch: {
         line(new_, old_) {
@@ -82,40 +85,86 @@ const TranscriptionModal = Vue.component('transcriptionmodal', {
             }.bind(this)).join('');
         },
 
-        computeStyles() {
-            // this.zoom.reset();
+        getLineAngle() {
+            let p1 = this.line.baseline[0];
+            let p2 = this.line.baseline[this.line.baseline.length-1];
+            return Math.atan2(p2[1] - p1[1], p2[0] - p1[0]) * 180 / Math.PI;
+        },
+
+        getRotatedLineBBox() {
+            // create temporary polygon to calculate the line bounding box
+            if (this.line.mask) {
+                var maskPoints = this.line.mask.map(
+                    pt => Math.round(pt[0])+ ','+
+                        Math.round(pt[1])).join(' ');
+            } else {
+                // TODO
+            }
+            let svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
+            let tmppoly = document.createElementNS('http://www.w3.org/2000/svg',
+                                                   'polygon');
+            tmppoly.setAttributeNS(null, 'points', maskPoints);
+            tmppoly.setAttributeNS(null, 'fill', 'red');
+
+            // calculate rotation needed to get the line horizontal
+            let target_angle = READ_DIRECTION == 'rtl' ? 180 : 0;
+            let angle = target_angle - this.getLineAngle();
+
+            // apply it to the polygon and get the resulting bbox
+            let transformOrigin =  this.$parent.part.image.size[0]/2+'px '+this.$parent.part.image.size[1]/2+'px';
+            tmppoly.style.transformOrigin = transformOrigin;
+            tmppoly.style.transform = 'rotate('+angle+'deg)';
+            svg.appendChild(tmppoly);
+            document.body.appendChild(svg);
+            let bbox = tmppoly.getBoundingClientRect();
+            let width = bbox.width;
+            let height = bbox.height
+            let top = bbox.top - svg.getBoundingClientRect().top;
+            let left = bbox.left - svg.getBoundingClientRect().left;
+            document.body.removeChild(svg); // done its job
+            return {width: width, height: height, top: top, left: left, angle: angle};
+        },
+
+        computeImgStyles(bbox, ratio, lineHeight, hContext) {
             let modalImgContainer = this.$el.querySelector('#modal-img-container');
             let img = modalImgContainer.querySelector('img#line-img');
-            let hContext = 0.6; // vertical context added around the line, in percentage
-
-            let poly = this.line.mask || this.line.baseline;
-            let minx = Math.min.apply(null, poly.map(pt => pt[0]));
-            let miny = Math.min.apply(null, poly.map(pt => pt[1]));
-            let maxx = Math.max.apply(null, poly.map(pt => pt[0]));
-            let maxy = Math.max.apply(null, poly.map(pt => pt[1]));
-            let width = maxx - minx;
-            let height = maxy - miny;
-
-            // we use the same same vertical context horizontaly
-            let ratio = modalImgContainer.clientWidth / (width + (2*height*hContext));
-            var MAX_HEIGHT = Math.round(Math.max(25, (window.innerHeight-200) / 3));
-            let lineHeight = Math.max(30, Math.round(height*ratio));
-            if (lineHeight > MAX_HEIGHT) {
-                // change the ratio so that the image can not get too big
-                ratio = (MAX_HEIGHT/lineHeight)*ratio;
-                lineHeight = MAX_HEIGHT;
-            }
+
+
             let context = hContext*lineHeight;
             let visuHeight = lineHeight + 2*context;
             modalImgContainer.style.height = visuHeight+'px';
-            img.style.width = this.$parent.part.image.size[0]*ratio +'px';
 
-            let top = Math.round(miny*ratio)-context;
-            let left = Math.round(minx*ratio)-context;
-            let right = Math.round(maxx*ratio)-context;
-            img.style.top = -top+'px';
-            img.style.left = -left+'px';
+            let top = -(bbox.top*ratio - context);
+            let left = -(bbox.left*ratio - context);
+            // modalImgContainer.style.transform = 'scale('+ratio+')';
 
+            let imgWidth = this.$parent.part.image.size[0]*ratio +'px';
+            let transformOrigin =  this.$parent.part.image.size[0]*ratio/2+'px '+this.$parent.part.image.size[1]*ratio/2+'px';
+            let transform = 'translate('+left+'px, '+top+'px) rotate('+bbox.angle+'deg)';
+            img.style.width = imgWidth;
+            img.style.transformOrigin = transformOrigin;
+            img.style.transform = transform;
+
+            // Overlay
+            let overlay = modalImgContainer.querySelector('.overlay');
+            if (this.line.mask) {
+                let maskPoints = this.line.mask.map(
+                    pt => Math.round(pt[0]*ratio)+ ','+
+                        Math.round(pt[1]*ratio)).join(' ');
+                let polygon = overlay.querySelector('polygon');
+                polygon.setAttribute('points', maskPoints);
+                overlay.style.width = imgWidth;
+                overlay.style.height = this.$parent.part.image.size[1]*ratio+'px';
+                overlay.style.transformOrigin = transformOrigin;
+                overlay.style.transform = transform;
+                overlay.style.display = 'block';
+            } else {
+                // TODO: fake mask?!
+                overlay.style.display = 'none';
+            }
+        },
+
+        computeInputStyles(bbox, ratio, lineHeight, hContext) {
             // Content input
             let container = this.$el.querySelector('#trans-modal #trans-input-container');
             let input = container.querySelector('#trans-input');
@@ -128,18 +177,21 @@ const TranscriptionModal = Vue.component('transcriptionmodal', {
             ruler.style.whiteSpace="nowrap"
             container.appendChild(ruler);
 
-            let fontSize = Math.round(lineHeight*0.7);  // Note could depend on the script
+            let context = hContext*lineHeight;
+            let fontSize = Math.max(15, Math.round(lineHeight*0.7));  // Note could depend on the script
             ruler.style.fontSize = fontSize+'px';
             input.style.fontSize = fontSize+'px';
-            input.style.height = 'auto';
+            input.style.height = Math.round(fontSize*1.1)+'px';
 
             if (READ_DIRECTION == 'rtl') {
                 container.style.marginRight = context+'px';
             } else {
+                // left to right
+                // TODO: deal with other directions
                 container.style.marginLeft = context+'px';
             }
             if (content) {
-                let lineWidth = width*ratio;
+                let lineWidth = bbox.width*ratio;
                 var scaleX = Math.min(5,  lineWidth / ruler.clientWidth);
                 scaleX = Math.max(0.2, scaleX);
                 input.style.transform = 'scaleX('+ scaleX +')';
@@ -149,20 +201,28 @@ const TranscriptionModal = Vue.component('transcriptionmodal', {
                 input.style.width = '100%'; //'calc(100% - '+context+'px)';
             }
             container.removeChild(ruler);  // done its job
+        },
 
-            input.focus();
+        computeStyles() {
+            /*
+               Centers the image on the line (zoom + rotation)
+               Modifies input font size and height to match the image
+             */
+            let modalImgContainer = this.$el.querySelector('#modal-img-container');
 
-            // Overlay
-            let overlay = modalImgContainer.querySelector('.overlay');
-            if (this.line.mask) {
-                let maskPoints = this.line.mask.map(
-                    pt => Math.round(pt[0]*ratio-left)+ ','+
-                        Math.round(pt[1]*ratio-top)).join(' ');
-                overlay.querySelector('polygon').setAttribute('points', maskPoints);
-                overlay.style.display = 'block';
-            } else {
-                overlay.style.display = 'none';
+            bbox = this.getRotatedLineBBox();
+            let hContext = 0.3; // vertical context added around the line, in percentage
+            let ratio = modalImgContainer.clientWidth / (bbox.width + (2*bbox.height*hContext));
+            let MAX_HEIGHT = Math.round(Math.max(25, (window.innerHeight-230) / 3));
+            let lineHeight = Math.max(30, Math.round(bbox.height*ratio));
+            if (lineHeight > MAX_HEIGHT) {
+                // change the ratio so that the image can not get too big
+                ratio = (MAX_HEIGHT/lineHeight)*ratio;
+                lineHeight = MAX_HEIGHT;
             }
-        }
+
+            this.computeImgStyles(bbox, ratio, lineHeight, hContext);
+            this.computeInputStyles(bbox, ratio, lineHeight, hContext);
+        },
     },
 });
diff --git a/app/apps/core/static/js/edit/store/part.js b/app/apps/core/static/js/edit/store/part.js
index 7757e0826325576694a36ed989ab3f207735d0c1..241bf9b4b318d21837090802f9e46dae4bc12b92 100644
--- a/app/apps/core/static/js/edit/store/part.js
+++ b/app/apps/core/static/js/edit/store/part.js
@@ -336,6 +336,16 @@ const partStore = {
         }
         this.debouncedRecalculateOrdering();
     },
+    rotate(angle, callback) {
+        let uri = this.getApiPart() + 'rotate/';
+        this.push(uri, {angle: angle}, method="post")
+            .then(function(data) {
+                this.reload(callback);
+            }.bind(this))
+            .catch(function(error) {
+                console.log('couldnt rotate!', error);
+            });
+    },
 
     createRegion(region, callback) {
         let uri = this.getApiPart() + 'blocks/';
@@ -503,5 +513,10 @@ const partStore = {
             this.reset();
             this.fetchPart(this.next, cb);
         }
+    },
+    reload(cb) {
+        let pk = this.pk;
+        this.reset();
+        this.fetchPart(pk, cb);
     }
 };
diff --git a/app/apps/core/tasks.py b/app/apps/core/tasks.py
index 8b7ec0adb1fea32d7b5f9323973baed9e5f6cd9d..3f712953920ff692035b708404fbba40725742ea 100644
--- a/app/apps/core/tasks.py
+++ b/app/apps/core/tasks.py
@@ -4,6 +4,7 @@ import logging
 import numpy as np
 import os.path
 import shutil
+from itertools import groupby
 
 from django.apps import apps
 from django.conf import settings
@@ -115,9 +116,12 @@ def binarize(instance_pk, user_pk=None, binarizer=None, threshold=None, **kwargs
 def make_segmentation_training_data(part):
     return {
         'image': part.image.path,
-        'baselines': [{'script': 'default', 'baseline': bl}
+        'baselines': [{'script': bl.typology.name or 'default',
+                       'baseline': bl}
                       for bl in part.lines.values_list('baseline', flat=True) if bl],
-        'regions': {'default': [r.box for r in part.blocks.all().only('box')]}
+        'regions': {typo: regs for typo, regs in groupby(
+            (r.box for r in part.blocks.all().order_by('typology')),
+            key=lambda reg: reg.typology.name)}
     }
 
 
diff --git a/app/apps/users/admin.py b/app/apps/users/admin.py
index 6af1d512c76cdf156087be1e68471861792ddc2a..423dac64946e2f06d9d7eb8ca6d6851f0ac954da 100644
--- a/app/apps/users/admin.py
+++ b/app/apps/users/admin.py
@@ -6,7 +6,7 @@ from django.contrib import messages
 from django.utils.translation import ngettext
 
 
-from users.models import User, ResearchField, Invitation, ContactUs
+from users.models import User, ResearchField, Invitation, ContactUs, GroupOwner
 
 
 class MyUserChangeForm(UserChangeForm):
@@ -39,7 +39,8 @@ class MyUserAdmin(UserAdmin):
 class InvitationAdmin(admin.ModelAdmin):
     date_hierarchy = 'created_at'
     list_filter = ('workflow_state', 'group')
-    list_display = ('recipient_email', 'recipient_last_name', 'recipient_first_name', 'sender', 'workflow_state')
+    list_display = ('recipient_email', 'recipient_last_name', 'recipient_first_name',
+                    'sender', 'workflow_state')
     readonly_fields = ('sender', 'recipient', 'token', 'created_at', 'sent_at', 'workflow_state')
     actions = ['resend']
 
@@ -63,4 +64,4 @@ admin.site.register(User, MyUserAdmin)
 admin.site.register(ResearchField)
 admin.site.register(Invitation, InvitationAdmin)
 admin.site.register(ContactUs)
-
+admin.site.register(GroupOwner)
diff --git a/app/apps/users/forms.py b/app/apps/users/forms.py
index 669d26727521d2315ff83d589fccb9ca4b235981..7ff6d17af1968e2ab2e3be900eaeb1d0d4f23e70 100644
--- a/app/apps/users/forms.py
+++ b/app/apps/users/forms.py
@@ -1,10 +1,12 @@
 from django import forms
-from django.contrib.auth.forms import UserChangeForm, UserCreationForm
+from django.db.models import Q
+from django.contrib.auth.forms import UserCreationForm
 from django.utils.translation import gettext as _
-from captcha.fields import CaptchaField
+from django.contrib.auth.models import Group
 
+from captcha.fields import CaptchaField
 from bootstrap.forms import BootstrapFormMixin
-from users.models import Invitation, User, ContactUs
+from users.models import Invitation, User, ContactUs, GroupOwner
 
 
 class InvitationForm(BootstrapFormMixin, forms.ModelForm):
@@ -24,11 +26,43 @@ class InvitationForm(BootstrapFormMixin, forms.ModelForm):
     def save(self, commit=True):
         invitation = super().save(commit=False)
         invitation.sender = self.sender
-        invitation.save()
-        invitation.send(self.request)
+        if commit:
+            invitation.save()
+            invitation.send(self.request)
         return invitation
 
 
+class GroupInvitationForm(InvitationForm):
+    recipient_id = forms.CharField(label=_("Email or username."))
+
+    class Meta:
+        model = Invitation
+        fields = ['recipient_id', 'group']
+
+    def clean_recipient_id(self):
+        # we don't throw an error on purpose to avoid fishing
+        try:
+            return User.objects.get(Q(email=self.data.get('recipient_id')) |
+                                    Q(username=self.data.get('recipient_id')))
+        except User.DoesNotExist:
+            return None
+
+    def clean(self):
+        data = super().clean()
+        return data
+
+    def save(self, commit=True):
+        recipient = self.cleaned_data['recipient_id']
+        print('recipient', recipient)
+        if recipient:
+            invitation = super().save(commit=False)
+            invitation.recipient = recipient
+            if commit:
+                invitation.save()
+                invitation.send(self.request)
+            return invitation
+
+
 class InvitationAcceptForm(BootstrapFormMixin, UserCreationForm):
     """
     This is a registration form since a user is created.
@@ -55,10 +89,61 @@ class ProfileForm(BootstrapFormMixin, forms.ModelForm):
         fields = ('email', 'first_name', 'last_name')
 
 
-class ContactUsForm(BootstrapFormMixin, forms.ModelForm):
+class GroupForm(BootstrapFormMixin, forms.ModelForm):
+    class Meta:
+        model = Group
+        fields = ('name',)
+
+    def __init__(self, *args, **kwargs):
+        self.user = kwargs.pop('request').user
+        super().__init__(*args, **kwargs)
+
+    def save(self, commit=True):
+        group = super().save(commit=True)
+        group.user_set.add(self.user)
+        GroupOwner.objects.create(
+            group=group,
+            owner=self.user)
+        return group
+
 
-    message = forms.CharField(widget=forms.Textarea(attrs={
-        'placeholder': 'Message : Please precise your institution or research center if applicable'}))
+class RemoveUserFromGroup(forms.ModelForm):
+    user = forms.ModelChoiceField(queryset=User.objects.all())
+
+    class Meta:
+        model = Group
+        fields = ('user',)
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.fields['user'].queryset = self.instance.user_set.exclude(
+            pk=self.instance.groupowner.owner.pk)
+
+    def save(self, commit=True):
+        self.instance.user_set.remove(self.cleaned_data['user'])
+
+
+class TransferGroupOwnershipForm(forms.ModelForm):
+    user = forms.ModelChoiceField(queryset=User.objects.all())
+
+    class Meta:
+        model = Group
+        fields = ('user',)
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.fields['user'].queryset = self.instance.user_set.exclude(
+            pk=self.instance.groupowner.owner.pk)
+
+    def save(self, commit=True):
+        self.instance.groupowner.owner = self.cleaned_data['user']
+        self.instance.groupowner.save()
+
+
+class ContactUsForm(BootstrapFormMixin, forms.ModelForm):
+    message = forms.CharField(
+        label=_("Message : Please precise your institution or research center if applicable"),
+        widget=forms.Textarea)
     captcha = CaptchaField()
 
     class Meta:
diff --git a/app/apps/users/migrations/0011_groupowner.py b/app/apps/users/migrations/0011_groupowner.py
new file mode 100644
index 0000000000000000000000000000000000000000..6522124dafa03750ff7321d16428d6fc5bb7f53d
--- /dev/null
+++ b/app/apps/users/migrations/0011_groupowner.py
@@ -0,0 +1,24 @@
+# Generated by Django 2.1.4 on 2020-11-24 09:58
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('auth', '0009_alter_user_last_name_max_length'),
+        ('users', '0010_auto_20200828_0823'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='GroupOwner',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='auth.Group')),
+                ('owner', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_groups', to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+    ]
diff --git a/app/apps/users/migrations/0012_create_group_owners.py b/app/apps/users/migrations/0012_create_group_owners.py
new file mode 100644
index 0000000000000000000000000000000000000000..5d560b3f322e72c2f699af34aa819f57bdf7ecab
--- /dev/null
+++ b/app/apps/users/migrations/0012_create_group_owners.py
@@ -0,0 +1,28 @@
+# Generated by Django 2.1.4 on 2020-12-03 11:21
+
+from django.db import migrations
+
+
+def forward(apps, se):
+    Group = apps.get_model('auth', 'Group')
+    GroupOwner = apps.get_model('users', 'GroupOwner')
+
+    groups_without_owner = Group.objects.filter(groupowner__isnull=True)
+    for group in groups_without_owner:
+        GroupOwner.objects.create(group=group, owner=group.user_set.first())
+
+
+def backward(apps, se):
+    # no need to do anything
+    pass
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('users', '0011_groupowner'),
+    ]
+
+    operations = [
+        migrations.RunPython(forward, backward),
+    ]
diff --git a/app/apps/users/models.py b/app/apps/users/models.py
index 6f314694260aba6d1b22503b4a958890e7d920b1..91264d718bb07f9c6959c948490a6ef0d460a61b 100644
--- a/app/apps/users/models.py
+++ b/app/apps/users/models.py
@@ -1,7 +1,6 @@
 import os
 import uuid
 from datetime import datetime
-
 from django.conf import settings
 from django.db import models
 from django.contrib.auth.models import AbstractUser, Group
@@ -95,6 +94,29 @@ class Invitation(models.Model):
         return '%s -> %s' % (self.sender, self.recipient_email)
 
     def send(self, request):
+        if self.recipient and self.group:  # already exists in the system
+            self.send_invitation_to_group(request)
+        elif self.recipient_email:
+            self.send_invitation_to_service(request)
+        else:
+            # shouldn't happen(?)
+            pass
+
+    def send_invitation_to_group(self, request):
+        accept_url = request.build_absolute_uri(
+            reverse("accept-group-invitation", kwargs={"slug": self.token.hex}))
+
+        context = {
+            "sender": self.sender.get_full_name() or self.sender.username,
+            "recipient_first_name": self.recipient.first_name,
+            "recipient_last_name": self.recipient.last_name,
+            "recipient_email": self.recipient.email,
+            "team": self.group.name,
+            "accept_link": accept_url,
+        }
+        self.send_email(self.recipient.email, context)
+
+    def send_invitation_to_service(self, request):
         accept_url = request.build_absolute_uri(
             reverse("accept-invitation", kwargs={"token": self.token.hex}))
         context = {
@@ -105,11 +127,14 @@ class Invitation(models.Model):
             "team": self.group and self.group.name,
             "accept_link": accept_url,
         }
+        self.send_email(self.recipient_email, context)
+
+    def send_email(self, to, context):
         send_email(
             'users/email/invitation_subject.txt',
             'users/email/invitation_message.txt',
             'users/email/invitation_html.html',
-            self.recipient_email,
+            to,
             context=context,
             result_interface=('users', 'Invitation', self.id))
 
@@ -124,6 +149,8 @@ class Invitation(models.Model):
         self.save()
 
     def accept(self, user):
+        if self.recipient and self.recipient != user:
+            return False
         self.recipient = user
         self.workflow_state = self.STATE_ACCEPTED
         self.save()
@@ -133,6 +160,8 @@ class Invitation(models.Model):
                           _('{username} accepted your invitation!').format(
                               username=self.recipient.username),
                           level='success')
+        return True
+
 
 
 class ContactUs(models.Model):
@@ -170,3 +199,16 @@ class ContactUs(models.Model):
         )
 
         super().save(*args, **kwargs)
+
+
+class GroupOwner(models.Model):
+    """
+    Model for the team to share documents with
+    the group owner is the first user
+    """
+    group = models.OneToOneField(Group, on_delete=models.CASCADE)
+    owner = models.ForeignKey(User, null=True, on_delete=models.SET_NULL,
+                              related_name='owned_groups')
+
+    def __str__(self):
+        return str(self.group)
diff --git a/app/apps/users/tests.py b/app/apps/users/tests.py
index 5f4b27f6757d469768902a1818496e86af798d46..ce6f99e686e4e5f7f615500bc15ce34a7421c48d 100644
--- a/app/apps/users/tests.py
+++ b/app/apps/users/tests.py
@@ -4,8 +4,7 @@ from django.contrib.auth.models import Group, Permission
 from django.test import TestCase, override_settings
 from django.urls import reverse
 
-from users.models import Invitation
-from users.models import User as CustomUser, ResearchField
+from users.models import Invitation, User as CustomUser, ResearchField, GroupOwner
 
 
 User = get_user_model()
@@ -156,3 +155,71 @@ class NotificationTestCase(TestCase):
     todo https://channels.readthedocs.io/en/latest/topics/testing.html
     """
     pass
+
+
+class TeamTestCase(TestCase):
+    def setUp(self):
+        self.owner = User.objects.create_user(username="test",
+                                              password="test",
+                                              email="test@test.com")
+
+        self.invitee = User.objects.create_user(username="test2",
+                                                password="test2",
+                                                email="test2@test.com")
+
+        self.group = Group.objects.create(name='testgroup')
+        self.group.user_set.add(self.owner)
+        GroupOwner.objects.create(group=self.group, owner=self.owner)
+
+    def test_accept(self):
+        invitation = Invitation.objects.create(
+            sender=self.owner,
+            recipient=self.invitee,
+            group=self.group)
+
+        self.client.force_login(self.invitee)
+        url = reverse('accept-group-invitation', kwargs={'slug': invitation.token})
+        with self.assertNumQueries(9):
+            response = self.client.get(url)
+        self.assertEqual(response.status_code, 302)
+
+        invitation.refresh_from_db()
+        self.assertEqual(invitation.workflow_state, Invitation.STATE_ACCEPTED)
+
+        self.assertEqual(self.group.user_set.count(), 2)
+
+        self.invitee.refresh_from_db()
+        self.assertEqual(self.invitee.groups.count(), 1)
+
+    def test_remove_from_group(self):
+
+        self.group.user_set.add(self.invitee)
+        self.client.force_login(self.owner)
+        url = reverse('team-remove-user', kwargs={'pk': self.group.pk})
+        with self.assertNumQueries(13):
+            response = self.client.post(url, data={'user': self.invitee.pk})
+        self.assertEqual(response.status_code, 302)
+
+        self.invitee.refresh_from_db()
+        self.assertEqual(self.invitee.groups.count(), 0)
+
+    def test_leave_group(self):
+
+        self.group.user_set.add(self.invitee)
+        self.client.force_login(self.invitee)
+        url = reverse('team-leave', kwargs={'pk': self.group.pk})
+        with self.assertNumQueries(4):
+            response = self.client.post(url)
+        self.assertEqual(response.status_code, 302)
+        self.assertEqual(self.invitee.groups.count(), 0)
+
+    def test_transfer_ownership(self):
+        self.group.user_set.add(self.invitee)
+        self.client.force_login(self.owner)
+        url = reverse('team-transfer-ownership', kwargs={'pk': self.group.pk})
+
+        with self.assertNumQueries(7):
+            response = self.client.post(url, data={'user': self.invitee.pk})
+        self.assertEqual(response.status_code, 302)
+        self.group.groupowner.refresh_from_db()
+        self.assertEqual(self.group.groupowner.owner, self.invitee)
diff --git a/app/apps/users/urls.py b/app/apps/users/urls.py
index a90f1c5f2c87036f0154040ff9694d7ebe5d33a3..e3aa844ec11047910b94461f1c68f1593fadcdae 100644
--- a/app/apps/users/urls.py
+++ b/app/apps/users/urls.py
@@ -1,14 +1,26 @@
 from django.urls import path, include
 
-from users.views import SendInvitation, AcceptInvitation, Profile, ContactUsView
+from users.views import (SendInvitation, AcceptInvitation, AcceptGroupInvitation, ContactUsView,
+                         ProfileInfos, ProfileGroupListCreate, ProfileApiKey, ProfileFiles,
+                         GroupDetail, RemoveFromGroup, LeaveGroup, TransferGroupOwnership)
 from django.contrib.auth.decorators import permission_required
 
 urlpatterns = [
     path('', include('django.contrib.auth.urls')),
-    path('profile/', Profile.as_view(), name='user_profile'),
-    path('contact/', ContactUsView.as_view(), name='contactus'),
+    path('profile/', ProfileInfos.as_view(), name='profile'),
+    path('profile/apikey/', ProfileApiKey.as_view(), name='profile-api-key'),
+    path('profile/files/', ProfileFiles.as_view(), name='profile-files'),
+    path('profile/teams/', ProfileGroupListCreate.as_view(), name='profile-team-list'),
+    path('teams/<int:pk>/', GroupDetail.as_view(), name='team-detail'),
+    path('teams/<int:pk>/remove/', RemoveFromGroup.as_view(), name='team-remove-user'),
+    path('teams/<int:pk>/leave/', LeaveGroup.as_view(), name='team-leave'),
+    path('teams/<int:pk>/transfer-ownership/',
+         TransferGroupOwnership.as_view(),
+         name='team-transfer-ownership'),
     path('invite/',
          permission_required('users.can_invite', raise_exception=True)(SendInvitation.as_view()),
          name='send-invitation'),
     path('accept/<token>/', AcceptInvitation.as_view(), name='accept-invitation'),
+    path('accept/group/<slug>/', AcceptGroupInvitation.as_view(), name='accept-group-invitation'),
+    path('contact/', ContactUsView.as_view(), name='contactus'),
 ]
diff --git a/app/apps/users/views.py b/app/apps/users/views.py
index 03a3a9f57d1da9bfd453abf9a740c5641a036261..9eb56f23eeca70d38f8f6f06788949cbd5eb7442 100644
--- a/app/apps/users/views.py
+++ b/app/apps/users/views.py
@@ -3,17 +3,22 @@ from os.path import isfile, join, relpath
 
 from django.conf import settings
 from django.contrib.auth.mixins import LoginRequiredMixin
+from django.contrib import messages
 from django.contrib.messages.views import SuccessMessageMixin
 from django.core.paginator import Paginator
-from django.http import Http404
+from django.http import Http404, HttpResponseRedirect
 from django.utils.functional import cached_property
 from django.utils.translation import gettext as _
-from django.views.generic.edit import CreateView, UpdateView
+from django.urls import reverse
+from django.views.generic import CreateView, UpdateView, TemplateView, DetailView
+from django.core.exceptions import PermissionDenied
 
 from rest_framework.authtoken.models import Token
-
+from django.contrib.auth.models import Group
 from users.models import User, Invitation, ContactUs
-from users.forms import InvitationForm, InvitationAcceptForm, ProfileForm, ContactUsForm
+from users.forms import (InvitationForm, InvitationAcceptForm, ProfileForm,
+                         ContactUsForm, GroupForm, GroupInvitationForm,
+                         RemoveUserFromGroup, TransferGroupOwnershipForm)
 
 
 class SendInvitation(LoginRequiredMixin, SuccessMessageMixin, CreateView):
@@ -74,7 +79,75 @@ class AcceptInvitation(CreateView):
         return response
 
 
-class Profile(SuccessMessageMixin, UpdateView):
+class AcceptGroupInvitation(DetailView):
+    model = Invitation
+    slug_field = 'token'
+
+    def get_success_message(self):
+        return _("You are now a member of Team {team_name}!").format(
+            team_name=self.object.group.name)
+
+    def get(self, request, *args, **kwargs):
+        self.object = self.get_object()
+        valid = self.object.accept(self.request.user)
+        if not valid:
+            return Http404
+        messages.success(self.request, self.get_success_message())
+        return HttpResponseRedirect(reverse('profile-team-list'))
+
+
+class GroupOwnerMixin():
+    def get_object(self, *args, **kwargs):
+        obj = super().get_object(*args, **kwargs)
+        if obj.groupowner.owner != self.request.user:
+            raise PermissionDenied()  # or Http404
+        return obj
+
+    def get_success_url(self):
+        return reverse('team-detail', kwargs={'pk': self.get_object().pk})
+
+
+class RemoveFromGroup(GroupOwnerMixin, LoginRequiredMixin, SuccessMessageMixin, UpdateView):
+    model = Group
+    form_class = RemoveUserFromGroup
+
+    def get_success_message(self, data):
+        return _('User {user} successfully removed from the team {team_name}.').format(
+            user=data.get('user'),
+            team_name=self.get_object())
+
+    def form_invalid(self, forms):
+        return reverse('team-detail', kwargs={'pk': self.get_object().pk})
+
+
+class LeaveGroup(LoginRequiredMixin, SuccessMessageMixin, DetailView):
+    model = Group
+    success_url = '/profile/teams/'
+
+    def get_success_message(self, data):
+        return _('You successfully left {team}.').format(team=self.object)
+
+    def post(self, request, **kwargs):
+        self.get_object().user_set.remove(request.user)
+        return HttpResponseRedirect(reverse('profile-team-list'))
+
+
+class TransferGroupOwnership(GroupOwnerMixin, LoginRequiredMixin, SuccessMessageMixin, UpdateView):
+    model = Group
+    form_class = TransferGroupOwnershipForm
+
+    def get_success_url(self):
+        return reverse('profile-team-list')
+
+    def get_success_message(self, data):
+        return _('Successfully transfered ownership to {user}.').format(
+            user=data.get('user'))
+
+    def form_invalid(self, forms):
+        return reverse('team-detail', kwargs={'pk': self.get_object().pk})
+
+
+class ProfileInfos(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
     model = User
     form_class = ProfileForm
     success_url = '/profile/'
@@ -84,12 +157,24 @@ class Profile(SuccessMessageMixin, UpdateView):
     def get_object(self):
         return self.request.user
 
-    def get_context_data(self):
-        context = super().get_context_data()
-        context['api_auth_token'], created = Token.objects.get_or_create(user=self.object)
+
+class ProfileApiKey(LoginRequiredMixin, TemplateView):
+    template_name = 'users/profile_api_key.html'
+
+    def get_context_data(self, *args, **kwargs):
+        context = super().get_context_data(*args, **kwargs)
+        context['api_auth_token'], created = Token.objects.get_or_create(user=self.request.user)
+        return context
+
+
+class ProfileFiles(LoginRequiredMixin, TemplateView):
+    template_name = 'users/profile_files.html'
+
+    def get_context_data(self, *args, **kwargs):
+        context = super().get_context_data(*args, **kwargs)
 
         # files directory
-        upath = self.object.get_document_store_path() + '/'
+        upath = self.request.user.get_document_store_path() + '/'
         files = listdir(upath)
         files.sort(key=lambda x: stat(join(upath, x)).st_mtime)
         files.reverse()
@@ -104,12 +189,52 @@ class Profile(SuccessMessageMixin, UpdateView):
         return context
 
 
+class ProfileGroupListCreate(LoginRequiredMixin, SuccessMessageMixin, CreateView):
+    """
+    Both were we create new groups and list them
+    """
+    model = Group
+    success_url = '/profile/teams/'
+    success_message = _('Team successfully created.')
+    template_name = 'users/profile_group_list.html'
+    form_class = GroupForm
+
+    def get_form_kwargs(self):
+        kwargs = super().get_form_kwargs()
+        kwargs['request'] = self.request
+        return kwargs
+
+    def get_context_data(self):
+        context = super().get_context_data()
+        context['invitations'] = Invitation.objects.filter(
+            recipient=self.request.user,
+            workflow_state__lt=Invitation.STATE_ACCEPTED)
+        return context
+
+
+class GroupDetail(GroupOwnerMixin, LoginRequiredMixin, SuccessMessageMixin, UpdateView):
+    model = Group
+    form_class = GroupInvitationForm
+    success_message = _('User successfully invited to the team.')
+    template_name = 'users/group_detail_invite.html'
+
+    def get_form_kwargs(self):
+        # passing instance=None, as the instance would be a group instead of an Invitation
+        kwargs = super().get_form_kwargs()
+        kwargs.update({
+            'instance': None,
+            'request': self.request})
+        return kwargs
+
+    def get_context_data(self, *args,  **kwargs):
+        context = super().get_context_data(*args, **kwargs)
+        context['other_users'] = self.object.user_set.exclude(pk=self.request.user.pk)
+        return context
+
+
 class ContactUsView(SuccessMessageMixin, CreateView):
     model = ContactUs
     form_class = ContactUsForm
-
-    success_message = _('Your message successfully sent.')
-
+    success_message = _('Message successfully sent.')
     template_name = 'users/contactus.html'
-
     success_url = '/contact/'
diff --git a/app/escriptorium/static/css/escriptorium.css b/app/escriptorium/static/css/escriptorium.css
index 2b060198e5fce09ff82f59fcf1d50cf55c9b3ac5..dba4ea87c941e40d16957e47da125da5e2d0fd3e 100644
--- a/app/escriptorium/static/css/escriptorium.css
+++ b/app/escriptorium/static/css/escriptorium.css
@@ -27,6 +27,10 @@ section {
     visibility: hidden;
 }
 
+.no-margin {
+    margin: 0;
+}
+
 .click-through {
     pointer-events: none;
 }
@@ -461,7 +465,7 @@ i.panel-icon {
 }
 
 #modal-img-container {
-    width: 80%;
+    width: 90%;
     position: relative;
     overflow: hidden;
 }
diff --git a/app/escriptorium/static/js/baseline.editor.js b/app/escriptorium/static/js/baseline.editor.js
index 83d93059a43049da25326a10df70410789f0c9c0..a74d0c39a7c020f07f8d3e00897f08ce27d379be 100644
--- a/app/escriptorium/static/js/baseline.editor.js
+++ b/app/escriptorium/static/js/baseline.editor.js
@@ -1455,7 +1455,7 @@ class Segmenter {
             if (this.idField) context[this.idField] = line[this.idField];
             if (!line.baseline) this.toggleMasks(true);
             return this.createLine(null, line.baseline, line.mask,
-                                   region || line.region || null, line.type, context);
+                                   region, line.type, context);
         } else {
             console.log('EDITOR SKIPING invalid line: ', line);
             return null;
diff --git a/app/escriptorium/templates/base.html b/app/escriptorium/templates/base.html
index 4bbf0d95155b88bb5ead3f4df0f514d5f815e54e..9ad9295c1708418597920382eb7d1b70df2cbae0 100644
--- a/app/escriptorium/templates/base.html
+++ b/app/escriptorium/templates/base.html
@@ -75,8 +75,10 @@
               </a>
               <div class="dropdown-menu  dropdown-menu-right" aria-labelledby="navbarDropdown">
               <a class="dropdown-item" href="{% url 'password_change' %}">{% trans "Change password" %}</a>
-              <a class="dropdown-item" href="{% url 'user_profile' %}">{% trans "Profile" %}</a>
+
+              <a class="dropdown-item" href="{% url 'profile' %}">{% trans "Profile" %}</a>
               <a class="dropdown-item" href="{% url 'report-list' %}">{% trans "Task reports" %}</a>
+
               <div class="dropdown-divider"></div>
               {% if perms.users.can_invite %}<a class="dropdown-item" href="{% url 'send-invitation' %}">Invite</a>{% endif %}
               <a class="dropdown-item" href="{% url 'logout' %}">{% trans 'Logout' %}</a>
diff --git a/app/escriptorium/templates/core/document_part_edit.html b/app/escriptorium/templates/core/document_part_edit.html
index f4f3802b92648e2ecd9c92c8e1185ffe159384b3..b40d1d545b5a20d47d73504d13e7d96ef0f25484 100644
--- a/app/escriptorium/templates/core/document_part_edit.html
+++ b/app/escriptorium/templates/core/document_part_edit.html
@@ -141,10 +141,25 @@
             <div class="col panel">
                 <div class="tools">
                     <i title="{% trans 'Source Panel' %}" class="panel-icon fas fa-eye"></i>
+
                     <a v-bind:href="part.image.uri" target="_blank">
                         <button class="btn btn-sm btn-info ml-3 fas fa-download"
                                 title="{% trans "Download full size image." %}" download></button>
                     </a>
+
+                    <div class="btn-group">
+                        <button id="rotate-counter-clock"
+                                @click="rotate(360-90)"
+                                title="{% trans "Rotate 90° counter-clockwise." %}"
+                                class="btn btn-sm btn-info ml-3 fas fa-undo"
+                                autocomplete="off">R</button>
+                        <button id="rotate-clock"
+                                @click="rotate(90)"
+                                title="{% trans "Rotate 90° clockwise." %}"
+                                class="btn btn-sm btn-info  fas fa-redo"
+                                autocomplete="off">R</button>
+                    </div>
+
                 </div>
                 <div class="content-container">
                     <div id="source-zoom-container" class="content">
diff --git a/app/escriptorium/templates/users/email/invitation_subject.txt b/app/escriptorium/templates/users/email/invitation_subject.txt
index 02e7c65314eb44d17d99e4dca8536309081b5d92..7aca19b33a89bb2c9c9ac0d307cb6a3ea025f4f6 100644
--- a/app/escriptorium/templates/users/email/invitation_subject.txt
+++ b/app/escriptorium/templates/users/email/invitation_subject.txt
@@ -1 +1 @@
-{{sender}} invites you to eScriptorium!
+{{sender}} invites you to {% if group %}a Team in {% endif%}eScriptorium!
diff --git a/app/escriptorium/templates/users/group_detail_invite.html b/app/escriptorium/templates/users/group_detail_invite.html
new file mode 100644
index 0000000000000000000000000000000000000000..681059d166a7b88dc3394ff1bdfa8eecaf189294
--- /dev/null
+++ b/app/escriptorium/templates/users/group_detail_invite.html
@@ -0,0 +1,50 @@
+{% extends "base.html" %}
+{% load i18n bootstrap %}
+
+{% block body %}
+<a href="{% url 'profile-team-list' %}">{% trans "Back to profile" %}</a>
+<br><br>
+<h4>{% trans "Team:" %} {{object}}</h4>
+
+<form method="post">
+    {% trans "Invite a user to this Team." %}
+        <div class="form-row">
+            <div class="col">
+                {% csrf_token %}
+                <input type="hidden" name="group" value="{{object.pk}}"/>
+                {% render_field form.recipient_id help_text="Make sure to use a correct email as you will not receive an error message if it is not the case (in order to avoid phishing)." %}
+            </div>
+            <div class="col-auto">
+                <input type="submit" value="{% trans 'Send invitation' %}" title="{% trans "Send invitation" %}" class="btn btn-success btn-block">
+            </div>
+        </div>
+</form>
+
+<h4>{% trans 'Users' %}</h4>
+<table class="table table-hover">
+    {% for user in other_users %}
+    <tr>
+        <td class="col">{{user}}</td>
+        <td class="col-auto">
+            <form method="post" action="{% url 'team-transfer-ownership' object.pk %}">
+                {% csrf_token %}
+                <input type="hidden" name="user" value="{{ user.pk }}">
+                <input type="submit"
+                       class="btn btn-warning btn-small"
+                       value="{% trans 'Transfer ownership' %}"
+                       title="{% trans "Transfer ownership" %}">
+            </form>
+        </td>
+        <td class="col-auto">
+            <form method="post" action="{% url 'team-remove-user' object.pk %}">
+            {% csrf_token %}
+            <input type="hidden" name="user" value="{{ user.pk }}">
+            <input type="submit" value="{% trans 'Remove' %}" class="btn btn-danger btn-small" title="{% trans "Remove user from group" %}">
+            </form>
+        </td>
+    </tr>
+    {% empty %}
+    {% trans "No other user in this Team." %}
+    {% endfor %}
+</table>
+{% endblock %}
diff --git a/app/escriptorium/templates/users/profile.html b/app/escriptorium/templates/users/profile.html
index 3063f8ed66a5c47ee7aedca92b05f4ecec3ffaf4..7d950acd7edff5bab0ef3acf7f8f1589718b72f6 100644
--- a/app/escriptorium/templates/users/profile.html
+++ b/app/escriptorium/templates/users/profile.html
@@ -5,13 +5,15 @@
 <div class="container">
     <div class="row">
         <div class="nav flex-column nav-pills" id="v-pills-tab" role="tablist" aria-orientation="vertical">
-            <a class="nav-link active" id="nav-infos-tab" data-toggle="pill" href="#infos-tab" role="tab">{% trans "Informations" %}</a>
-            <a class="nav-link" id="nav-key-tab" data-toggle="pill" href="#key-tab" role="tab">{% trans "Api key" %}</a>
-            <a class="nav-link" id="nav-files-tab" data-toggle="pill" href="#files-tab" role="tab">{% trans "Files" %}</a>
+            <a class="nav-link {% block infos-tab-active %}active{% endblock %}" id="nav-infos-tab" href="{% url 'profile' %}" role="tab">{% trans "Informations" %}</a>
+            <a class="nav-link {% block key-tab-active %}{% endblock %}" id="nav-key-tab"  href="{% url 'profile-api-key' %}" role="tab">{% trans "Api key" %}</a>
+            <a class="nav-link {% block files-tab-active %}{% endblock %}" id="nav-files-tab" href="{% url 'profile-files' %}" role="tab">{% trans "Files" %}</a>
+            <a class="nav-link {% block team-tab-active %}{% endblock %}" id="nav-infos-tab" href="{% url 'profile-team-list' %}" role="tab">{% trans "Teams" %}</a>
         </div>
 
         <div class="col-md-8 tab-content" id="v-pills-tabContent">
-            <div class="tab-pane fade show active" id="infos-tab" role="tabpanel" aria-labelledby="v-pills-home-tab">
+            <div class="tab-pane fade show active" id="infos-tab" role="tabpanel">
+                {% block tab-content %}
                 <form method="post">
                     {% csrf_token %}
                     <fieldset>
@@ -22,24 +24,9 @@
                         <input type="submit" value="{% trans 'Save' %}" class="btn btn-lg btn-success btn-block">
                     </fieldset>
                 </form>
+                <button id="reset-onboarding" style="" class="btn btn-lg btn-primary btn-block">Reset onboarding</button>
 
-               <button id="reset-onboarding" style="" class="btn btn-lg btn-primary btn-block">Reset onboarding</button>
-            </div>
-
-            <div class="tab-pane fade show" id="key-tab" role="tabpanel" aria-labelledby="v-pills-home-tab">
-                {% trans "API Authentication Token:" %} {{ api_auth_token.key }}
-                <button class="btn btn-secondary btn-sm fas fa-clipboard float-right" id="api-key-clipboard" title="{% trans "Copy to clipboard." %}" data-key="{{ api_auth_token.key }}"></button>
-                <br/><span class="text-muted"><small>{% trans "example: "%} $ curl {{request.scheme}}://{{request.get_host}}/api/documents/ -H 'Authorization: Token {{ api_auth_token.key }}'</small></span>
-            </div>
-
-            <div class="tab-pane fade show" id="files-tab" role="tabpanel" aria-labelledby="v-pills-home-tab">
-                {% for fpath, fname in page_obj %}
-                <br><a href="{{request.scheme}}://{{request.get_host}}{% get_media_prefix %}{{fpath}}"><i class="fas fa-download"></i></a> {{ fname }}
-                {% empty %}
-                {% trans "You don't have any saved files yet." %}
-                {% endfor %}
-
-                {% include "includes/pagination.html" %}
+                {% endblock %}
             </div>
         </div>
     </div>
@@ -54,28 +41,7 @@
          navigator.clipboard.writeText($(this).data('key'))
      });
 
-     function updatePagination(hash) {
-         $('ul.pagination a').each(function(i, a) {
-             let href = $(a).attr("href").split('#')[0];
-             $(a).attr("href", href+location.hash);
-         });
-     }
-
-     if (location.hash) {
-         let tab = location.hash.split('#')[1];
-         $('#nav-' + tab).tab("show");
-         updatePagination(location.hash);
-     }
-     $('a[data-toggle="pill"]').on("click", function() {
-         let url = location.href;
-         let hash = $(this).attr("href");
-         let newUrl = url.split("#")[0] + hash;
-         history.replaceState(null, null, newUrl);
-         updatePagination(hash);
-     });
-
      $('#reset-onboarding').on('click',function () {
-
          userProfile.set('onboarding_document_form', false);
          userProfile.set('onboarding_images', false);
          userProfile.set('onboarding_edit', false);
diff --git a/app/escriptorium/templates/users/profile_api_key.html b/app/escriptorium/templates/users/profile_api_key.html
new file mode 100644
index 0000000000000000000000000000000000000000..48e1fe1d5cbb66397545879cc9a0fa2acce3d709
--- /dev/null
+++ b/app/escriptorium/templates/users/profile_api_key.html
@@ -0,0 +1,13 @@
+{% extends "users/profile.html" %}
+{% load i18n %}
+
+{% block infos-tab-active %}{% endblock %}
+{% block key-tab-active %}active{% endblock %}
+{% block files-tab-active %}{% endblock %}
+{% block team-tab-active %}{% endblock %}
+
+{% block tab-content %}
+{% trans "API Authentication Token:" %} {{ api_auth_token.key }}
+<button class="btn btn-secondary btn-sm fas fa-clipboard float-right" id="api-key-clipboard" title="{% trans "Copy to clipboard." %}" data-key="{{ api_auth_token.key }}"></button>
+<br/><span class="text-muted"><small>{% trans "example: "%} $ curl {{request.scheme}}://{{request.get_host}}/api/documents/ -H 'Authorization: Token {{ api_auth_token.key }}'</small></span>
+{% endblock %}
diff --git a/app/escriptorium/templates/users/profile_files.html b/app/escriptorium/templates/users/profile_files.html
new file mode 100644
index 0000000000000000000000000000000000000000..9849d42183ac7c6a189a0f0ff225190301ccb432
--- /dev/null
+++ b/app/escriptorium/templates/users/profile_files.html
@@ -0,0 +1,17 @@
+{% extends "users/profile.html" %}
+{% load i18n static %}
+
+{% block infos-tab-active %}{% endblock %}
+{% block key-tab-active %}{% endblock %}
+{% block files-tab-active %}active{% endblock %}
+{% block team-tab-active %}{% endblock %}
+
+{% block tab-content %}
+{% for fpath, fname in page_obj %}
+<br><a href="{{request.scheme}}://{{request.get_host}}{% get_media_prefix %}{{fpath}}"><i class="fas fa-download"></i></a> {{ fname }}
+{% empty %}
+{% trans "You don't have any saved files yet." %}
+{% endfor %}
+
+{% include "includes/pagination.html" %}
+{% endblock %}
diff --git a/app/escriptorium/templates/users/profile_group_list.html b/app/escriptorium/templates/users/profile_group_list.html
new file mode 100644
index 0000000000000000000000000000000000000000..3a7b855f17bd40eab0a437e2a3a1458c85a5ff31
--- /dev/null
+++ b/app/escriptorium/templates/users/profile_group_list.html
@@ -0,0 +1,63 @@
+{% extends "users/profile.html" %}
+{% load i18n bootstrap static %}
+
+{% block infos-tab-active %}{% endblock %}
+{% block key-tab-active %}{% endblock %}
+{% block files-tab-active %}{% endblock %}
+{% block team-tab-active %}active{% endblock %}
+
+{% block tab-content %}
+
+<h4>{% trans "Create a new Team" %}</h4>
+<form method="post">
+    {% csrf_token %}
+    <div class="form-row">
+        <div class="col">{% render_field form.name group=True %}</div>
+        <div class="col-auto"><input type="submit" value="{% trans 'Create' %}" class="btn btn-success btn-block col-auto"></div>
+    </div>
+
+</form>
+<br>
+
+{% if invitations %}
+<h4>{% trans "Pending Invitations"  %}</h4>
+<table class="table table-hover">
+    {% for invitation in invitations %}
+    <tr>
+        <td class="col">
+            {% blocktrans with team_name=invitation.group.name sender=invitation.sender.username %}
+            Invited to {{team_name}} by {{sender}}
+            {% endblocktrans %}
+        </td>
+        <td class="col-auto">
+            <a href="{% url 'accept-group-invitation' invitation.token %}">{% trans "Accept" %}</a>
+        </td>
+    </tr>
+    {% endfor %}
+</table>
+<br>
+{% endif %}
+
+<h4>{% trans "My teams" %}</h4>
+<table class="table table-hover container">
+    {% for team in user.groups.all %}
+    <tr class="row no-margin">
+        {% if team.groupowner.owner == user %}
+        <td class="col"><a href="{% url 'team-detail' team.pk %}">{{ team }} (owner)</a></td>
+        <td></td
+        <td></td>
+        {% else %}
+        <td class="col">{{ team }}</td>
+        <td class="col-auto">{% blocktrans with owner=team.groupowner.owner.username %}Owned by {{owner}}{% endblocktrans %}</td>
+        <td class="col-auto">
+            <form method="post" action="{% url 'team-leave' team.pk %}">
+                {% csrf_token %}
+                <input type="submit" class="btn btn-danger btn-sm" value="{% trans "Leave" %}" />
+            </form></td>
+        {% endif  %}
+    </tr>
+    {% empty %}
+    {% trans "You are not part of any team yet." %}
+    {% endfor %}
+</table>
+{% endblock %}
diff --git a/app/requirements.txt b/app/requirements.txt
index 0c77e4a9742166bf645b8aa8e644a482e7557828..8cc8364724daac8118b784f06706f7cad2b55d4e 100644
--- a/app/requirements.txt
+++ b/app/requirements.txt
@@ -2,18 +2,18 @@
 git+https://github.com/celery/kombu.git@4.6.6#egg=kombu
 git+https://github.com/celery/celery.git@4.4.0rc4#egg=celery
 Pillow>=5.4.1
-Django==2.1.4
+Django>=2.2,<3
 redis==3.2.1
 uwsgi==2.0.17
-daphne==2.2.0  # dependency conflict, remove once https://github.com/django/channels/pull/1278 is merged
-channels==2.1.7
-channels-redis==2.3.1
+daphne==2.5.0
+channels==2.4.0
+channels-redis==3.2.0
 django-redis==4.10.0
 psycopg2-binary==2.7.6
 django-ordered-model==3.1.1
 easy-thumbnails==2.5
 git+https://github.com/mittagessen/kraken.git@3.0b17#egg=kraken
-django-cleanup==3.0.1
+django-cleanup==5.1.0
 djangorestframework==3.9.2
 drf-nested-routers==0.91
 bleach==3.1.5