diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index a836dfdfddb096384bfff92855a34cf478f0aaa6..b74c2bdfe499af75053748ddb8d90b96b8b42760 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -461,3 +461,7 @@ MUSIC_DIRECTORY_PATH = env("MUSIC_DIRECTORY_PATH", default=None)
 MUSIC_DIRECTORY_SERVE_PATH = env(
     "MUSIC_DIRECTORY_SERVE_PATH", default=MUSIC_DIRECTORY_PATH
 )
+
+USERS_INVITATION_EXPIRATION_DAYS = env.int(
+    "USERS_INVITATION_EXPIRATION_DAYS", default=14
+)
diff --git a/api/funkwhale_api/common/serializers.py b/api/funkwhale_api/common/serializers.py
index 029338ef992c6a57e3d88f096d4c7619df39d63b..b995afcaa0e28cf32f1746a198a46e05d0e5e466 100644
--- a/api/funkwhale_api/common/serializers.py
+++ b/api/funkwhale_api/common/serializers.py
@@ -1,6 +1,16 @@
 from rest_framework import serializers
 
 
+class Action(object):
+    def __init__(self, name, allow_all=False, filters=None):
+        self.name = name
+        self.allow_all = allow_all
+        self.filters = filters or {}
+
+    def __repr__(self):
+        return "<Action {}>".format(self.name)
+
+
 class ActionSerializer(serializers.Serializer):
     """
     A special serializer that can operate on a list of objects
@@ -11,19 +21,16 @@ class ActionSerializer(serializers.Serializer):
     objects = serializers.JSONField(required=True)
     filters = serializers.DictField(required=False)
     actions = None
-    filterset_class = None
-    # those are actions identifier where we don't want to allow the "all"
-    # selector because it's to dangerous. Like object deletion.
-    dangerous_actions = []
 
     def __init__(self, *args, **kwargs):
+        self.actions_by_name = {a.name: a for a in self.actions}
         self.queryset = kwargs.pop("queryset")
         if self.actions is None:
             raise ValueError(
                 "You must declare a list of actions on " "the serializer class"
             )
 
-        for action in self.actions:
+        for action in self.actions_by_name.keys():
             handler_name = "handle_{}".format(action)
             assert hasattr(self, handler_name), "{} miss a {} method".format(
                 self.__class__.__name__, handler_name
@@ -31,13 +38,14 @@ class ActionSerializer(serializers.Serializer):
         super().__init__(self, *args, **kwargs)
 
     def validate_action(self, value):
-        if value not in self.actions:
+        try:
+            return self.actions_by_name[value]
+        except KeyError:
             raise serializers.ValidationError(
                 "{} is not a valid action. Pick one of {}.".format(
-                    value, ", ".join(self.actions)
+                    value, ", ".join(self.actions_by_name.keys())
                 )
             )
-        return value
 
     def validate_objects(self, value):
         if value == "all":
@@ -51,15 +59,15 @@ class ActionSerializer(serializers.Serializer):
         )
 
     def validate(self, data):
-        dangerous = data["action"] in self.dangerous_actions
-        if dangerous and self.initial_data["objects"] == "all":
+        allow_all = data["action"].allow_all
+        if not allow_all and self.initial_data["objects"] == "all":
             raise serializers.ValidationError(
-                "This action is to dangerous to be applied to all objects"
-            )
-        if self.filterset_class and "filters" in data:
-            qs_filterset = self.filterset_class(
-                data["filters"], queryset=data["objects"]
+                "You cannot apply this action on all objects"
             )
+        final_filters = data.get("filters", {}) or {}
+        final_filters.update(data["action"].filters)
+        if self.filterset_class and final_filters:
+            qs_filterset = self.filterset_class(final_filters, queryset=data["objects"])
             try:
                 assert qs_filterset.form.is_valid()
             except (AssertionError, TypeError):
@@ -72,12 +80,12 @@ class ActionSerializer(serializers.Serializer):
         return data
 
     def save(self):
-        handler_name = "handle_{}".format(self.validated_data["action"])
+        handler_name = "handle_{}".format(self.validated_data["action"].name)
         handler = getattr(self, handler_name)
         result = handler(self.validated_data["objects"])
         payload = {
             "updated": self.validated_data["count"],
-            "action": self.validated_data["action"],
+            "action": self.validated_data["action"].name,
             "result": result,
         }
         return payload
diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py
index 062f74f476da3b818477306683a9f284a4e126bc..44de5d3129536e0c18a5617007a99694b1aa2864 100644
--- a/api/funkwhale_api/federation/serializers.py
+++ b/api/funkwhale_api/federation/serializers.py
@@ -769,7 +769,7 @@ class CollectionSerializer(serializers.Serializer):
 
 
 class LibraryTrackActionSerializer(common_serializers.ActionSerializer):
-    actions = ["import"]
+    actions = [common_serializers.Action("import", allow_all=True)]
     filterset_class = filters.LibraryTrackFilter
 
     @transaction.atomic
diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py
index e4cda18c5b9de693a83adf12c4335d45249c0052..5f83ebf1a3143e201891448e2f43206c14015d7c 100644
--- a/api/funkwhale_api/manage/filters.py
+++ b/api/funkwhale_api/manage/filters.py
@@ -1,4 +1,3 @@
-
 from django_filters import rest_framework as filters
 
 from funkwhale_api.common import fields
@@ -37,3 +36,17 @@ class ManageUserFilterSet(filters.FilterSet):
             "permission_settings",
             "permission_federation",
         ]
+
+
+class ManageInvitationFilterSet(filters.FilterSet):
+    q = fields.SearchFilter(search_fields=["owner__username", "code", "owner__email"])
+    is_open = filters.BooleanFilter(method="filter_is_open")
+
+    class Meta:
+        model = users_models.Invitation
+        fields = ["q", "is_open"]
+
+    def filter_is_open(self, queryset, field_name, value):
+        if value is None:
+            return queryset
+        return queryset.open(value)
diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py
index 6e57db81f91a431baab736f24dda6b1a4c2e9f2e..c639d3a3c2ad26301c203df9c2f393df56f8e926 100644
--- a/api/funkwhale_api/manage/serializers.py
+++ b/api/funkwhale_api/manage/serializers.py
@@ -61,8 +61,7 @@ class ManageTrackFileSerializer(serializers.ModelSerializer):
 
 
 class ManageTrackFileActionSerializer(common_serializers.ActionSerializer):
-    actions = ["delete"]
-    dangerous_actions = ["delete"]
+    actions = [common_serializers.Action("delete", allow_all=False)]
     filterset_class = filters.ManageTrackFileFilterSet
 
     @transaction.atomic
@@ -78,6 +77,23 @@ class PermissionsSerializer(serializers.Serializer):
         return {"permissions": o}
 
 
+class ManageUserSimpleSerializer(serializers.ModelSerializer):
+    class Meta:
+        model = users_models.User
+        fields = (
+            "id",
+            "username",
+            "email",
+            "name",
+            "is_active",
+            "is_staff",
+            "is_superuser",
+            "date_joined",
+            "last_activity",
+            "privacy_level",
+        )
+
+
 class ManageUserSerializer(serializers.ModelSerializer):
     permissions = PermissionsSerializer(source="*")
 
@@ -115,3 +131,32 @@ class ManageUserSerializer(serializers.ModelSerializer):
                 update_fields=["permission_{}".format(p) for p in permissions.keys()]
             )
         return instance
+
+
+class ManageInvitationSerializer(serializers.ModelSerializer):
+    users = ManageUserSimpleSerializer(many=True, required=False)
+    owner = ManageUserSimpleSerializer(required=False)
+    code = serializers.CharField(required=False, allow_null=True)
+
+    class Meta:
+        model = users_models.Invitation
+        fields = ("id", "owner", "code", "expiration_date", "creation_date", "users")
+        read_only_fields = ["id", "expiration_date", "owner", "creation_date", "users"]
+
+    def validate_code(self, value):
+        if not value:
+            return value
+        if users_models.Invitation.objects.filter(code__iexact=value).exists():
+            raise serializers.ValidationError(
+                "An invitation with this code already exists"
+            )
+        return value
+
+
+class ManageInvitationActionSerializer(common_serializers.ActionSerializer):
+    actions = [common_serializers.Action("delete", allow_all=False)]
+    filterset_class = filters.ManageInvitationFilterSet
+
+    @transaction.atomic
+    def handle_delete(self, objects):
+        return objects.delete()
diff --git a/api/funkwhale_api/manage/urls.py b/api/funkwhale_api/manage/urls.py
index f208fb857079db390c6d4d4bb63e14107b2d6c11..3d4e15db9327855ff4df5f983fe8a70dba26d452 100644
--- a/api/funkwhale_api/manage/urls.py
+++ b/api/funkwhale_api/manage/urls.py
@@ -7,6 +7,7 @@ library_router = routers.SimpleRouter()
 library_router.register(r"track-files", views.ManageTrackFileViewSet, "track-files")
 users_router = routers.SimpleRouter()
 users_router.register(r"users", views.ManageUserViewSet, "users")
+users_router.register(r"invitations", views.ManageInvitationViewSet, "invitations")
 
 urlpatterns = [
     url(r"^library/", include((library_router.urls, "instance"), namespace="library")),
diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py
index f9b78ef87a076a95afa43dc8c57fe9d6ededffc1..ae3c08a57c829dbc8330c37568a3043fa5f8484e 100644
--- a/api/funkwhale_api/manage/views.py
+++ b/api/funkwhale_api/manage/views.py
@@ -62,3 +62,37 @@ class ManageUserViewSet(
         context = super().get_serializer_context()
         context["default_permissions"] = preferences.get("users__default_permissions")
         return context
+
+
+class ManageInvitationViewSet(
+    mixins.CreateModelMixin,
+    mixins.ListModelMixin,
+    mixins.RetrieveModelMixin,
+    mixins.UpdateModelMixin,
+    mixins.DestroyModelMixin,
+    viewsets.GenericViewSet,
+):
+    queryset = (
+        users_models.Invitation.objects.all()
+        .order_by("-id")
+        .prefetch_related("users")
+        .select_related("owner")
+    )
+    serializer_class = serializers.ManageInvitationSerializer
+    filter_class = filters.ManageInvitationFilterSet
+    permission_classes = (HasUserPermission,)
+    required_permissions = ["settings"]
+    ordering_fields = ["creation_date", "expiration_date"]
+
+    def perform_create(self, serializer):
+        serializer.save(owner=self.request.user)
+
+    @list_route(methods=["post"])
+    def action(self, request, *args, **kwargs):
+        queryset = self.get_queryset()
+        serializer = serializers.ManageInvitationActionSerializer(
+            request.data, queryset=queryset
+        )
+        serializer.is_valid(raise_exception=True)
+        result = serializer.save()
+        return response.Response(result, status=200)
diff --git a/api/funkwhale_api/users/admin.py b/api/funkwhale_api/users/admin.py
index 5c694ab0ee962c2977dd227c709861dfef93098f..205c7c36703ebfa153dab7b2a393f4432152e162 100644
--- a/api/funkwhale_api/users/admin.py
+++ b/api/funkwhale_api/users/admin.py
@@ -7,12 +7,12 @@ from django.contrib.auth.admin import UserAdmin as AuthUserAdmin
 from django.contrib.auth.forms import UserChangeForm, UserCreationForm
 from django.utils.translation import ugettext_lazy as _
 
-from .models import User
+from . import models
 
 
 class MyUserChangeForm(UserChangeForm):
     class Meta(UserChangeForm.Meta):
-        model = User
+        model = models.User
 
 
 class MyUserCreationForm(UserCreationForm):
@@ -22,18 +22,18 @@ class MyUserCreationForm(UserCreationForm):
     )
 
     class Meta(UserCreationForm.Meta):
-        model = User
+        model = models.User
 
     def clean_username(self):
         username = self.cleaned_data["username"]
         try:
-            User.objects.get(username=username)
-        except User.DoesNotExist:
+            models.User.objects.get(username=username)
+        except models.User.DoesNotExist:
             return username
         raise forms.ValidationError(self.error_messages["duplicate_username"])
 
 
-@admin.register(User)
+@admin.register(models.User)
 class UserAdmin(AuthUserAdmin):
     form = MyUserChangeForm
     add_form = MyUserCreationForm
@@ -74,3 +74,11 @@ class UserAdmin(AuthUserAdmin):
         (_("Important dates"), {"fields": ("last_login", "date_joined")}),
         (_("Useless fields"), {"fields": ("user_permissions", "groups")}),
     )
+
+
+@admin.register(models.Invitation)
+class InvitationAdmin(admin.ModelAdmin):
+    list_select_related = True
+    list_display = ["owner", "code", "creation_date", "expiration_date"]
+    search_fields = ["owner__username", "code"]
+    readonly_fields = ["expiration_date", "code"]
diff --git a/api/funkwhale_api/users/factories.py b/api/funkwhale_api/users/factories.py
index eed8c7175a2dacdb65404aaf827c2bee04582dbb..5fceb57bbc17bdd8ac70e95bc81e672869b4abe8 100644
--- a/api/funkwhale_api/users/factories.py
+++ b/api/funkwhale_api/users/factories.py
@@ -1,5 +1,6 @@
 import factory
 from django.contrib.auth.models import Permission
+from django.utils import timezone
 
 from funkwhale_api.factories import ManyToManyFromList, registry
 
@@ -28,6 +29,17 @@ class GroupFactory(factory.django.DjangoModelFactory):
             self.permissions.add(*perms)
 
 
+@registry.register
+class InvitationFactory(factory.django.DjangoModelFactory):
+    owner = factory.LazyFunction(lambda: UserFactory())
+
+    class Meta:
+        model = "users.Invitation"
+
+    class Params:
+        expired = factory.Trait(expiration_date=factory.LazyFunction(timezone.now))
+
+
 @registry.register
 class UserFactory(factory.django.DjangoModelFactory):
     username = factory.Sequence(lambda n: "user-{0}".format(n))
@@ -40,6 +52,9 @@ class UserFactory(factory.django.DjangoModelFactory):
         model = "users.User"
         django_get_or_create = ("username",)
 
+    class Params:
+        invited = factory.Trait(invitation=factory.SubFactory(InvitationFactory))
+
     @factory.post_generation
     def perms(self, create, extracted, **kwargs):
         if not create:
diff --git a/api/funkwhale_api/users/migrations/0009_auto_20180619_2024.py b/api/funkwhale_api/users/migrations/0009_auto_20180619_2024.py
new file mode 100644
index 0000000000000000000000000000000000000000..e8204c4e4748371b7906d9115b0d529f30a35db7
--- /dev/null
+++ b/api/funkwhale_api/users/migrations/0009_auto_20180619_2024.py
@@ -0,0 +1,31 @@
+# Generated by Django 2.0.6 on 2018-06-19 20:24
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('users', '0008_auto_20180617_1531'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Invitation',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
+                ('expiration_date', models.DateTimeField()),
+                ('code', models.CharField(max_length=50, unique=True)),
+                ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)),
+            ],
+        ),
+        migrations.AddField(
+            model_name='user',
+            name='invitation',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users', to='users.Invitation'),
+        ),
+    ]
diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py
index 15d16db2369967cddf38310d871c6bae3733dab0..ec9c39fd69a47d08f08f2a59ba20f480e8cfdb9c 100644
--- a/api/funkwhale_api/users/models.py
+++ b/api/funkwhale_api/users/models.py
@@ -4,6 +4,8 @@ from __future__ import absolute_import, unicode_literals
 import binascii
 import datetime
 import os
+import random
+import string
 import uuid
 
 from django.conf import settings
@@ -79,6 +81,14 @@ class User(AbstractUser):
 
     last_activity = models.DateTimeField(default=None, null=True, blank=True)
 
+    invitation = models.ForeignKey(
+        "Invitation",
+        related_name="users",
+        null=True,
+        blank=True,
+        on_delete=models.SET_NULL,
+    )
+
     def __str__(self):
         return self.username
 
@@ -138,3 +148,40 @@ class User(AbstractUser):
         if current is None or current < now - datetime.timedelta(seconds=delay):
             self.last_activity = now
             self.save(update_fields=["last_activity"])
+
+
+def generate_code(length=10):
+    return "".join(
+        random.SystemRandom().choice(string.ascii_uppercase) for _ in range(length)
+    )
+
+
+class InvitationQuerySet(models.QuerySet):
+    def open(self, include=True):
+        now = timezone.now()
+        qs = self.annotate(_users=models.Count("users"))
+        query = models.Q(_users=0, expiration_date__gt=now)
+        if include:
+            return qs.filter(query)
+        return qs.exclude(query)
+
+
+class Invitation(models.Model):
+    creation_date = models.DateTimeField(default=timezone.now)
+    expiration_date = models.DateTimeField()
+    owner = models.ForeignKey(
+        User, related_name="invitations", on_delete=models.CASCADE
+    )
+    code = models.CharField(max_length=50, unique=True)
+
+    objects = InvitationQuerySet.as_manager()
+
+    def save(self, **kwargs):
+        if not self.code:
+            self.code = generate_code()
+        if not self.expiration_date:
+            self.expiration_date = self.creation_date + datetime.timedelta(
+                days=settings.USERS_INVITATION_EXPIRATION_DAYS
+            )
+
+        return super().save(**kwargs)
diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py
index b3bd431c722fc5f8e4751270a9b1690973119de2..4389512650327a2da66fcd35fee362106814d43a 100644
--- a/api/funkwhale_api/users/serializers.py
+++ b/api/funkwhale_api/users/serializers.py
@@ -1,5 +1,6 @@
 from django.conf import settings
 from rest_auth.serializers import PasswordResetSerializer as PRS
+from rest_auth.registration.serializers import RegisterSerializer as RS
 from rest_framework import serializers
 
 from funkwhale_api.activity import serializers as activity_serializers
@@ -7,6 +8,28 @@ from funkwhale_api.activity import serializers as activity_serializers
 from . import models
 
 
+class RegisterSerializer(RS):
+    invitation = serializers.CharField(
+        required=False, allow_null=True, allow_blank=True
+    )
+
+    def validate_invitation(self, value):
+        if not value:
+            return
+
+        try:
+            return models.Invitation.objects.open().get(code__iexact=value)
+        except models.Invitation.DoesNotExist:
+            raise serializers.ValidationError("Invalid invitation code")
+
+    def save(self, request):
+        user = super().save(request)
+        if self.validated_data.get("invitation"):
+            user.invitation = self.validated_data.get("invitation")
+            user.save(update_fields=["invitation"])
+        return user
+
+
 class UserActivitySerializer(activity_serializers.ModelSerializer):
     type = serializers.SerializerMethodField()
     name = serializers.CharField(source="username")
diff --git a/api/funkwhale_api/users/views.py b/api/funkwhale_api/users/views.py
index 69e69d26e6b987426ef59a450d07e3d6458f6ab2..20d63d788f349643b9065038102cabfd60a49ce0 100644
--- a/api/funkwhale_api/users/views.py
+++ b/api/funkwhale_api/users/views.py
@@ -10,8 +10,11 @@ from . import models, serializers
 
 
 class RegisterView(BaseRegisterView):
+    serializer_class = serializers.RegisterSerializer
+
     def create(self, request, *args, **kwargs):
-        if not self.is_open_for_signup(request):
+        invitation_code = request.data.get("invitation")
+        if not invitation_code and not self.is_open_for_signup(request):
             r = {"detail": "Registration has been disabled"}
             return Response(r, status=403)
         return super().create(request, *args, **kwargs)
diff --git a/api/tests/common/test_serializers.py b/api/tests/common/test_serializers.py
index ca5e5ad8f6fd2317af880eef0aec3f28b1184b04..dbbd38a0dc442bc04358eef8e14ff4e2a89310a9 100644
--- a/api/tests/common/test_serializers.py
+++ b/api/tests/common/test_serializers.py
@@ -11,7 +11,7 @@ class TestActionFilterSet(django_filters.FilterSet):
 
 
 class TestSerializer(serializers.ActionSerializer):
-    actions = ["test"]
+    actions = [serializers.Action("test", allow_all=True)]
     filterset_class = TestActionFilterSet
 
     def handle_test(self, objects):
@@ -19,8 +19,10 @@ class TestSerializer(serializers.ActionSerializer):
 
 
 class TestDangerousSerializer(serializers.ActionSerializer):
-    actions = ["test", "test_dangerous"]
-    dangerous_actions = ["test_dangerous"]
+    actions = [
+        serializers.Action("test", allow_all=True),
+        serializers.Action("test_dangerous"),
+    ]
 
     def handle_test(self, objects):
         pass
@@ -29,6 +31,14 @@ class TestDangerousSerializer(serializers.ActionSerializer):
         pass
 
 
+class TestDeleteOnlyInactiveSerializer(serializers.ActionSerializer):
+    actions = [serializers.Action("test", allow_all=True, filters={"is_active": False})]
+    filterset_class = TestActionFilterSet
+
+    def handle_test(self, objects):
+        pass
+
+
 def test_action_serializer_validates_action():
     data = {"objects": "all", "action": "nope"}
     serializer = TestSerializer(data, queryset=models.User.objects.none())
@@ -52,7 +62,7 @@ def test_action_serializers_objects_clean_ids(factories):
     data = {"objects": [user1.pk], "action": "test"}
     serializer = TestSerializer(data, queryset=models.User.objects.all())
 
-    assert serializer.is_valid() is True
+    assert serializer.is_valid(raise_exception=True) is True
     assert list(serializer.validated_data["objects"]) == [user1]
 
 
@@ -63,7 +73,7 @@ def test_action_serializers_objects_clean_all(factories):
     data = {"objects": "all", "action": "test"}
     serializer = TestSerializer(data, queryset=models.User.objects.all())
 
-    assert serializer.is_valid() is True
+    assert serializer.is_valid(raise_exception=True) is True
     assert list(serializer.validated_data["objects"]) == [user1, user2]
 
 
@@ -75,7 +85,7 @@ def test_action_serializers_save(factories, mocker):
     data = {"objects": "all", "action": "test"}
     serializer = TestSerializer(data, queryset=models.User.objects.all())
 
-    assert serializer.is_valid() is True
+    assert serializer.is_valid(raise_exception=True) is True
     result = serializer.save()
     assert result == {"updated": 2, "action": "test", "result": {"hello": "world"}}
     handler.assert_called_once()
@@ -88,7 +98,7 @@ def test_action_serializers_filterset(factories):
     data = {"objects": "all", "action": "test", "filters": {"is_active": True}}
     serializer = TestSerializer(data, queryset=models.User.objects.all())
 
-    assert serializer.is_valid() is True
+    assert serializer.is_valid(raise_exception=True) is True
     assert list(serializer.validated_data["objects"]) == [user2]
 
 
@@ -109,9 +119,14 @@ def test_dangerous_actions_refuses_all(factories):
     assert "non_field_errors" in serializer.errors
 
 
-def test_dangerous_actions_refuses_not_listed(factories):
-    factories["users.User"]()
+def test_action_serializers_can_require_filter(factories):
+    user1 = factories["users.User"](is_active=False)
+    factories["users.User"](is_active=True)
+
     data = {"objects": "all", "action": "test"}
-    serializer = TestDangerousSerializer(data, queryset=models.User.objects.all())
+    serializer = TestDeleteOnlyInactiveSerializer(
+        data, queryset=models.User.objects.all()
+    )
 
-    assert serializer.is_valid() is True
+    assert serializer.is_valid(raise_exception=True) is True
+    assert list(serializer.validated_data["objects"]) == [user1]
diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py
index a72bcf5af4fa77b92a25ab5dd099684bb196f13c..d54fca5ddafe3b570bf0d671846587b8132c4f77 100644
--- a/api/tests/manage/test_views.py
+++ b/api/tests/manage/test_views.py
@@ -9,6 +9,7 @@ from funkwhale_api.manage import serializers, views
     [
         (views.ManageTrackFileViewSet, ["library"], "and"),
         (views.ManageUserViewSet, ["settings"], "and"),
+        (views.ManageInvitationViewSet, ["settings"], "and"),
     ],
 )
 def test_permissions(assert_user_permission, view, permissions, operator):
@@ -42,3 +43,23 @@ def test_user_view(factories, superuser_api_client, mocker):
 
     assert response.data["count"] == len(users)
     assert response.data["results"] == expected
+
+
+def test_invitation_view(factories, superuser_api_client, mocker):
+    invitations = factories["users.Invitation"].create_batch(size=5)
+    qs = invitations[0].__class__.objects.order_by("-id")
+    url = reverse("api:v1:manage:users:invitations-list")
+
+    response = superuser_api_client.get(url, {"sort": "-id"})
+    expected = serializers.ManageInvitationSerializer(qs, many=True).data
+
+    assert response.data["count"] == len(invitations)
+    assert response.data["results"] == expected
+
+
+def test_invitation_view_create(factories, superuser_api_client, mocker):
+    url = reverse("api:v1:manage:users:invitations-list")
+    response = superuser_api_client.post(url)
+
+    assert response.status_code == 201
+    assert superuser_api_client.user.invitations.latest("id") is not None
diff --git a/api/tests/users/test_models.py b/api/tests/users/test_models.py
index 74bb091e54270c4034050b7810e2b6ba3cb4bde3..ea760cc6c6b5a49f39903f1d641bd3d664819348 100644
--- a/api/tests/users/test_models.py
+++ b/api/tests/users/test_models.py
@@ -1,3 +1,4 @@
+import datetime
 import pytest
 
 from funkwhale_api.users import models
@@ -95,3 +96,34 @@ def test_record_activity_does_nothing_if_already(factories, now, mocker):
     user.record_activity()
 
     save.assert_not_called()
+
+
+def test_invitation_generates_random_code_on_save(factories):
+    invitation = factories["users.Invitation"]()
+    assert len(invitation.code) >= 6
+
+
+def test_invitation_expires_after_delay(factories, settings):
+    delay = settings.USERS_INVITATION_EXPIRATION_DAYS
+    invitation = factories["users.Invitation"]()
+    assert invitation.expiration_date == (
+        invitation.creation_date + datetime.timedelta(days=delay)
+    )
+
+
+def test_can_filter_open_invitations(factories):
+    okay = factories["users.Invitation"]()
+    factories["users.Invitation"](expired=True)
+    factories["users.User"](invited=True)
+
+    assert models.Invitation.objects.count() == 3
+    assert list(models.Invitation.objects.open()) == [okay]
+
+
+def test_can_filter_closed_invitations(factories):
+    factories["users.Invitation"]()
+    expired = factories["users.Invitation"](expired=True)
+    used = factories["users.User"](invited=True).invitation
+
+    assert models.Invitation.objects.count() == 3
+    assert list(models.Invitation.objects.open(False)) == [expired, used]
diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py
index 00272c2aea76026c3df2b125c39fdee852acf1ec..fca66d302efc499cb7ef7cf0c2d8124ec7b5668b 100644
--- a/api/tests/users/test_views.py
+++ b/api/tests/users/test_views.py
@@ -50,6 +50,39 @@ def test_can_disable_registration_view(preferences, api_client, db):
     assert response.status_code == 403
 
 
+def test_can_signup_with_invitation(preferences, factories, api_client):
+    url = reverse("rest_register")
+    invitation = factories["users.Invitation"](code="Hello")
+    data = {
+        "username": "test1",
+        "email": "test1@test.com",
+        "password1": "testtest",
+        "password2": "testtest",
+        "invitation": "hello",
+    }
+    preferences["users__registration_enabled"] = False
+    response = api_client.post(url, data)
+    assert response.status_code == 201
+    u = User.objects.get(email="test1@test.com")
+    assert u.username == "test1"
+    assert u.invitation == invitation
+
+
+def test_can_signup_with_invitation_invalid(preferences, factories, api_client):
+    url = reverse("rest_register")
+    factories["users.Invitation"](code="hello")
+    data = {
+        "username": "test1",
+        "email": "test1@test.com",
+        "password1": "testtest",
+        "password2": "testtest",
+        "invitation": "nope",
+    }
+    response = api_client.post(url, data)
+    assert response.status_code == 400
+    assert "invitation" in response.data
+
+
 def test_can_fetch_data_from_api(api_client, factories):
     url = reverse("api:v1:users:users-me")
     response = api_client.get(url)
diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue
index 03ea4ee0773a022acd0ef5d9cbd36c554330f6af..87c374a336780d1e2623f85685e581abbabab347 100644
--- a/front/src/components/Sidebar.vue
+++ b/front/src/components/Sidebar.vue
@@ -99,7 +99,7 @@
             <router-link
               class="item"
               v-if="$store.state.auth.availablePermissions['settings']"
-              :to="{path: '/manage/users'}">
+              :to="{name: 'manage.users.users.list'}">
               <i class="users icon"></i>{{ $t('Users') }}
             </router-link>
           </div>
diff --git a/front/src/components/auth/Signup.vue b/front/src/components/auth/Signup.vue
index 89f4cb1f1266c956283142cfc4f470e3b0c5d031..e4e5cebbce950b7470204f710e06ffe01e7632e7 100644
--- a/front/src/components/auth/Signup.vue
+++ b/front/src/components/auth/Signup.vue
@@ -2,19 +2,22 @@
   <div class="main pusher" v-title="'Sign Up'">
     <div class="ui vertical stripe segment">
       <div class="ui small text container">
-        <h2><i18next path="Create a funkwhale account"/></h2>
+        <h2>{{ $t("Create a funkwhale account") }}</h2>
         <form
-          v-if="$store.state.instance.settings.users.registration_enabled.value"
           :class="['ui', {'loading': isLoadingInstanceSetting}, 'form']"
           @submit.prevent="submit()">
+          <p class="ui message" v-if="!$store.state.instance.settings.users.registration_enabled.value">
+            {{ $t('Registration are closed on this instance, you will need an invitation code to signup.') }}
+          </p>
+
           <div v-if="errors.length > 0" class="ui negative message">
-            <div class="header"><i18next path="We cannot create your account"/></div>
+            <div class="header">{{ $t("We cannot create your account") }}</div>
             <ul class="list">
               <li v-for="error in errors">{{ error }}</li>
             </ul>
           </div>
           <div class="field">
-            <i18next tag="label" path="Username"/>
+            <label>{{ $t("Username") }}</label>
             <input
             ref="username"
             required
@@ -24,7 +27,7 @@
             v-model="username">
           </div>
           <div class="field">
-            <i18next tag="label" path="Email"/>
+            <label>{{ $t("Email") }}</label>
             <input
             ref="email"
             required
@@ -33,12 +36,22 @@
             v-model="email">
           </div>
           <div class="field">
-            <i18next tag="label" path="Password"/>
+            <label>{{ $t("Password") }}</label>
             <password-input v-model="password" />
           </div>
-          <button :class="['ui', 'green', {'loading': isLoading}, 'button']" type="submit"><i18next path="Create my account"/></button>
+          <div class="field">
+            <label v-if="!$store.state.instance.settings.users.registration_enabled.value">{{ $t("Invitation code") }}</label>
+            <label v-else>{{ $t("Invitation code (optional)") }}</label>
+            <input
+            :required="!$store.state.instance.settings.users.registration_enabled.value"
+            type="text"
+            :placeholder="$t('Enter your invitation code (case insensitive)')"
+            v-model="invitation">
+          </div>
+          <button :class="['ui', 'green', {'loading': isLoading}, 'button']" type="submit">
+            {{ $t("Create my account") }}
+          </button>
         </form>
-        <i18next v-else tag="p" path="Registration is currently disabled on this instance, please try again later."/>
       </div>
     </div>
   </div>
@@ -51,13 +64,13 @@ import logger from '@/logging'
 import PasswordInput from '@/components/forms/PasswordInput'
 
 export default {
-  name: 'login',
-  components: {
-    PasswordInput
-  },
   props: {
+    invitation: {type: String, required: false, default: null},
     next: {type: String, default: '/'}
   },
+  components: {
+    PasswordInput
+  },
   data () {
     return {
       username: '',
@@ -85,7 +98,8 @@ export default {
         username: this.username,
         password1: this.password,
         password2: this.password,
-        email: this.email
+        email: this.email,
+        invitation: this.invitation
       }
       return axios.post('auth/registration/', payload).then(response => {
         logger.default.info('Successfully created account')
diff --git a/front/src/components/common/ActionTable.vue b/front/src/components/common/ActionTable.vue
index f23479066e37667d3ea7dba63d504f1778a93d07..097fb29385eb495d00bc349d88f733c5d9ac5813 100644
--- a/front/src/components/common/ActionTable.vue
+++ b/front/src/components/common/ActionTable.vue
@@ -36,7 +36,7 @@
               <div class="count field">
                 <span v-if="selectAll">{{ $t('{% count %} on {% total %} selected', {count: objectsData.count, total: objectsData.count}) }}</span>
                 <span v-else>{{ $t('{% count %} on {% total %} selected', {count: checked.length, total: objectsData.count}) }}</span>
-                <template v-if="!currentAction.isDangerous && checkable.length === checked.length">
+                <template v-if="!currentAction.isDangerous && checkable.length > 0 && checkable.length === checked.length">
                   <a @click="selectAll = true" v-if="!selectAll">
                     {{ $t('Select all {% total %} elements', {total: objectsData.count}) }}
                   </a>
@@ -157,6 +157,7 @@ export default {
       let self = this
       self.actionLoading = true
       self.result = null
+      self.actionErrors = []
       let payload = {
         action: this.currentActionName,
         filters: this.filters
diff --git a/front/src/components/manage/users/InvitationForm.vue b/front/src/components/manage/users/InvitationForm.vue
new file mode 100644
index 0000000000000000000000000000000000000000..9429c1ae16294c49b91aa5a6ee43780a7b596c90
--- /dev/null
+++ b/front/src/components/manage/users/InvitationForm.vue
@@ -0,0 +1,82 @@
+<template>
+  <div>
+    <form class="ui form" @submit.prevent="submit">
+      <div v-if="errors.length > 0" class="ui negative message">
+        <div class="header">{{ $t('Error while creating invitation') }}</div>
+        <ul class="list">
+          <li v-for="error in errors">{{ error }}</li>
+        </ul>
+      </div>
+      <div class="inline fields">
+        <div class="ui field">
+          <label>{{ $t('Invitation code')}}</label>
+          <input type="text" v-model="code" :placeholder="$t('Leave empty for a random code')" />
+        </div>
+        <div class="ui field">
+          <button :class="['ui', {loading: isLoading}, 'button']" :disabled="isLoading" type="submit">
+            {{ $t('Get a new invitation') }}
+          </button>
+        </div>
+      </div>
+    </form>
+    <div v-if="invitations.length > 0">
+      <div class="ui hidden divider"></div>
+      <table class="ui ui basic table">
+        <thead>
+          <tr>
+            <th>{{ $t('Code') }}</th>
+            <th>{{ $t('Share link') }}</th>
+          </tr>
+        </thead>
+        <tbody>
+          <tr v-for="invitation in invitations" :key="invitation.code">
+            <td>{{ invitation.code.toUpperCase() }}</td>
+            <td><a :href="getUrl(invitation.code)" target="_blank">{{ getUrl(invitation.code) }}</a></td>
+          </tr>
+        </tbody>
+      </table>
+      <button class="ui basic button" @click="invitations = []">{{ $t('Clear') }}</button>
+    </div>
+  </div>
+</template>
+
+<script>
+import axios from 'axios'
+
+import backend from '@/audio/backend'
+
+export default {
+  data () {
+    return {
+      isLoading: false,
+      code: null,
+      invitations: [],
+      errors: []
+    }
+  },
+  methods: {
+    submit () {
+      let self = this
+      this.isLoading = true
+      this.errors = []
+      let url = 'manage/users/invitations/'
+      let payload = {
+        code: this.code
+      }
+      axios.post(url, payload).then((response) => {
+        self.isLoading = false
+        self.invitations.unshift(response.data)
+      }, (error) => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    },
+    getUrl (code) {
+      return backend.absoluteUrl(this.$router.resolve({name: 'signup', query: {invitation: code.toUpperCase()}}).href)
+    }
+  }
+}
+</script>
+
+<style scoped>
+</style>
diff --git a/front/src/components/manage/users/InvitationsTable.vue b/front/src/components/manage/users/InvitationsTable.vue
new file mode 100644
index 0000000000000000000000000000000000000000..e8d0a2406aaf465d7e30803b05ace126b621606d
--- /dev/null
+++ b/front/src/components/manage/users/InvitationsTable.vue
@@ -0,0 +1,191 @@
+<template>
+  <div>
+    <div class="ui inline form">
+      <div class="fields">
+        <div class="ui field">
+          <label>{{ $t('Search') }}</label>
+          <input type="text" v-model="search" placeholder="Search by username, email, code..." />
+        </div>
+        <div class="field">
+          <label>{{ $t("Ordering") }}</label>
+          <select class="ui dropdown" v-model="ordering">
+            <option v-for="option in orderingOptions" :value="option[0]">
+              {{ option[1] }}
+            </option>
+          </select>
+        </div>
+        <div class="field">
+          <label>{{ $t("Status") }}</label>
+          <select class="ui dropdown" v-model="isOpen">
+            <option :value="null">{{ $t('All') }}</option>
+            <option :value="true">{{ $t('Open') }}</option>
+            <option :value="false">{{ $t('Expired/used') }}</option>
+          </select>
+        </div>
+      </div>
+      </div>
+    <div class="dimmable">
+      <div v-if="isLoading" class="ui active inverted dimmer">
+          <div class="ui loader"></div>
+      </div>
+      <action-table
+        v-if="result"
+        @action-launched="fetchData"
+        :objects-data="result"
+        :actions="actions"
+        :action-url="'manage/users/invitations/action/'"
+        :filters="actionFilters">
+        <template slot="header-cells">
+          <th>{{ $t('Owner') }}</th>
+          <th>{{ $t('Status') }}</th>
+          <th>{{ $t('Creation date') }}</th>
+          <th>{{ $t('Expiration date') }}</th>
+          <th>{{ $t('Code') }}</th>
+        </template>
+        <template slot="row-cells" slot-scope="scope">
+          <td>
+            <router-link :to="{name: 'manage.users.users.detail', params: {id: scope.obj.id }}">{{ scope.obj.owner.username }}</router-link>
+          </td>
+          <td>
+            <span v-if="scope.obj.users.length > 0" class="ui green basic label">{{ $t('Used') }}</span>
+            <span v-else-if="moment().isAfter(scope.obj.expiration_date)" class="ui red basic label">{{ $t('Expired') }}</span>
+            <span v-else class="ui basic label">{{ $t('Not used') }}</span>
+          </td>
+          <td>
+            <human-date :date="scope.obj.creation_date"></human-date>
+          </td>
+          <td>
+            <human-date :date="scope.obj.expiration_date"></human-date>
+          </td>
+          <td>
+            {{ scope.obj.code.toUpperCase() }}
+          </td>
+        </template>
+      </action-table>
+    </div>
+    <div>
+      <pagination
+        v-if="result && result.results.length > 0"
+        @page-changed="selectPage"
+        :compact="true"
+        :current="page"
+        :paginate-by="paginateBy"
+        :total="result.count"
+        ></pagination>
+
+      <span v-if="result && result.results.length > 0">
+        {{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((page-1) * paginateBy) + 1 , end: ((page-1) * paginateBy) + result.results.length, total: result.count})}}
+      </span>
+    </div>
+  </div>
+</template>
+
+<script>
+import axios from 'axios'
+import moment from 'moment'
+import _ from 'lodash'
+import Pagination from '@/components/Pagination'
+import ActionTable from '@/components/common/ActionTable'
+import OrderingMixin from '@/components/mixins/Ordering'
+
+export default {
+  mixins: [OrderingMixin],
+  props: {
+    filters: {type: Object, required: false}
+  },
+  components: {
+    Pagination,
+    ActionTable
+  },
+  data () {
+    let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
+    return {
+      moment,
+      isLoading: false,
+      result: null,
+      page: 1,
+      paginateBy: 50,
+      search: '',
+      isOpen: null,
+      orderingDirection: defaultOrdering.direction || '+',
+      ordering: defaultOrdering.field,
+      orderingOptions: [
+        ['expiration_date', 'Expiration date'],
+        ['creation_date', 'Creation date']
+      ]
+
+    }
+  },
+  created () {
+    this.fetchData()
+  },
+  methods: {
+    fetchData () {
+      let params = _.merge({
+        'page': this.page,
+        'page_size': this.paginateBy,
+        'q': this.search,
+        'is_open': this.isOpen,
+        'ordering': this.getOrderingAsString()
+      }, this.filters)
+      let self = this
+      self.isLoading = true
+      self.checked = []
+      axios.get('/manage/users/invitations/', {params: params}).then((response) => {
+        self.result = response.data
+        self.isLoading = false
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    },
+    selectPage: function (page) {
+      this.page = page
+    }
+  },
+  computed: {
+    actionFilters () {
+      var currentFilters = {
+        q: this.search
+      }
+      if (this.filters) {
+        return _.merge(currentFilters, this.filters)
+      } else {
+        return currentFilters
+      }
+    },
+    actions () {
+      return [
+        {
+          name: 'delete',
+          label: this.$t('Delete'),
+          filterCheckable: (obj) => {
+            return obj.users.length === 0 && moment().isBefore(obj.expiration_date)
+          }
+        }
+      ]
+    }
+  },
+  watch: {
+    search (newValue) {
+      this.page = 1
+      this.fetchData()
+    },
+    page () {
+      this.fetchData()
+    },
+    ordering () {
+      this.page = 1
+      this.fetchData()
+    },
+    isOpen () {
+      this.page = 1
+      this.fetchData()
+    },
+    orderingDirection () {
+      this.page = 1
+      this.fetchData()
+    }
+  }
+}
+</script>
diff --git a/front/src/components/manage/users/UsersTable.vue b/front/src/components/manage/users/UsersTable.vue
index 5658583c3df1d59b51d186123beb6fa15aa6dd29..855fbe2b5da53abf717dc819a5f2560c5e35bd54 100644
--- a/front/src/components/manage/users/UsersTable.vue
+++ b/front/src/components/manage/users/UsersTable.vue
@@ -45,7 +45,7 @@
         </template>
         <template slot="row-cells" slot-scope="scope">
           <td>
-            <router-link :to="{name: 'manage.users.detail', params: {id: scope.obj.id }}">{{ scope.obj.username }}</router-link>
+            <router-link :to="{name: 'manage.users.users.detail', params: {id: scope.obj.id }}">{{ scope.obj.username }}</router-link>
           </td>
           <td>
             <span>{{ scope.obj.email }}</span>
diff --git a/front/src/main.js b/front/src/main.js
index eb2e3a23d6ceed3bdb88ed59d49df5a9744e4828..7973e4bb7e87222097c649f44fbba13c318a7256 100644
--- a/front/src/main.js
+++ b/front/src/main.js
@@ -86,11 +86,15 @@ axios.interceptors.response.use(function (response) {
   } else if (error.response.status === 500) {
     error.backendErrors.push('A server error occured')
   } else if (error.response.data) {
-    for (var field in error.response.data) {
-      if (error.response.data.hasOwnProperty(field)) {
-        error.response.data[field].forEach(e => {
-          error.backendErrors.push(e)
-        })
+    if (error.response.data.detail) {
+      error.backendErrors.push(error.response.data.detail)
+    } else {
+      for (var field in error.response.data) {
+        if (error.response.data.hasOwnProperty(field)) {
+          error.response.data[field].forEach(e => {
+            error.backendErrors.push(e)
+          })
+        }
       }
     }
   }
diff --git a/front/src/router/index.js b/front/src/router/index.js
index 0d2ad34f98aceff40b212625bac52b9ab9be9bf7..19474376874435c36e735920290061b7111b3fc4 100644
--- a/front/src/router/index.js
+++ b/front/src/router/index.js
@@ -34,6 +34,7 @@ import AdminLibraryFilesList from '@/views/admin/library/FilesList'
 import AdminUsersBase from '@/views/admin/users/Base'
 import AdminUsersDetail from '@/views/admin/users/UsersDetail'
 import AdminUsersList from '@/views/admin/users/UsersList'
+import AdminInvitationsList from '@/views/admin/users/InvitationsList'
 import FederationBase from '@/views/federation/Base'
 import FederationScan from '@/views/federation/Scan'
 import FederationLibraryDetail from '@/views/federation/LibraryDetail'
@@ -96,7 +97,10 @@ export default new Router({
     {
       path: '/signup',
       name: 'signup',
-      component: Signup
+      component: Signup,
+      props: (route) => ({
+        invitation: route.query.invitation
+      })
     },
     {
       path: '/logout',
@@ -188,15 +192,20 @@ export default new Router({
       component: AdminUsersBase,
       children: [
         {
-          path: '',
-          name: 'manage.users.list',
+          path: 'users',
+          name: 'manage.users.users.list',
           component: AdminUsersList
         },
         {
-          path: ':id',
-          name: 'manage.users.detail',
+          path: 'users/:id',
+          name: 'manage.users.users.detail',
           component: AdminUsersDetail,
           props: true
+        },
+        {
+          path: 'invitations',
+          name: 'manage.users.invitations.list',
+          component: AdminInvitationsList
         }
       ]
     },
diff --git a/front/src/views/admin/users/Base.vue b/front/src/views/admin/users/Base.vue
index e545b7f709fd63cad5154c4c0375b067f689e72f..505ca587fe2bf2726b2a3c30bfbf4b6c540a858f 100644
--- a/front/src/views/admin/users/Base.vue
+++ b/front/src/views/admin/users/Base.vue
@@ -3,7 +3,10 @@
     <div class="ui secondary pointing menu">
       <router-link
         class="ui item"
-        :to="{name: 'manage.users.list'}">{{ $t('Users') }}</router-link>
+        :to="{name: 'manage.users.users.list'}">{{ $t('Users') }}</router-link>
+      <router-link
+        class="ui item"
+        :to="{name: 'manage.users.invitations.list'}">{{ $t('Invitations') }}</router-link>
     </div>
     <router-view :key="$route.fullPath"></router-view>
   </div>
diff --git a/front/src/views/admin/users/InvitationsList.vue b/front/src/views/admin/users/InvitationsList.vue
new file mode 100644
index 0000000000000000000000000000000000000000..230dad6c1971a9c86c08570c404530ecf405c7d4
--- /dev/null
+++ b/front/src/views/admin/users/InvitationsList.vue
@@ -0,0 +1,26 @@
+<template>
+  <div v-title="$t('Invitations')">
+    <div class="ui vertical stripe segment">
+      <h2 class="ui header">{{ $t('Invitations') }}</h2>
+      <invitation-form></invitation-form>
+      <div class="ui hidden divider"></div>
+      <invitations-table></invitations-table>
+    </div>
+  </div>
+</template>
+
+<script>
+import InvitationForm from '@/components/manage/users/InvitationForm'
+import InvitationsTable from '@/components/manage/users/InvitationsTable'
+
+export default {
+  components: {
+    InvitationForm,
+    InvitationsTable
+  }
+}
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>