diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 1c58740e5a55d69467b19f822f6d7019a3aecd6d..97cdf7683dee7db459fc97b74cfdbbe875b601ed 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -135,7 +135,7 @@ test_api:
   only:
     - branches
   before_script:
-    - apk add make git
+    - apk add make git gcc python3-dev
     - cd api
     - pip3 install -r requirements/base.txt
     - pip3 install -r requirements/local.txt
diff --git a/api/config/settings/common.py b/api/config/settings/common.py
index 373c196d66edb07f9a988991ba6059b624ce25c8..2daec55caae32163c8c289cf7e2a0b5c1992c87c 100644
--- a/api/config/settings/common.py
+++ b/api/config/settings/common.py
@@ -659,6 +659,8 @@ AUTH_PASSWORD_VALIDATORS = [
     {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
     {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
 ]
+if env.bool("DISABLE_PASSWORD_VALIDATORS", default=False):
+    AUTH_PASSWORD_VALIDATORS = []
 ACCOUNT_ADAPTER = "funkwhale_api.users.adapters.FunkwhaleAccountAdapter"
 CORS_ORIGIN_ALLOW_ALL = True
 # CORS_ORIGIN_WHITELIST = (
diff --git a/api/funkwhale_api/common/preferences.py b/api/funkwhale_api/common/preferences.py
index 668a7c8976d2d784696f10fcaf8843e8e01c8d30..972b1ac1ecc0d0a54ba4f8936ffe88e96326651e 100644
--- a/api/funkwhale_api/common/preferences.py
+++ b/api/funkwhale_api/common/preferences.py
@@ -1,4 +1,7 @@
+import json
+
 from django import forms
+from django.contrib.postgres.forms import JSONField
 from django.conf import settings
 from dynamic_preferences import serializers, types
 from dynamic_preferences.registries import global_preferences_registry
@@ -57,3 +60,48 @@ class StringListPreference(types.BasePreferenceType):
         d = super(StringListPreference, self).get_api_additional_data()
         d["choices"] = self.get("choices")
         return d
+
+
+class JSONSerializer(serializers.BaseSerializer):
+    required = True
+
+    @classmethod
+    def to_db(cls, value, **kwargs):
+        if not cls.required and value is None:
+            return json.dumps(value)
+        data_serializer = cls.data_serializer_class(data=value)
+        if not data_serializer.is_valid():
+            raise cls.exception(
+                "{} is not a valid value: {}".format(value, data_serializer.errors)
+            )
+        value = data_serializer.validated_data
+        try:
+            return json.dumps(value, sort_keys=True)
+        except TypeError:
+            raise cls.exception(
+                "Cannot serialize, value {} is not JSON serializable".format(value)
+            )
+
+    @classmethod
+    def to_python(cls, value, **kwargs):
+        return json.loads(value)
+
+
+class SerializedPreference(types.BasePreferenceType):
+    """
+    A preference that store arbitrary JSON and validate it using a rest_framework
+    serializer
+    """
+
+    serializer = JSONSerializer
+    data_serializer_class = None
+    field_class = JSONField
+    widget = forms.Textarea
+
+    @property
+    def serializer(self):
+        class _internal(JSONSerializer):
+            data_serializer_class = self.data_serializer_class
+            required = self.get("required")
+
+        return _internal
diff --git a/api/funkwhale_api/federation/migrations/0025_auto_20200317_0820.py b/api/funkwhale_api/federation/migrations/0025_auto_20200317_0820.py
new file mode 100644
index 0000000000000000000000000000000000000000..6a59c95b6ad6b5142fec9a876eee87ca33b6aef9
--- /dev/null
+++ b/api/funkwhale_api/federation/migrations/0025_auto_20200317_0820.py
@@ -0,0 +1,45 @@
+# Generated by Django 3.0.4 on 2020-03-17 08:20
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('federation', '0024_actor_attachment_icon'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='activity',
+            name='object_content_type',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='objecting_activities', to='contenttypes.ContentType'),
+        ),
+        migrations.AlterField(
+            model_name='activity',
+            name='object_id',
+            field=models.IntegerField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='activity',
+            name='related_object_content_type',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='related_objecting_activities', to='contenttypes.ContentType'),
+        ),
+        migrations.AlterField(
+            model_name='activity',
+            name='related_object_id',
+            field=models.IntegerField(blank=True, null=True),
+        ),
+        migrations.AlterField(
+            model_name='activity',
+            name='target_content_type',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='targeting_activities', to='contenttypes.ContentType'),
+        ),
+        migrations.AlterField(
+            model_name='activity',
+            name='target_id',
+            field=models.IntegerField(blank=True, null=True),
+        ),
+    ]
diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py
index 3579369557f6a08cb37de1b40f471ec7429d1ed8..50e8eef1a634c298f2b526df8c0feb311e7f8cbb 100644
--- a/api/funkwhale_api/federation/models.py
+++ b/api/funkwhale_api/federation/models.py
@@ -292,6 +292,9 @@ class Actor(models.Model):
             from_activity__actor=self.pk
         ).count()
         data["reports"] = moderation_models.Report.objects.get_for_target(self).count()
+        data["requests"] = moderation_models.UserRequest.objects.filter(
+            submitter=self
+        ).count()
         data["albums"] = music_models.Album.objects.filter(
             from_activity__actor=self.pk
         ).count()
diff --git a/api/funkwhale_api/manage/filters.py b/api/funkwhale_api/manage/filters.py
index 5791afba3011cbccb5743c72a34932afc2515ac9..eb86a59f025cac52d6e18dc4703577827e8f8333 100644
--- a/api/funkwhale_api/manage/filters.py
+++ b/api/funkwhale_api/manage/filters.py
@@ -394,3 +394,26 @@ class ManageNoteFilterSet(filters.FilterSet):
     class Meta:
         model = moderation_models.Note
         fields = ["q"]
+
+
+class ManageUserRequestFilterSet(filters.FilterSet):
+    q = fields.SmartSearchFilter(
+        config=search.SearchConfig(
+            search_fields={
+                "username": {"to": "submitter__preferred_username"},
+                "uuid": {"to": "uuid"},
+            },
+            filter_fields={
+                "uuid": {"to": "uuid"},
+                "id": {"to": "id"},
+                "status": {"to": "status"},
+                "category": {"to": "type"},
+                "submitter": get_actor_filter("submitter"),
+                "assigned_to": get_actor_filter("assigned_to"),
+            },
+        )
+    )
+
+    class Meta:
+        model = moderation_models.UserRequest
+        fields = ["q", "status", "type"]
diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py
index 7adb9c86303def2879b91120704beb90e84d909d..c6966f108789e270168bb4058306525860922de2 100644
--- a/api/funkwhale_api/manage/serializers.py
+++ b/api/funkwhale_api/manage/serializers.py
@@ -710,3 +710,36 @@ class ManageReportSerializer(serializers.ModelSerializer):
     def get_notes(self, o):
         notes = getattr(o, "_prefetched_notes", [])
         return ManageBaseNoteSerializer(notes, many=True).data
+
+
+class ManageUserRequestSerializer(serializers.ModelSerializer):
+    assigned_to = ManageBaseActorSerializer()
+    submitter = ManageBaseActorSerializer()
+    notes = serializers.SerializerMethodField()
+
+    class Meta:
+        model = moderation_models.UserRequest
+        fields = [
+            "id",
+            "uuid",
+            "creation_date",
+            "handled_date",
+            "type",
+            "status",
+            "assigned_to",
+            "submitter",
+            "notes",
+            "metadata",
+        ]
+        read_only_fields = [
+            "id",
+            "uuid",
+            "submitter",
+            "creation_date",
+            "handled_date",
+            "metadata",
+        ]
+
+    def get_notes(self, o):
+        notes = getattr(o, "_prefetched_notes", [])
+        return ManageBaseNoteSerializer(notes, many=True).data
diff --git a/api/funkwhale_api/manage/urls.py b/api/funkwhale_api/manage/urls.py
index 36997b24aff1c3ab1df59bea43a308f7fb3b41b3..8af692d7a06c7d9ea50be3d7da50a65f85a66af7 100644
--- a/api/funkwhale_api/manage/urls.py
+++ b/api/funkwhale_api/manage/urls.py
@@ -18,6 +18,7 @@ moderation_router.register(
     r"instance-policies", views.ManageInstancePolicyViewSet, "instance-policies"
 )
 moderation_router.register(r"reports", views.ManageReportViewSet, "reports")
+moderation_router.register(r"requests", views.ManageUserRequestViewSet, "requests")
 moderation_router.register(r"notes", views.ManageNoteViewSet, "notes")
 
 users_router = routers.OptionalSlashRouter()
diff --git a/api/funkwhale_api/manage/views.py b/api/funkwhale_api/manage/views.py
index c84f9b3c028b58052653ca6776ec54a79b019a65..5a0f81a39cf77694852171d9d0eeadfd12428e3e 100644
--- a/api/funkwhale_api/manage/views.py
+++ b/api/funkwhale_api/manage/views.py
@@ -1,12 +1,14 @@
 from rest_framework import mixins, response, viewsets
 from rest_framework import decorators as rest_decorators
 
+from django.db import transaction
 from django.db.models import Count, Prefetch, Q, Sum, OuterRef, Subquery
 from django.db.models.functions import Coalesce, Length
 from django.shortcuts import get_object_or_404
 
 from funkwhale_api.common import models as common_models
 from funkwhale_api.common import preferences, decorators
+from funkwhale_api.common import utils as common_utils
 from funkwhale_api.favorites import models as favorites_models
 from funkwhale_api.federation import models as federation_models
 from funkwhale_api.federation import tasks as federation_tasks
@@ -14,6 +16,7 @@ from funkwhale_api.history import models as history_models
 from funkwhale_api.music import models as music_models
 from funkwhale_api.music import views as music_views
 from funkwhale_api.moderation import models as moderation_models
+from funkwhale_api.moderation import tasks as moderation_tasks
 from funkwhale_api.playlists import models as playlists_models
 from funkwhale_api.tags import models as tags_models
 from funkwhale_api.users import models as users_models
@@ -469,8 +472,8 @@ class ManageActorViewSet(
 
     @rest_decorators.action(methods=["get"], detail=True)
     def stats(self, request, *args, **kwargs):
-        domain = self.get_object()
-        return response.Response(domain.get_stats(), status=200)
+        obj = self.get_object()
+        return response.Response(obj.get_stats(), status=200)
 
     action = decorators.action_route(serializers.ManageActorActionSerializer)
 
@@ -607,3 +610,54 @@ class ManageTagViewSet(
         serializer.is_valid(raise_exception=True)
         result = serializer.save()
         return response.Response(result, status=200)
+
+
+class ManageUserRequestViewSet(
+    mixins.ListModelMixin,
+    mixins.RetrieveModelMixin,
+    mixins.UpdateModelMixin,
+    viewsets.GenericViewSet,
+):
+    lookup_field = "uuid"
+    queryset = (
+        moderation_models.UserRequest.objects.all()
+        .order_by("-creation_date")
+        .select_related("submitter", "assigned_to")
+        .prefetch_related(
+            Prefetch(
+                "notes",
+                queryset=moderation_models.Note.objects.order_by(
+                    "creation_date"
+                ).select_related("author"),
+                to_attr="_prefetched_notes",
+            )
+        )
+    )
+    serializer_class = serializers.ManageUserRequestSerializer
+    filterset_class = filters.ManageUserRequestFilterSet
+    required_scope = "instance:requests"
+    ordering_fields = ["id", "creation_date", "handled_date"]
+
+    def get_queryset(self):
+        queryset = super().get_queryset()
+        if self.action in ["update", "partial_update"]:
+            # approved requests cannot be edited
+            queryset = queryset.exclude(status="approved")
+        return queryset
+
+    @transaction.atomic
+    def perform_update(self, serializer):
+        old_status = serializer.instance.status
+        new_status = serializer.validated_data.get("status")
+
+        if old_status != new_status and new_status != "pending":
+            # report was resolved, we assign to the mod making the request
+            serializer.save(assigned_to=self.request.user.actor)
+            common_utils.on_commit(
+                moderation_tasks.user_request_handle.delay,
+                user_request_id=serializer.instance.pk,
+                new_status=new_status,
+                old_status=old_status,
+            )
+        else:
+            serializer.save()
diff --git a/api/funkwhale_api/moderation/dynamic_preferences_registry.py b/api/funkwhale_api/moderation/dynamic_preferences_registry.py
index 29390434197062fce2fb5a031c0b40c93367b4b0..331b9b5c75e4270f73218a3a3de10724a2416b05 100644
--- a/api/funkwhale_api/moderation/dynamic_preferences_registry.py
+++ b/api/funkwhale_api/moderation/dynamic_preferences_registry.py
@@ -1,7 +1,11 @@
 from dynamic_preferences import types
 from dynamic_preferences.registries import global_preferences_registry
 
+from rest_framework import serializers
+
 from funkwhale_api.common import preferences as common_preferences
+from funkwhale_api.common import serializers as common_serializers
+from funkwhale_api.common import utils as common_utils
 
 from . import models
 
@@ -40,3 +44,52 @@ class UnauthenticatedReportTypes(common_preferences.StringListPreference):
     help_text = "A list of categories for which external users (without an account) can submit a report"
     choices = models.REPORT_TYPES
     field_kwargs = {"choices": choices, "required": False}
+
+
+@global_preferences_registry.register
+class SignupApprovalEnabled(types.BooleanPreference):
+    show_in_api = True
+    section = moderation
+    name = "signup_approval_enabled"
+    verbose_name = "Enable manual sign-up validation"
+    help_text = "If enabled, new registrations will go to a moderation queue and need to be reviewed by moderators."
+    default = False
+
+
+CUSTOM_FIELDS_TYPES = [
+    "short_text",
+    "long_text",
+]
+
+
+class CustomFieldSerializer(serializers.Serializer):
+    label = serializers.CharField()
+    required = serializers.BooleanField(default=True)
+    input_type = serializers.ChoiceField(choices=CUSTOM_FIELDS_TYPES)
+
+
+class CustomFormSerializer(serializers.Serializer):
+    help_text = common_serializers.ContentSerializer(required=False, allow_null=True)
+    fields = serializers.ListField(
+        child=CustomFieldSerializer(), min_length=0, max_length=10, required=False
+    )
+
+    def validate_help_text(self, v):
+        if not v:
+            return
+        v["html"] = common_utils.render_html(
+            v["text"], content_type=v["content_type"], permissive=True
+        )
+        return v
+
+
+@global_preferences_registry.register
+class SignupFormCustomization(common_preferences.SerializedPreference):
+    show_in_api = True
+    section = moderation
+    name = "signup_form_customization"
+    verbose_name = "Sign-up form customization"
+    help_text = "Configure custom fields and help text for your sign-up form"
+    required = False
+    default = {}
+    data_serializer_class = CustomFormSerializer
diff --git a/api/funkwhale_api/moderation/factories.py b/api/funkwhale_api/moderation/factories.py
index 96e6b5d1169bd57b627e13a0e8dc45a3715977c1..35256285df023f4cf4d010f3286507d11f934f1c 100644
--- a/api/funkwhale_api/moderation/factories.py
+++ b/api/funkwhale_api/moderation/factories.py
@@ -74,3 +74,20 @@ class ReportFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
             return
 
         self.target_owner = serializers.get_target_owner(self.target)
+
+
+@registry.register
+class UserRequestFactory(NoUpdateOnCreate, factory.DjangoModelFactory):
+    submitter = factory.SubFactory(federation_factories.ActorFactory, local=True)
+
+    class Meta:
+        model = "moderation.UserRequest"
+
+    class Params:
+        signup = factory.Trait(
+            submitter=factory.SubFactory(federation_factories.ActorFactory, local=True),
+            type="signup",
+        )
+        assigned = factory.Trait(
+            assigned_to=factory.SubFactory(federation_factories.ActorFactory)
+        )
diff --git a/api/funkwhale_api/moderation/migrations/0005_auto_20200317_0820.py b/api/funkwhale_api/moderation/migrations/0005_auto_20200317_0820.py
new file mode 100644
index 0000000000000000000000000000000000000000..941b8158c68cf381c137e5735cc980079b9b20b0
--- /dev/null
+++ b/api/funkwhale_api/moderation/migrations/0005_auto_20200317_0820.py
@@ -0,0 +1,41 @@
+# Generated by Django 3.0.4 on 2020-03-17 08:20
+
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations, models
+import django.db.models.deletion
+import django.utils.timezone
+import uuid
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('federation', '0025_auto_20200317_0820'),
+        ('moderation', '0004_note'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='report',
+            name='summary',
+            field=models.TextField(blank=True, max_length=50000, null=True),
+        ),
+        migrations.CreateModel(
+            name='UserRequest',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('url', models.URLField(blank=True, max_length=500, null=True)),
+                ('uuid', models.UUIDField(default=uuid.uuid4, unique=True)),
+                ('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
+                ('handled_date', models.DateTimeField(null=True)),
+                ('type', models.CharField(choices=[('signup', 'Sign-up')], max_length=40)),
+                ('status', models.CharField(choices=[('pending', 'Pending'), ('refused', 'Refused'), ('approved', 'approved')], default='pending', max_length=40)),
+                ('metadata', django.contrib.postgres.fields.jsonb.JSONField(null=True)),
+                ('assigned_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assigned_requests', to='federation.Actor')),
+                ('submitter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='requests', to='federation.Actor')),
+            ],
+            options={
+                'abstract': False,
+            },
+        ),
+    ]
diff --git a/api/funkwhale_api/moderation/models.py b/api/funkwhale_api/moderation/models.py
index e6b9cf09eb2d0032985962ffcdf534f5721c5abb..0271877e4888299bd49d425e0d7e378769c2f59c 100644
--- a/api/funkwhale_api/moderation/models.py
+++ b/api/funkwhale_api/moderation/models.py
@@ -185,6 +185,43 @@ class Note(models.Model):
     target = GenericForeignKey("target_content_type", "target_id")
 
 
+USER_REQUEST_TYPES = [
+    ("signup", "Sign-up"),
+]
+
+USER_REQUEST_STATUSES = [
+    ("pending", "Pending"),
+    ("refused", "Refused"),
+    ("approved", "Approved"),
+]
+
+
+class UserRequest(models.Model):
+    uuid = models.UUIDField(default=uuid.uuid4, unique=True)
+    creation_date = models.DateTimeField(default=timezone.now)
+    handled_date = models.DateTimeField(null=True)
+    type = models.CharField(max_length=40, choices=USER_REQUEST_TYPES)
+    status = models.CharField(
+        max_length=40, choices=USER_REQUEST_STATUSES, default="pending"
+    )
+    submitter = models.ForeignKey(
+        "federation.Actor", related_name="requests", on_delete=models.CASCADE,
+    )
+    assigned_to = models.ForeignKey(
+        "federation.Actor",
+        related_name="assigned_requests",
+        on_delete=models.SET_NULL,
+        null=True,
+        blank=True,
+    )
+
+    metadata = JSONField(null=True)
+
+    notes = GenericRelation(
+        "Note", content_type_field="target_content_type", object_id_field="target_id"
+    )
+
+
 @receiver(pre_save, sender=Report)
 def set_handled_date(sender, instance, **kwargs):
     if instance.is_handled is True and not instance.handled_date:
diff --git a/api/funkwhale_api/moderation/tasks.py b/api/funkwhale_api/moderation/tasks.py
index 0d0e970521d804baf5e92fc6202ddaf50ae179ea..6f908270fd3a483b1a48a69b188f743fd1743130 100644
--- a/api/funkwhale_api/moderation/tasks.py
+++ b/api/funkwhale_api/moderation/tasks.py
@@ -1,9 +1,11 @@
 import logging
 from django.core import mail
-from django.dispatch import receiver
 from django.conf import settings
+from django.db import transaction
+from django.dispatch import receiver
 
 from funkwhale_api.common import channels
+from funkwhale_api.common import preferences
 from funkwhale_api.common import utils
 from funkwhale_api.taskapp import celery
 from funkwhale_api.federation import utils as federation_utils
@@ -41,11 +43,7 @@ def trigger_moderator_email(report, **kwargs):
         utils.on_commit(send_new_report_email_to_moderators.delay, report_id=report.pk)
 
 
-@celery.app.task(name="moderation.send_new_report_email_to_moderators")
-@celery.require_instance(
-    models.Report.objects.select_related("submitter").filter(is_handled=False), "report"
-)
-def send_new_report_email_to_moderators(report):
+def get_moderators():
     moderators = users_models.User.objects.filter(
         is_active=True, permission_moderation=True
     )
@@ -53,6 +51,15 @@ def send_new_report_email_to_moderators(report):
         # we fallback on superusers
         moderators = users_models.User.objects.filter(is_superuser=True)
     moderators = sorted(moderators, key=lambda m: m.pk)
+    return moderators
+
+
+@celery.app.task(name="moderation.send_new_report_email_to_moderators")
+@celery.require_instance(
+    models.Report.objects.select_related("submitter").filter(is_handled=False), "report"
+)
+def send_new_report_email_to_moderators(report):
+    moderators = get_moderators()
     submitter_repr = (
         report.submitter.full_username if report.submitter else report.submitter_email
     )
@@ -114,3 +121,148 @@ def send_new_report_email_to_moderators(report):
             recipient_list=[moderator.email],
             from_email=settings.DEFAULT_FROM_EMAIL,
         )
+
+
+@celery.app.task(name="moderation.user_request_handle")
+@celery.require_instance(
+    models.UserRequest.objects.select_related("submitter"), "user_request"
+)
+@transaction.atomic
+def user_request_handle(user_request, new_status, old_status=None):
+    if user_request.status != new_status:
+        logger.warn(
+            "User request %s was handled before asynchronous tasks run", user_request.pk
+        )
+        return
+
+    if user_request.type == "signup" and new_status == "pending" and old_status is None:
+        notify_mods_signup_request_pending(user_request)
+        broadcast_user_request_created(user_request)
+    elif user_request.type == "signup" and new_status == "approved":
+        user_request.submitter.user.is_active = True
+        user_request.submitter.user.save(update_fields=["is_active"])
+        notify_submitter_signup_request_approved(user_request)
+    elif user_request.type == "signup" and new_status == "refused":
+        notify_submitter_signup_request_refused(user_request)
+
+
+def broadcast_user_request_created(user_request):
+    from funkwhale_api.manage import serializers as manage_serializers
+
+    channels.group_send(
+        "admin.moderation",
+        {
+            "type": "event.send",
+            "text": "",
+            "data": {
+                "type": "user_request.created",
+                "user_request": manage_serializers.ManageUserRequestSerializer(
+                    user_request
+                ).data,
+                "pending_count": models.UserRequest.objects.filter(
+                    status="pending"
+                ).count(),
+            },
+        },
+    )
+
+
+def notify_mods_signup_request_pending(obj):
+    moderators = get_moderators()
+    submitter_repr = obj.submitter.preferred_username
+    subject = "[{} moderation] New sign-up request from {}".format(
+        settings.FUNKWHALE_HOSTNAME, submitter_repr
+    )
+    detail_url = federation_utils.full_url(
+        "/manage/moderation/requests/{}".format(obj.uuid)
+    )
+    unresolved_requests_url = federation_utils.full_url(
+        "/manage/moderation/requests?q=status:pending"
+    )
+    unresolved_requests = models.UserRequest.objects.filter(status="pending").count()
+    body = [
+        "{} wants to register on your pod. You need to review their request before they can use the service.".format(
+            submitter_repr
+        ),
+        "",
+        "- To handle this request, please visit {}".format(detail_url),
+        "- To view all unresolved requests (currently {}), please visit {}".format(
+            unresolved_requests, unresolved_requests_url
+        ),
+        "",
+        "—",
+        "",
+        "You are receiving this email because you are a moderator for {}.".format(
+            settings.FUNKWHALE_HOSTNAME
+        ),
+    ]
+
+    for moderator in moderators:
+        if not moderator.email:
+            logger.warning("Moderator %s has no email configured", moderator.username)
+            continue
+        mail.send_mail(
+            subject,
+            message="\n".join(body),
+            recipient_list=[moderator.email],
+            from_email=settings.DEFAULT_FROM_EMAIL,
+        )
+
+
+def notify_submitter_signup_request_approved(user_request):
+    submitter_repr = user_request.submitter.preferred_username
+    submitter_email = user_request.submitter.user.email
+    if not submitter_email:
+        logger.warning("User %s has no email configured", submitter_repr)
+        return
+    subject = "Welcome to {}, {}!".format(settings.FUNKWHALE_HOSTNAME, submitter_repr)
+    login_url = federation_utils.full_url("/login")
+    body = [
+        "Hi {} and welcome,".format(submitter_repr),
+        "",
+        "Our moderation team has approved your account request and you can now start "
+        "using the service. Please visit {} to get started.".format(login_url),
+        "",
+        "Before your first login, you may need to verify your email address if you didn't already.",
+    ]
+
+    mail.send_mail(
+        subject,
+        message="\n".join(body),
+        recipient_list=[submitter_email],
+        from_email=settings.DEFAULT_FROM_EMAIL,
+    )
+
+
+def notify_submitter_signup_request_refused(user_request):
+    submitter_repr = user_request.submitter.preferred_username
+    submitter_email = user_request.submitter.user.email
+    if not submitter_email:
+        logger.warning("User %s has no email configured", submitter_repr)
+        return
+    subject = "Your account request at {} was refused".format(
+        settings.FUNKWHALE_HOSTNAME
+    )
+    body = [
+        "Hi {},".format(submitter_repr),
+        "",
+        "You recently submitted an account request on our service. However, our "
+        "moderation team has refused it, and as a result, you won't be able to use "
+        "the service.",
+    ]
+
+    instance_contact_email = preferences.get("instance__contact_email")
+    if instance_contact_email:
+        body += [
+            "",
+            "If you think this is a mistake, please contact our team at {}.".format(
+                instance_contact_email
+            ),
+        ]
+
+    mail.send_mail(
+        subject,
+        message="\n".join(body),
+        recipient_list=[submitter_email],
+        from_email=settings.DEFAULT_FROM_EMAIL,
+    )
diff --git a/api/funkwhale_api/moderation/utils.py b/api/funkwhale_api/moderation/utils.py
index d4a1b879a537b0666c372a061e780057c8b90313..c8bf691dcc863ecc105c4eb158a1f091862903b7 100644
--- a/api/funkwhale_api/moderation/utils.py
+++ b/api/funkwhale_api/moderation/utils.py
@@ -12,6 +12,11 @@ NOTE_TARGET_FIELDS = {
         "id_attr": "uuid",
         "id_field": serializers.UUIDField(),
     },
+    "request": {
+        "queryset": models.UserRequest.objects.all(),
+        "id_attr": "uuid",
+        "id_field": serializers.UUIDField(),
+    },
     "account": {
         "queryset": federation_models.Actor.objects.all(),
         "id_attr": "full_username",
@@ -19,3 +24,21 @@ NOTE_TARGET_FIELDS = {
         "get_query": moderation_serializers.get_actor_query,
     },
 }
+
+
+def get_signup_form_additional_fields_serializer(customization):
+    fields = (customization or {}).get("fields", []) or []
+
+    class AdditionalFieldsSerializer(serializers.Serializer):
+        def __init__(self, *args, **kwargs):
+            super().__init__(*args, **kwargs)
+            for field in fields:
+                required = bool(field.get("required", True))
+                self.fields[field["label"]] = serializers.CharField(
+                    max_length=5000,
+                    required=required,
+                    allow_null=not required,
+                    allow_blank=not required,
+                )
+
+    return AdditionalFieldsSerializer(required=fields, allow_null=not fields)
diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py
index c920503cd79445d743a92f43aed5085f88dfd52f..db30199c6c678e5e842b58b4244c9111115db761 100644
--- a/api/funkwhale_api/users/models.py
+++ b/api/funkwhale_api/users/models.py
@@ -46,6 +46,8 @@ PERMISSIONS_CONFIGURATION = {
             "write:instance:domains",
             "read:instance:reports",
             "write:instance:reports",
+            "read:instance:requests",
+            "write:instance:requests",
             "read:instance:notes",
             "write:instance:notes",
         },
diff --git a/api/funkwhale_api/users/oauth/scopes.py b/api/funkwhale_api/users/oauth/scopes.py
index 88b928c50813fc516b63b8d4d4baa6868679d51c..23958ccade9ff8238628ca0f328092753f0a279a 100644
--- a/api/funkwhale_api/users/oauth/scopes.py
+++ b/api/funkwhale_api/users/oauth/scopes.py
@@ -35,6 +35,7 @@ BASE_SCOPES = [
     Scope("instance:domains", "Access instance domains"),
     Scope("instance:policies", "Access instance moderation policies"),
     Scope("instance:reports", "Access instance moderation reports"),
+    Scope("instance:requests", "Access instance moderation requests"),
     Scope("instance:notes", "Access instance moderation notes"),
 ]
 SCOPES = [
diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py
index 1e919f7b711eb139b688601e1ef8d7a4da1828f1..2027ee8c9b07d69f44d6c59c5306637125b45d1b 100644
--- a/api/funkwhale_api/users/serializers.py
+++ b/api/funkwhale_api/users/serializers.py
@@ -10,9 +10,14 @@ from rest_framework import serializers
 
 from funkwhale_api.activity import serializers as activity_serializers
 from funkwhale_api.common import models as common_models
+from funkwhale_api.common import preferences
 from funkwhale_api.common import serializers as common_serializers
 from funkwhale_api.common import utils as common_utils
 from funkwhale_api.federation import models as federation_models
+from funkwhale_api.moderation import models as moderation_models
+from funkwhale_api.moderation import tasks as moderation_tasks
+from funkwhale_api.moderation import utils as moderation_utils
+
 from . import adapters
 from . import models
 
@@ -36,6 +41,17 @@ class RegisterSerializer(RS):
         required=False, allow_null=True, allow_blank=True
     )
 
+    def __init__(self, *args, **kwargs):
+        self.approval_enabled = preferences.get("moderation__signup_approval_enabled")
+        super().__init__(*args, **kwargs)
+        if self.approval_enabled:
+            customization = preferences.get("moderation__signup_form_customization")
+            self.fields[
+                "request_fields"
+            ] = moderation_utils.get_signup_form_additional_fields_serializer(
+                customization
+            )
+
     def validate_invitation(self, value):
         if not value:
             return
@@ -67,11 +83,28 @@ class RegisterSerializer(RS):
 
     def save(self, request):
         user = super().save(request)
+        update_fields = ["actor"]
+        user.actor = models.create_actor(user)
+        user_request = None
+        if self.approval_enabled:
+            # manually approve users
+            user.is_active = False
+            user_request = moderation_models.UserRequest.objects.create(
+                submitter=user.actor,
+                type="signup",
+                metadata=self.validated_data.get("request_fields", None) or None,
+            )
+            update_fields.append("is_active")
         if self.validated_data.get("invitation"):
             user.invitation = self.validated_data.get("invitation")
-            user.save(update_fields=["invitation"])
-        user.actor = models.create_actor(user)
-        user.save(update_fields=["actor"])
+            update_fields.append("invitation")
+        user.save(update_fields=update_fields)
+        if user_request:
+            common_utils.on_commit(
+                moderation_tasks.user_request_handle.delay,
+                user_request_id=user_request.pk,
+                new_status=user_request.status,
+            )
 
         return user
 
diff --git a/api/setup.cfg b/api/setup.cfg
index 2b8f8e825814c5794e8183dce8f81f188a30a1d9..8872573e9512091a9f34347e96f674fa29141bb7 100644
--- a/api/setup.cfg
+++ b/api/setup.cfg
@@ -13,6 +13,7 @@ exclude=.tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules
 [tool:pytest]
 python_files = tests.py test_*.py *_tests.py
 testpaths = tests
+addopts = -p no:warnings
 env =
     SECRET_KEY=test
     EMAIL_CONFIG=consolemail://
diff --git a/api/tests/common/test_preferences.py b/api/tests/common/test_preferences.py
index 7f941a45006be86550c8622bde019be61ebb0abc..f380e8236cdd8ab5b9c6278bf436208e5977f227 100644
--- a/api/tests/common/test_preferences.py
+++ b/api/tests/common/test_preferences.py
@@ -1,6 +1,10 @@
 import pytest
+
+from dynamic_preferences import types
 from dynamic_preferences.registries import global_preferences_registry
 
+from rest_framework import serializers
+
 from funkwhale_api.common import preferences as common_preferences
 
 
@@ -43,3 +47,44 @@ def test_string_list_pref_default(string_list_pref, preferences):
 def test_string_list_pref_set(string_list_pref, preferences):
     preferences["test__string_list"] = ["world", "hello"]
     assert preferences["test__string_list"] == ["hello", "world"]
+
+
+class PreferenceDataSerializer(serializers.Serializer):
+    name = serializers.CharField()
+    optional = serializers.BooleanField(required=False)
+
+
+@pytest.fixture
+def serialized_preference(db):
+    @global_preferences_registry.register
+    class TestSerialized(common_preferences.SerializedPreference):
+        section = types.Section("test")
+        name = "serialized"
+        data_serializer_class = PreferenceDataSerializer
+        default = None
+        required = False
+
+    yield
+    del global_preferences_registry["test"]["serialized"]
+
+
+@pytest.mark.parametrize(
+    "value", [{"name": "hello"}, {"name": "hello", "optional": True}]
+)
+def test_get_serialized_preference(value, preferences, serialized_preference):
+    pref_id = "test__serialized"
+    # default value
+    assert preferences[pref_id] is None
+
+    preferences[pref_id] = value
+    assert preferences[pref_id] == value
+
+
+@pytest.mark.parametrize(
+    "value", [{"noop": "hello"}, {"name": "hello", "optional": None}, "noop"]
+)
+def test_get_serialized_preference_error(value, preferences, serialized_preference):
+    pref_id = "test__serialized"
+
+    with pytest.raises(common_preferences.JSONSerializer.exception):
+        preferences[pref_id] = value
diff --git a/api/tests/federation/test_models.py b/api/tests/federation/test_models.py
index c96251ecb3d9dcfe19bc080f2496f46e8835b412..a0895d44a44d04fe6c9719ea18481cd4867af6e3 100644
--- a/api/tests/federation/test_models.py
+++ b/api/tests/federation/test_models.py
@@ -148,6 +148,7 @@ def test_actor_stats(factories):
         "uploads": 0,
         "artists": 0,
         "reports": 0,
+        "requests": 0,
         "outbox_activities": 0,
         "received_library_follows": 0,
         "emitted_library_follows": 0,
diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py
index 412a7a58c6e3ec9e4fd1cd5131699ee7d8684949..71f80bed0c8741f7e2fff3b11f285eadfd70b883 100644
--- a/api/tests/manage/test_serializers.py
+++ b/api/tests/manage/test_serializers.py
@@ -562,3 +562,26 @@ def test_manage_note_serializer(factories, to_api_date):
     s = serializers.ManageNoteSerializer(note)
 
     assert s.data == expected
+
+
+def test_manage_user_request_serializer(factories, to_api_date):
+    user_request = factories["moderation.UserRequest"](
+        signup=True, metadata={"foo": "bar"}, assigned=True
+    )
+    expected = {
+        "id": user_request.id,
+        "uuid": str(user_request.uuid),
+        "creation_date": to_api_date(user_request.creation_date),
+        "handled_date": None,
+        "status": user_request.status,
+        "type": user_request.type,
+        "submitter": serializers.ManageBaseActorSerializer(user_request.submitter).data,
+        "assigned_to": serializers.ManageBaseActorSerializer(
+            user_request.assigned_to
+        ).data,
+        "metadata": {"foo": "bar"},
+        "notes": [],
+    }
+    s = serializers.ManageUserRequestSerializer(user_request)
+
+    assert s.data == expected
diff --git a/api/tests/manage/test_views.py b/api/tests/manage/test_views.py
index ca03a42795497a5695c6ed49224274834717d7be..2482e0d80b7a91ac808017f87acada96da904630 100644
--- a/api/tests/manage/test_views.py
+++ b/api/tests/manage/test_views.py
@@ -3,6 +3,7 @@ from django.urls import reverse
 from funkwhale_api.federation import models as federation_models
 from funkwhale_api.federation import tasks as federation_tasks
 from funkwhale_api.manage import serializers
+from funkwhale_api.moderation import tasks as moderation_tasks
 
 
 def test_user_view(factories, superuser_api_client, mocker):
@@ -409,7 +410,7 @@ def test_upload_delete(factories, superuser_api_client):
     assert response.status_code == 204
 
 
-def test_note_create(factories, superuser_api_client):
+def test_note_create_actor(factories, superuser_api_client):
     actor = superuser_api_client.user.create_actor()
     target = factories["federation.Actor"]()
     data = {
@@ -425,6 +426,22 @@ def test_note_create(factories, superuser_api_client):
     assert response.data == serializers.ManageNoteSerializer(note).data
 
 
+def test_note_create_user_request(factories, superuser_api_client):
+    actor = superuser_api_client.user.create_actor()
+    target = factories["moderation.UserRequest"]()
+    data = {
+        "summary": "Hello",
+        "target": {"type": "request", "uuid": target.uuid},
+    }
+    url = reverse("api:v1:manage:moderation:notes-list")
+    response = superuser_api_client.post(url, data, format="json")
+    assert response.status_code == 201
+
+    note = actor.moderation_notes.latest("id")
+    assert note.target == target
+    assert response.data == serializers.ManageNoteSerializer(note).data
+
+
 def test_note_list(factories, superuser_api_client, settings):
     note = factories["moderation.Note"]()
     url = reverse("api:v1:manage:moderation:notes-list")
@@ -527,3 +544,58 @@ def test_report_update_is_handled_true_assigns(factories, superuser_api_client):
     report.refresh_from_db()
     assert report.is_handled is True
     assert report.assigned_to == actor
+
+
+def test_request_detail(factories, superuser_api_client):
+    request = factories["moderation.UserRequest"]()
+    url = reverse(
+        "api:v1:manage:moderation:requests-detail", kwargs={"uuid": request.uuid}
+    )
+    response = superuser_api_client.get(url)
+
+    assert response.status_code == 200
+    assert response.data["uuid"] == str(request.uuid)
+
+
+def test_request_list(factories, superuser_api_client, settings):
+    request = factories["moderation.UserRequest"]()
+    url = reverse("api:v1:manage:moderation:requests-list")
+    response = superuser_api_client.get(url)
+
+    assert response.status_code == 200
+
+    assert response.data["count"] == 1
+    assert response.data["results"][0]["uuid"] == str(request.uuid)
+
+
+def test_user_request_update(factories, superuser_api_client):
+    user_request = factories["moderation.UserRequest"](signup=True)
+    url = reverse(
+        "api:v1:manage:moderation:requests-detail", kwargs={"uuid": user_request.uuid}
+    )
+    response = superuser_api_client.patch(url, {"status": "approved"})
+
+    assert response.status_code == 200
+    user_request.refresh_from_db()
+    assert user_request.status == "approved"
+
+
+def test_user_request_update_status_assigns(factories, superuser_api_client, mocker):
+    actor = superuser_api_client.user.create_actor()
+    user_request = factories["moderation.UserRequest"](signup=True)
+    url = reverse(
+        "api:v1:manage:moderation:requests-detail", kwargs={"uuid": user_request.uuid}
+    )
+    on_commit = mocker.patch("funkwhale_api.common.utils.on_commit")
+    response = superuser_api_client.patch(url, {"status": "refused"})
+
+    assert response.status_code == 200
+    user_request.refresh_from_db()
+    assert user_request.status == "refused"
+    assert user_request.assigned_to == actor
+    on_commit.assert_called_once_with(
+        moderation_tasks.user_request_handle.delay,
+        user_request_id=user_request.pk,
+        new_status="refused",
+        old_status="pending",
+    )
diff --git a/api/tests/moderation/test_preferences.py b/api/tests/moderation/test_preferences.py
new file mode 100644
index 0000000000000000000000000000000000000000..74c350001e68a8db9f75ce798a8cf8835df80d8f
--- /dev/null
+++ b/api/tests/moderation/test_preferences.py
@@ -0,0 +1,52 @@
+import pytest
+
+from django.urls import reverse
+
+from funkwhale_api.common import preferences as common_preferences
+from funkwhale_api.common import utils as common_utils
+
+
+@pytest.mark.parametrize("value", [{"fields": {}}, {"fields": list(range(15))}])
+def test_get_serialized_preference_error(value, preferences):
+    pref_id = "moderation__signup_form_customization"
+
+    with pytest.raises(common_preferences.JSONSerializer.exception):
+        preferences[pref_id] = value
+
+
+@pytest.mark.parametrize(
+    "value",
+    [
+        {"fields": []},
+        {"help_text": {"text": "hello", "content_type": "text/markdown"}},
+        {"fields": [{"label": "Message", "required": True, "input_type": "long_text"}]},
+    ],
+)
+def test_get_serialized_preference(value, preferences):
+    pref_id = "moderation__signup_form_customization"
+
+    preferences[pref_id] = value
+    if "help_text" in value:
+        value["help_text"]["html"] = common_utils.render_html(
+            value["help_text"]["text"],
+            content_type=value["help_text"]["content_type"],
+            permissive=True,
+        )
+    assert preferences[pref_id] == value
+
+
+def test_update_via_api(superuser_api_client, preferences):
+    pref_id = "moderation__signup_form_customization"
+    url = reverse("api:v1:instance:admin-settings-bulk")
+    new_value = {
+        "help_text": {"text": "hello", "content_type": "text/markdown"},
+        "fields": [{"required": True, "label": "hello", "input_type": "short_text"}],
+    }
+    response = superuser_api_client.post(url, {pref_id: new_value}, format="json")
+    assert response.status_code == 200
+    new_value["help_text"]["html"] = common_utils.render_html(
+        new_value["help_text"]["text"],
+        content_type=new_value["help_text"]["content_type"],
+        permissive=True,
+    )
+    assert preferences[pref_id] == new_value
diff --git a/api/tests/moderation/test_tasks.py b/api/tests/moderation/test_tasks.py
index a4f2877998d42636456741a5b88f8f38151d1cc6..f51d8ff3bc665bd2a36c4a04820357be45fce163 100644
--- a/api/tests/moderation/test_tasks.py
+++ b/api/tests/moderation/test_tasks.py
@@ -46,3 +46,77 @@ def test_report_created_signal_sends_email_to_mods(factories, mailoutbox, settin
         assert detail_url in m.body
         assert unresolved_reports_url in m.body
         assert list(m.to) == [mod.email]
+
+
+def test_signup_request_pending_sends_email_to_mods(factories, mailoutbox, settings):
+    mod1 = factories["users.User"](permission_moderation=True)
+    mod2 = factories["users.User"](permission_moderation=True)
+
+    signup_request = factories["moderation.UserRequest"](signup=True)
+
+    tasks.user_request_handle(user_request_id=signup_request.pk, new_status="pending")
+
+    detail_url = federation_utils.full_url(
+        "/manage/moderation/requests/{}".format(signup_request.uuid)
+    )
+    unresolved_requests_url = federation_utils.full_url(
+        "/manage/moderation/requests?q=status:pending"
+    )
+    assert len(mailoutbox) == 2
+    for i, mod in enumerate([mod1, mod2]):
+        m = mailoutbox[i]
+        assert m.subject == "[{} moderation] New sign-up request from {}".format(
+            settings.FUNKWHALE_HOSTNAME, signup_request.submitter.preferred_username,
+        )
+        assert detail_url in m.body
+        assert unresolved_requests_url in m.body
+        assert list(m.to) == [mod.email]
+
+
+def test_approved_request_sends_email_to_submitter_and_set_active(
+    factories, mailoutbox, settings
+):
+    user = factories["users.User"](is_active=False)
+    actor = user.create_actor()
+    signup_request = factories["moderation.UserRequest"](
+        signup=True, submitter=actor, status="approved"
+    )
+
+    tasks.user_request_handle(user_request_id=signup_request.pk, new_status="approved")
+
+    user.refresh_from_db()
+
+    assert user.is_active is True
+    assert len(mailoutbox) == 1
+    m = mailoutbox[-1]
+    login_url = federation_utils.full_url("/login")
+    assert m.subject == "Welcome to {}, {}!".format(
+        settings.FUNKWHALE_HOSTNAME, signup_request.submitter.preferred_username,
+    )
+    assert login_url in m.body
+    assert list(m.to) == [user.email]
+
+
+def test_refused_request_sends_email_to_submitter(
+    factories, mailoutbox, settings, preferences
+):
+    preferences["instance__contact_email"] = "test@pod.example"
+    user = factories["users.User"](is_active=False)
+    actor = user.create_actor()
+    signup_request = factories["moderation.UserRequest"](
+        signup=True, submitter=actor, status="refused"
+    )
+
+    tasks.user_request_handle(user_request_id=signup_request.pk, new_status="refused")
+
+    user.refresh_from_db()
+
+    assert user.is_active is False
+
+    assert len(mailoutbox) == 1
+    m = mailoutbox[-1]
+    assert m.subject == "Your account request at {} was refused".format(
+        settings.FUNKWHALE_HOSTNAME,
+    )
+    assert "test@pod.example" in m.body
+    assert list(m.to) == [user.email]
diff --git a/api/tests/users/oauth/test_scopes.py b/api/tests/users/oauth/test_scopes.py
index 4943a8a1e1b8ab9e5bc0fd820f12487d14f28a2b..7261ac6b17a46734948381051bddc4ef4d157cbc 100644
--- a/api/tests/users/oauth/test_scopes.py
+++ b/api/tests/users/oauth/test_scopes.py
@@ -54,6 +54,8 @@ from funkwhale_api.users.oauth import scopes
                 "write:instance:notes",
                 "read:instance:reports",
                 "write:instance:reports",
+                "read:instance:requests",
+                "write:instance:requests",
             },
         ),
         (
@@ -99,6 +101,8 @@ from funkwhale_api.users.oauth import scopes
                 "write:instance:notes",
                 "read:instance:reports",
                 "write:instance:reports",
+                "read:instance:requests",
+                "write:instance:requests",
             },
         ),
         (
@@ -138,6 +142,8 @@ from funkwhale_api.users.oauth import scopes
                 "write:instance:notes",
                 "read:instance:reports",
                 "write:instance:reports",
+                "read:instance:requests",
+                "write:instance:requests",
             },
         ),
         (
diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py
index 3b5cf4cd369ee7edbb076c80d1c3480539c8ca85..2a453ebccd6e068f16fdb757987d1c591cc40406 100644
--- a/api/tests/users/test_views.py
+++ b/api/tests/users/test_views.py
@@ -3,6 +3,7 @@ from django.urls import reverse
 
 from funkwhale_api.common import serializers as common_serializers
 from funkwhale_api.common import utils as common_utils
+from funkwhale_api.moderation import tasks as moderation_tasks
 from funkwhale_api.users.models import User
 
 
@@ -415,3 +416,64 @@ def test_username_with_existing_local_account_are_invalid(
 
     assert response.status_code == 400
     assert "username" in response.data
+
+
+def test_signup_with_approval_enabled(preferences, factories, api_client, mocker):
+    url = reverse("rest_register")
+    data = {
+        "username": "test1",
+        "email": "test1@test.com",
+        "password1": "thisismypassword",
+        "password2": "thisismypassword",
+        "request_fields": {"field1": "Value 1", "field2": "Value 2", "noop": "Noop"},
+    }
+    preferences["users__registration_enabled"] = True
+    preferences["moderation__signup_approval_enabled"] = True
+    preferences["moderation__signup_form_customization"] = {
+        "fields": [
+            {"label": "field1", "input_type": "short_text"},
+            {"label": "field2", "input_type": "short_text"},
+        ]
+    }
+    on_commit = mocker.patch("funkwhale_api.common.utils.on_commit")
+    response = api_client.post(url, data, format="json")
+    assert response.status_code == 201
+    u = User.objects.get(email="test1@test.com")
+    assert u.username == "test1"
+    assert u.is_active is False
+    user_request = u.actor.requests.latest("id")
+    assert user_request.type == "signup"
+    assert user_request.status == "pending"
+    assert user_request.metadata == {
+        "field1": "Value 1",
+        "field2": "Value 2",
+    }
+
+    on_commit.assert_any_call(
+        moderation_tasks.user_request_handle.delay,
+        user_request_id=user_request.pk,
+        new_status="pending",
+    )
+
+
+def test_signup_with_approval_enabled_validation_error(
+    preferences, factories, api_client
+):
+    url = reverse("rest_register")
+    data = {
+        "username": "test1",
+        "email": "test1@test.com",
+        "password1": "thisismypassword",
+        "password2": "thisismypassword",
+        "request_fields": {"field1": "Value 1"},
+    }
+    preferences["users__registration_enabled"] = True
+    preferences["moderation__signup_approval_enabled"] = True
+    preferences["moderation__signup_form_customization"] = {
+        "fields": [
+            {"label": "field1", "input_type": "short_text"},
+            {"label": "field2", "input_type": "short_text"},
+        ]
+    }
+    response = api_client.post(url, data, format="json")
+    assert response.status_code == 400
diff --git a/changes/changelog.d/1040.feature b/changes/changelog.d/1040.feature
new file mode 100644
index 0000000000000000000000000000000000000000..ec905688bd93f8dc86954ee495a6176b181735e1
--- /dev/null
+++ b/changes/changelog.d/1040.feature
@@ -0,0 +1 @@
+Screening for sign-ups (#1040)
diff --git a/changes/notes.rst b/changes/notes.rst
index b1b9cc655eb2e384aa2ee847e601ec239eea61b3..044a739c75f07b17618b31d6817e942f275c380f 100644
--- a/changes/notes.rst
+++ b/changes/notes.rst
@@ -13,19 +13,23 @@ This release includes a full redesign of our navigation, player and queue. Overa
 a better, less confusing experience, especially on mobile devices. This redesign was suggested
 14 months ago, and took a while, but thanks to the involvement and feedback of many people, we got it done!
 
-Progressive web app [Manual change suggested, non-docker only]
-^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+Screening for sign-ups and custom sign-up form
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Instance admins can now configure their pod so that registrations required manual approval from a moderator. This
+is especially useful on private or semi-private pods where you don't want to close registrations completely,
+but don't want spam or unwanted users to join your pod.
+
+When this is enabled and a new user register, their request is put in a moderation queue, and moderators
+are notified by email. When the request is approved or refused, the user is also notified by email.
+
+In addition, it's also possible to customize the sign-up form by:
+
+- Providing a custom help text, in markdown format
+- Including additional fields in the form, for instance to ask the user why they want to join. Data collected through these fields is included in the sign-up request and viewable by the mods
 
-We've made Funkwhale's Web UI a Progressive Web Application (PWA), in order to improve the user experience
-during offline use, and on mobile devices.
 
-In order to fully benefit from this change, if your pod isn't deployed using Docker, ensure
-the following instruction is present in your nginx configuration::
 
-    location /front/ {
-        # Add the following line in the /front/ location
-        add_header Service-Worker-Allowed "/";
-    }
 
 Federated reports
 ^^^^^^^^^^^^^^^^^
@@ -63,6 +67,20 @@ All user-related commands are available under the ``python manage.py fw users``
 Please refer to the `Admin documentation <https://docs.funkwhale.audio/admin/commands.html#user-management>`_ for
 more information and instructions.
 
+Progressive web app [Manual change suggested, non-docker only]
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+We've made Funkwhale's Web UI a Progressive Web Application (PWA), in order to improve the user experience
+during offline use, and on mobile devices.
+
+In order to fully benefit from this change, if your pod isn't deployed using Docker, ensure
+the following instruction is present in your nginx configuration::
+
+    location /front/ {
+        # Add the following line in the /front/ location
+        add_header Service-Worker-Allowed "/";
+    }
+
 Postgres docker changed environment variable [manual action required, docker multi-container only]
 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
 
diff --git a/front/src/App.vue b/front/src/App.vue
index 648f4cb943aac3aa95979d2e9a4ba3ab3267bb25..b572b1bcd0c4263375a205f43a2022b34140adbe 100644
--- a/front/src/App.vue
+++ b/front/src/App.vue
@@ -133,6 +133,11 @@ export default {
       id: 'sidebarPendingReviewReportCount',
       handler: this.incrementPendingReviewReportsCountInSidebar
     })
+    this.$store.commit('ui/addWebsocketEventHandler', {
+      eventName: 'user_request.created',
+      id: 'sidebarPendingReviewRequestCount',
+      handler: this.incrementPendingReviewRequestsCountInSidebar
+    })
   },
   mounted () {
     let self = this
@@ -166,6 +171,10 @@ export default {
       eventName: 'mutation.updated',
       id: 'sidebarPendingReviewReportCount',
     })
+    this.$store.commit('ui/removeWebsocketEventHandler', {
+      eventName: 'user_request.created',
+      id: 'sidebarPendingReviewRequestCount',
+    })
     this.disconnect()
   },
   methods: {
@@ -178,6 +187,9 @@ export default {
     incrementPendingReviewReportsCountInSidebar (event) {
       this.$store.commit('ui/incrementNotifications', {type: 'pendingReviewReports', value: event.unresolved_count})
     },
+    incrementPendingReviewRequestsCountInSidebar (event) {
+      this.$store.commit('ui/incrementNotifications', {type: 'pendingReviewRequests', value: event.pending_count})
+    },
     async fetchNodeInfo () {
       let response = await axios.get('instance/nodeinfo/2.0/')
       this.$store.commit('instance/nodeinfo', response.data)
diff --git a/front/src/components/Sidebar.vue b/front/src/components/Sidebar.vue
index 177eb0ba312455b9ba0e121c491f64245c9a9dc6..732847ef56e15a47fdbe2ccc8a13d2cd45877d6e 100644
--- a/front/src/components/Sidebar.vue
+++ b/front/src/components/Sidebar.vue
@@ -17,8 +17,8 @@
             <div class="item ui inline admin-dropdown dropdown">
               <i class="wrench icon"></i>
               <div
-                v-if="$store.state.ui.notifications.pendingReviewEdits + $store.state.ui.notifications.pendingReviewReports > 0"
-                :class="['ui', 'teal', 'mini', 'bottom floating', 'circular', 'label']">{{ $store.state.ui.notifications.pendingReviewEdits + $store.state.ui.notifications.pendingReviewReports }}</div>
+                v-if="moderationNotifications > 0"
+                :class="['ui', 'teal', 'mini', 'bottom floating', 'circular', 'label']">{{ moderationNotifications }}</div>
               <div class="menu">
                 <div class="header">
                   <translate translate-context="Sidebar/Admin/Title/Noun">Administration</translate>
@@ -40,9 +40,9 @@
                   class="item"
                   :to="{name: 'manage.moderation.reports.list', query: {q: 'resolved:no'}}">
                   <div
-                    v-if="$store.state.ui.notifications.pendingReviewReports > 0"
+                    v-if="$store.state.ui.notifications.pendingReviewReports + $store.state.ui.notifications.pendingReviewRequests> 0"
                     :title="labels.pendingReviewReports"
-                    :class="['ui', 'circular', 'mini', 'right floated', 'teal', 'label']">{{ $store.state.ui.notifications.pendingReviewReports }}</div>
+                    :class="['ui', 'circular', 'mini', 'right floated', 'teal', 'label']">{{ $store.state.ui.notifications.pendingReviewReports + $store.state.ui.notifications.pendingReviewRequests }}</div>
                   <translate translate-context="*/Moderation/*">Moderation</translate>
                 </router-link>
                 <router-link
@@ -242,6 +242,13 @@ export default {
       } else {
         return 'exploreExpanded'
       }
+    },
+    moderationNotifications () {
+      return (
+        this.$store.state.ui.notifications.pendingReviewEdits +
+        this.$store.state.ui.notifications.pendingReviewReports +
+        this.$store.state.ui.notifications.pendingReviewRequests
+      )
     }
   },
   methods: {
diff --git a/front/src/components/admin/SettingsGroup.vue b/front/src/components/admin/SettingsGroup.vue
index cbc07d94913e92f3fcc4891f36c0cf1b486837a3..2362d046eb59bcd8bbd1e898d124a66487d71127 100644
--- a/front/src/components/admin/SettingsGroup.vue
+++ b/front/src/components/admin/SettingsGroup.vue
@@ -18,6 +18,11 @@
         <p v-if="setting.help_text">{{ setting.help_text }}</p>
       </template>
       <content-form v-if="setting.fieldType === 'markdown'" v-model="values[setting.identifier]" v-bind="setting.fieldParams" />
+      <signup-form-builder
+        v-else-if="setting.fieldType === 'formBuilder'"
+        :value="values[setting.identifier]"
+        :signup-approval-enabled="values.moderation__signup_approval_enabled"
+        @input="set(setting.identifier, $event)" />
       <input
         :id="setting.identifier"
         :name="setting.identifier"
@@ -82,11 +87,16 @@
 <script>
 import axios from 'axios'
 
+import lodash from '@/lodash'
+
 export default {
   props: {
     group: {type: Object, required: true},
     settingsData: {type: Array, required: true}
   },
+  components: {
+    SignupFormBuilder:  () => import(/* webpackChunkName: "signup-form-builder" */ "@/components/admin/SignupFormBuilder"),
+  },
   data () {
     return {
       values: {},
@@ -141,6 +151,11 @@ export default {
         self.isLoading = false
         self.errors = error.backendErrors
       })
+    },
+    set (key, value) {
+      // otherwise reactivity doesn't trigger :/
+      this.values = lodash.cloneDeep(this.values)
+      this.$set(this.values, key, value)
     }
   },
   computed: {
diff --git a/front/src/components/admin/SignupFormBuilder.vue b/front/src/components/admin/SignupFormBuilder.vue
new file mode 100644
index 0000000000000000000000000000000000000000..211c1e924132b0a7557838f4bd7ae8530c626aa2
--- /dev/null
+++ b/front/src/components/admin/SignupFormBuilder.vue
@@ -0,0 +1,191 @@
+<template>
+  <div>
+
+    <div class="ui top attached tabular menu">
+      <button :class="[{active: !isPreviewing}, 'item']" @click.stop.prevent="isPreviewing = false">
+        <translate translate-context="Content/*/Button.Label/Verb">Edit form</translate>
+      </button>
+      <button :class="[{active: isPreviewing}, 'item']" @click.stop.prevent="isPreviewing = true">
+        <translate translate-context="*/Form/Menu.item">Preview form</translate>
+      </button>
+    </div>
+    <div v-if="isPreviewing" class="ui bottom attached segment">
+      <signup-form
+        :customization="local"
+        :signup-approval-enabled="signupApprovalEnabled"
+        :fetch-description-html="true"></signup-form>
+      <div class="ui clearing hidden divider"></div>
+    </div>
+    <div v-else class="ui bottom attached segment">
+      <div class="field">
+        <label for="help-text">
+          <translate translate-context="*/*/Label">Help text</translate>
+        </label>
+        <p>
+          <translate translate-context="*/*/Help">An optional text to be displayed at the start of the sign-up form.</translate>
+        </p>
+        <content-form
+          field-id="help-text"
+          :permissive="true"
+          :value="(local.help_text || {}).text"
+          @input="update('help_text.text', $event)"></content-form>
+      </div>
+      <div class="field">
+        <label>
+          <translate translate-context="*/*/Label">Additional fields</translate>
+        </label>
+        <p>
+          <translate translate-context="*/*/Help">Additional form fields to be displayed in the form. Only shown if manual sign-up validation is enabled.</translate>
+        </p>
+        <table v-if="local.fields.length > 0">
+          <thead>
+            <tr>
+              <th>
+                <translate translate-context="*/*/Form-builder,Help">Field label</translate>
+              </th>
+              <th>
+                <translate translate-context="*/*/Form-builder,Help">Field type</translate>
+              </th>
+              <th>
+                <translate translate-context="*/*/Form-builder,Help">Required</translate>
+              </th>
+              <th></th>
+            </tr>
+          </thead>
+          <tbody>
+            <tr v-for="(field, idx) in local.fields">
+              <td>
+                <input type="text" v-model="field.label" required>
+              </td>
+              <td>
+                <select v-model="field.input_type">
+                  <option value="short_text">
+                    <translate translate-context="*/*/Form-builder">Short text</translate>
+                  </option>
+                  <option value="long_text">
+                    <translate translate-context="*/*/Form-builder">Long text</translate>
+                  </option>
+                </select>
+              </td>
+              <td>
+                <select v-model="field.required">
+                  <option :value="true">
+                    <translate translate-context="*/*/*">Yes</translate>
+                  </option>
+                  <option :value="false">
+                    <translate translate-context="*/*/*">No</translate>
+                  </option>
+                </select>
+              </td>
+              <td>
+                <i
+                  :disabled="idx === 0"
+                  @click="move(idx, -1)" rel="button"
+                  :title="labels.up"
+                  :class="['up', 'arrow', {disabled: idx === 0}, 'icon']"></i>
+                <i
+                  :disabled="idx >= local.fields.length - 1"
+                  @click="move(idx, 1)" rel="button"
+                  :title="labels.up"
+                  :class="['down', 'arrow', {disabled: idx >= local.fields.length - 1}, 'icon']"></i>
+                <i @click="remove(idx)" rel="button" :title="labels.delete" class="x icon"></i>
+              </td>
+            </tr>
+          </tbody>
+        </table>
+        <div class="ui hidden divider"></div>
+        <button v-if="local.fields.length < maxFields" class="ui basic button" @click.stop.prevent="addField">
+          <translate translate-context="*/*/Form-builder">Add a new field</translate>
+        </button>
+      </div>
+    </div>
+    <div class="ui hidden divider"></div>
+  </div>
+</template>
+
+<script>
+import lodash from '@/lodash'
+
+import SignupForm from "@/components/auth/SignupForm"
+
+function arrayMove(arr, oldIndex, newIndex) {
+  if (newIndex >= arr.length) {
+    var k = newIndex - arr.length + 1
+    while (k--) {
+      arr.push(undefined)
+    }
+  }
+  arr.splice(newIndex, 0, arr.splice(oldIndex, 1)[0])
+  return arr
+};
+
+// v-model with objects is complex, cf
+// https://simonkollross.de/posts/vuejs-using-v-model-with-objects-for-custom-components
+export default {
+  props: {
+    value: {type: Object},
+    signupApprovalEnabled: {type: Boolean},
+  },
+  components: {
+    SignupForm
+  },
+  data () {
+    return {
+      maxFields: 10,
+      isPreviewing: false
+    }
+  },
+  created () {
+    this.$emit('input', this.local)
+  },
+  computed: {
+    labels () {
+      return {
+        delete: this.$pgettext('*/*/*', 'Delete'),
+        up: this.$pgettext('*/*/*', 'Move up'),
+        down: this.$pgettext('*/*/*', 'Move down'),
+      }
+    },
+    local() {
+      return (this.value && this.value.fields) ? this.value : { help_text: {text: null, content_type: "text/markdown"}, fields: [] }
+    },
+  },
+  methods: {
+    addField () {
+      let newValue = lodash.tap(lodash.cloneDeep(this.local), v => v.fields.push({
+        label: this.$pgettext('*/*/Form-builder', 'Additional field') + ' ' + (this.local.fields.length + 1),
+        required: true,
+        input_type: 'short_text',
+      }))
+      this.$emit('input', newValue)
+    },
+    remove (idx) {
+      this.$emit('input', lodash.tap(lodash.cloneDeep(this.local), v => v.fields.splice(idx, 1)))
+    },
+    move (idx, incr) {
+      if (idx === 0 && incr < 0) {
+        return
+      }
+      if (idx + incr >= this.local.fields.length) {
+        return
+      }
+      let newFields = arrayMove(lodash.cloneDeep(this.local).fields, idx, idx + incr)
+      this.update('fields', newFields)
+    },
+    update(key, value) {
+      if (key === 'help_text.text') {
+        key = 'help_text'
+        if (!value || value.length === 0) {
+          value = null
+        } else {
+          value = {
+            text: value,
+            content_type: "text/markdown"
+          }
+        }
+      }
+      this.$emit('input', lodash.tap(lodash.cloneDeep(this.local), v => lodash.set(v, key, value)))
+    },
+  },
+}
+</script>
diff --git a/front/src/components/auth/LoginForm.vue b/front/src/components/auth/LoginForm.vue
index 6ba652c3e3c63e6ee411820ef5b34ba3be17c1d6..43e1b45a3423372c2073f2fff8b54685fd762e42 100644
--- a/front/src/components/auth/LoginForm.vue
+++ b/front/src/components/auth/LoginForm.vue
@@ -3,7 +3,12 @@
     <div v-if="error" class="ui negative message">
       <div class="header"><translate translate-context="Content/Login/Error message.Title">We cannot log you in</translate></div>
       <ul class="list">
-        <li v-if="error == 'invalid_credentials'"><translate translate-context="Content/Login/Error message.List item/Call to action">Please double-check your username/password couple is correct</translate></li>
+        <li v-if="error == 'invalid_credentials'">
+          <translate translate-context="Content/Login/Error message.List item/Call to action">Please double-check your username/password couple is correct</translate>
+        </li>
+        <li v-if="error == 'invalid_credentials' && $store.state.instance.settings.moderation.signup_approval_enabled.value">
+          <translate translate-context="Content/Login/Error message.List item/Call to action">If you signed-up recently, you may need to wait before our moderation team review your account</translate>
+        </li>
         <li v-else>{{ error }}</li>
       </ul>
     </div>
diff --git a/front/src/components/auth/SignupForm.vue b/front/src/components/auth/SignupForm.vue
index a9c60f6b988e69ec58916c8f26a17650dea9b00c..d78ac4351f948d661798ecb61e7ecb1e710d4bc1 100644
--- a/front/src/components/auth/SignupForm.vue
+++ b/front/src/components/auth/SignupForm.vue
@@ -1,18 +1,37 @@
 <template>
+  <div v-if="submitted">
+    <div class="ui success message">
+      <p v-if="signupRequiresApproval">
+        <translate translate-context="Content/Signup/Form/Paragraph">Your account request was successfully submitted. You will be notified by email when our moderation team has reviewed your request.</translate>
+      </p>
+      <p v-else>
+        <translate translate-context="Content/Signup/Form/Paragraph">Your account was successfully created. Please verify your email before trying to login.</translate>
+      </p>
+    </div>
+    <h2><translate translate-context="Content/Login/Title/Verb">Log in to your Funkwhale account</translate></h2>
+    <login-form button-classes="basic green" :show-signup="false"></login-form>
+  </div>
   <form
+    v-else
     :class="['ui', {'loading': isLoadingInstanceSetting}, 'form']"
     @submit.prevent="submit()">
     <p class="ui message" v-if="!$store.state.instance.settings.users.registration_enabled.value">
       <translate translate-context="Content/Signup/Form/Paragraph">Public registrations are not possible on this instance. You will need an invitation code to sign up.</translate>
     </p>
-
+    <p class="ui message" v-else-if="signupRequiresApproval">
+      <translate translate-context="Content/Signup/Form/Paragraph">Registrations on this pod are open, but reviewed by moderators before approval.</translate>
+    </p>
+    <template v-if="formCustomization && formCustomization.help_text">
+      <rendered-description :content="formCustomization.help_text" :fetch-html="fetchDescriptionHtml" :permissive="true"></rendered-description>
+      <div class="ui hidden divider"></div>
+    </template>
     <div v-if="errors.length > 0" class="ui negative message">
       <div class="header"><translate translate-context="Content/Signup/Form/Paragraph">Your account cannot be created.</translate></div>
       <ul class="list">
         <li v-for="error in errors">{{ error }}</li>
       </ul>
     </div>
-    <div class="field">
+    <div class="required field">
       <label><translate translate-context="Content/*/*">Username</translate></label>
       <input
       ref="username"
@@ -23,7 +42,7 @@
       :placeholder="labels.usernamePlaceholder"
       v-model="username">
     </div>
-    <div class="field">
+    <div class="required field">
       <label><translate translate-context="Content/*/*/Noun">Email</translate></label>
       <input
       ref="email"
@@ -33,11 +52,11 @@
       :placeholder="labels.emailPlaceholder"
       v-model="email">
     </div>
-    <div class="field">
+    <div class="required field">
       <label><translate translate-context="*/*/*">Password</translate></label>
       <password-input v-model="password" />
     </div>
-    <div class="field" v-if="!$store.state.instance.settings.users.registration_enabled.value">
+    <div class="required field" v-if="!$store.state.instance.settings.users.registration_enabled.value">
       <label><translate translate-context="Content/*/Input.Label">Invitation code</translate></label>
       <input
       required
@@ -46,6 +65,17 @@
       :placeholder="labels.placeholder"
       v-model="invitation">
     </div>
+    <template v-if="signupRequiresApproval && formCustomization && formCustomization.fields && formCustomization.fields.length > 0">
+      <div :class="[{required: field.required}, 'field']" v-for="(field, idx) in formCustomization.fields" :key="idx">
+        <label :for="`custom-field-${idx}`">{{ field.label }}</label>
+        <textarea
+          v-if="field.input_type === 'long_text'"
+          :value="customFields[field.label]"
+          :required="field.required"
+          @input="$set(customFields, field.label, $event.target.value)" rows="5"></textarea>
+        <input v-else type="text" :value="customFields[field.label]" :required="field.required" @input="$set(customFields, field.label, $event.target.value)">
+      </div>
+    </template>
     <button :class="['ui', buttonClasses, {'loading': isLoading}, ' right floated button']" type="submit">
       <translate translate-context="Content/Signup/Button.Label">Create my account</translate>
     </button>
@@ -56,6 +86,7 @@
 import axios from "axios"
 import logger from "@/logging"
 
+import LoginForm from "@/components/auth/LoginForm"
 import PasswordInput from "@/components/forms/PasswordInput"
 
 export default {
@@ -63,9 +94,14 @@ export default {
     defaultInvitation: { type: String, required: false, default: null },
     next: { type: String, default: "/" },
     buttonClasses: { type: String, default: "green" },
+    customization: { type: Object, default: null},
+    fetchDescriptionHtml: { type: Boolean, default: false},
+    fetchDescriptionHtml: { type: Boolean, default: false},
+    signupApprovalEnabled: {type: Boolean, default: null, required: false},
   },
   components: {
-    PasswordInput
+    LoginForm,
+    PasswordInput,
   },
   data() {
     return {
@@ -75,7 +111,9 @@ export default {
       isLoadingInstanceSetting: true,
       errors: [],
       isLoading: false,
-      invitation: this.defaultInvitation
+      invitation: this.defaultInvitation,
+      customFields: {},
+      submitted: false,
     }
   },
   created() {
@@ -99,6 +137,15 @@ export default {
         emailPlaceholder,
         placeholder
       }
+    },
+    formCustomization () {
+      return this.customization || this.$store.state.instance.settings.moderation.signup_form_customization.value
+    },
+    signupRequiresApproval () {
+      if (this.signupApprovalEnabled === null) {
+        return this.$store.state.instance.settings.moderation.signup_approval_enabled.value
+      }
+      return this.signupApprovalEnabled
     }
   },
   methods: {
@@ -111,17 +158,14 @@ export default {
         password1: this.password,
         password2: this.password,
         email: this.email,
-        invitation: this.invitation
+        invitation: this.invitation,
+        request_fields: this.customFields,
       }
       return axios.post("auth/registration/", payload).then(
         response => {
           logger.default.info("Successfully created account")
-          self.$router.push({
-            name: "profile.overview",
-            params: {
-              username: this.username
-            }
-          })
+          self.submitted = true
+          self.isLoading = false
         },
         error => {
           self.errors = error.backendErrors
diff --git a/front/src/components/common/ActorLink.vue b/front/src/components/common/ActorLink.vue
index ce2be46a294732ba3ca88217cd0aafaf00d65581..81199e7efe2792bbf9afc1a789a9d18b7d1d3420 100644
--- a/front/src/components/common/ActorLink.vue
+++ b/front/src/components/common/ActorLink.vue
@@ -1,6 +1,6 @@
 <template>
   <router-link :to="url" :title="actor.full_username">
-    <template v-if="avatar"><actor-avatar :actor="actor" />&nbsp;</template><slot>{{ repr | truncate(truncateLength) }}</slot>
+    <template v-if="avatar"><actor-avatar :actor="actor" /><span>&nbsp;</span></template><slot>{{ repr | truncate(truncateLength) }}</slot>
   </router-link>
 </template>
 
@@ -17,6 +17,9 @@ export default {
   },
   computed: {
     url () {
+      if (this.admin) {
+        return {name: 'manage.moderation.accounts.detail', params: {id: this.actor.full_username}}
+      }
       if (this.actor.is_local) {
         return {name: 'profile.overview', params: {username: this.actor.preferred_username}}
       } else {
@@ -24,7 +27,7 @@ export default {
       }
     },
     repr () {
-      if (this.displayName) {
+      if (this.displayName || this.actor.is_local) {
         return this.actor.preferred_username
       } else {
         return this.actor.full_username
diff --git a/front/src/components/common/RenderedDescription.vue b/front/src/components/common/RenderedDescription.vue
index 8d8e1db43269615c1faeaa5e310ea661e0b38962..92b2d080f1814ff2c99edabed15d718fe902cd34 100644
--- a/front/src/components/common/RenderedDescription.vue
+++ b/front/src/components/common/RenderedDescription.vue
@@ -1,6 +1,6 @@
 <template>
   <div>
-    <div v-html="content.html" v-if="content && !isUpdating"></div>
+    <div v-html="html" v-if="content && !isUpdating"></div>
     <p v-else-if="!isUpdating">
       <translate translate-context="*/*/Placeholder">No description available</translate>
     </p>
@@ -40,6 +40,9 @@ export default {
     fieldName: {required: false, default: 'description'},
     updateUrl: {required: false, type: String},
     canUpdate: {required: false, default: true, type: Boolean},
+    fetchHtml: {required: false, default: false, type: Boolean},
+    permissive: {required: false, default: false, type: Boolean},
+
   },
   data () {
     return {
@@ -48,9 +51,27 @@ export default {
       errors: null,
       isLoading: false,
       errors: [],
+      preview: null
+    }
+  },
+  async created () {
+    if (this.fetchHtml) {
+      await this.fetchPreview()
+    }
+  },
+  computed: {
+    html () {
+      if (this.fetchHtml) {
+        return this.preview
+      }
+      return this.content.html
     }
   },
   methods: {
+    async fetchPreview () {
+      let response = await axios.post('text-preview/', {text: this.content.text, permissive: this.permissive})
+      this.preview = response.data.rendered
+    },
     submit () {
       let self = this
       this.isLoading = true
diff --git a/front/src/components/manage/moderation/NoteForm.vue b/front/src/components/manage/moderation/NoteForm.vue
index eaf4d961742ca1da2b69fbea280087a0c3c07ed6..d4cc3355fe0fc20ac22a5a7a8d29eaad1a0e51b4 100644
--- a/front/src/components/manage/moderation/NoteForm.vue
+++ b/front/src/components/manage/moderation/NoteForm.vue
@@ -46,6 +46,7 @@ export default {
         target: this.target,
         summary: this.summary
       }
+      this.errors = []
       axios.post(`manage/moderation/notes/`, payload).then((response) => {
         self.$emit('created', response.data)
         self.summary = ''
diff --git a/front/src/components/manage/moderation/UserRequestCard.vue b/front/src/components/manage/moderation/UserRequestCard.vue
new file mode 100644
index 0000000000000000000000000000000000000000..8a67fa2cc40c0adf6301d443cb547247e035ed69
--- /dev/null
+++ b/front/src/components/manage/moderation/UserRequestCard.vue
@@ -0,0 +1,192 @@
+<template>
+  <div class="ui fluid user-request card">
+    <div class="content">
+      <div class="header">
+        <router-link :to="{name: 'manage.moderation.requests.detail', params: {id: obj.uuid}}">
+          <translate translate-context="Content/Moderation/Card/Short" :translate-params="{id: obj.uuid.substring(0, 8)}">Request %{ id }</translate>
+        </router-link>
+        <collapse-link class="right floated" v-model="isCollapsed"></collapse-link>
+      </div>
+      <div class="content">
+        <div class="ui hidden divider"></div>
+        <div class="ui stackable two column grid">
+          <div class="column">
+            <table class="ui very basic unstackable table">
+              <tbody>
+                <tr>
+                  <td>
+                    <translate translate-context="Content/Moderation/*">Submitted by</translate>
+                  </td>
+                  <td>
+                    <actor-link :admin="true" :actor="obj.submitter" />
+                  </td>
+                </tr>
+                <tr>
+                  <td>
+                    <translate translate-context="Content/*/*/Noun">Creation date</translate>
+                  </td>
+                  <td>
+                    <human-date :date="obj.creation_date" :icon="true"></human-date>
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+          <div class="column">
+            <table class="ui very basic unstackable table">
+              <tbody>
+                <tr>
+                  <td>
+                    <translate translate-context="*/*/*">Status</translate>
+                  </td>
+                  <td>
+                    <template v-if="obj.status === 'pending'">
+                      <i class="yellow hourglass icon"></i>
+                      <translate translate-context="Content/Library/*/Short">Pending</translate>
+                    </template>
+                    <template v-else-if="obj.status === 'refused'">
+                      <i class="red x icon"></i>
+                      <translate translate-context="Content/*/*/Short">Refused</translate>
+                    </template>
+                    <template v-else-if="obj.status === 'approved'">
+                      <i class="green check icon"></i>
+                      <translate translate-context="Content/*/*/Short">Approved</translate>
+                    </template>
+                  </td>
+                </tr>
+                <tr>
+                  <td>
+                    <translate translate-context="Content/Moderation/*">Assigned to</translate>
+                  </td>
+                  <td>
+                    <div v-if="obj.assigned_to">
+                      <actor-link :admin="true" :actor="obj.assigned_to" />
+                    </div>
+                    <translate v-else translate-context="*/*/*">N/A</translate>
+                  </td>
+                </tr>
+                <tr>
+                  <td>
+                    <translate translate-context="Content/*/*/Noun">Resolution date</translate>
+                  </td>
+                  <td>
+                    <human-date v-if="obj.handled_date" :date="obj.handled_date" :icon="true"></human-date>
+                    <translate v-else translate-context="*/*/*">N/A</translate>
+                  </td>
+                </tr>
+                <tr>
+                  <td>
+                    <translate translate-context="Content/*/*/Noun">Internal notes</translate>
+                  </td>
+                  <td>
+                    <i class="comment icon"></i>
+                    {{ obj.notes.length }}
+                  </td>
+                </tr>
+              </tbody>
+            </table>
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="main content" v-if="!isCollapsed">
+      <div class="ui stackable two column grid">
+        <div class="column">
+          <h3>
+            <translate translate-context="*/*/Field.Label/Noun">Message</translate>
+          </h3>
+          <p>
+            <translate translate-context="Content/Moderation/Paragraph">This user wants to sign-up on your pod.</translate>
+          </p>
+          <template v-if="obj.metadata">
+            <div class="ui hidden divider"></div>
+            <div v-for="k in Object.keys(obj.metadata)" :key="k">
+              <h4>{{ k }}</h4>
+              <p v-if="obj.metadata[k] && obj.metadata[k].length">{{ obj.metadata[k] }}</p>
+              <translate v-else translate-context="*/*/*">N/A</translate>
+              <div class="ui hidden divider"></div>
+            </div>
+          </template>
+        </div>
+        <aside class="column">
+          <div v-if="obj.status != 'approved'">
+            <h3>
+              <translate translate-context="Content/*/*/Noun">Actions</translate>
+            </h3>
+            <div class="ui labelled icon basic buttons">
+              <button
+                v-if="obj.status === 'pending' || obj.status === 'refused'"
+                @click="approve(true)"
+                :class="['ui', {loading: isLoading}, 'button']">
+                <i class="green check icon"></i>&nbsp;
+                <translate translate-context="Content/*/Button.Label/Verb">Approve</translate>
+              </button>
+              <button
+                v-if="obj.status === 'pending'"
+                @click="approve(false)"
+                :class="['ui', {loading: isLoading}, 'button']">
+                <i class="red x icon"></i>&nbsp;
+                <translate translate-context="Content/*/Button.Label">Refuse</translate>
+              </button>
+            </div>
+          </div>
+          <h3>
+            <translate translate-context="Content/*/*/Noun">Internal notes</translate>
+          </h3>
+          <notes-thread @deleted="handleRemovedNote($event)" :notes="obj.notes" />
+          <note-form @created="obj.notes.push($event)" :target="{type: 'request', uuid: obj.uuid}" />
+        </aside>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+import axios from 'axios'
+import NoteForm from '@/components/manage/moderation/NoteForm'
+import NotesThread from '@/components/manage/moderation/NotesThread'
+import {setUpdate} from '@/utils'
+import showdown from 'showdown'
+
+
+export default {
+  props: {
+    obj: {required: true},
+  },
+  components: {
+    NoteForm,
+    NotesThread,
+  },
+  data () {
+    return {
+      markdown: new showdown.Converter(),
+      isLoading: false,
+      isCollapsed: false,
+    }
+  },
+  methods: {
+    approve (v) {
+      let url = `manage/moderation/requests/${this.obj.uuid}/`
+      let self = this
+      let newStatus = v ? 'approved' : 'refused'
+      this.isLoading = true
+      axios.patch(url, {status: newStatus}).then((response) => {
+        self.$emit('handled', newStatus)
+        self.isLoading = false
+        self.obj.status = newStatus
+        if (v) {
+          self.isCollapsed = true
+        }
+        self.$store.commit('ui/incrementNotifications', {count: -1, type: 'pendingReviewRequests'})
+      }, error => {
+        self.isLoading = false
+      })
+    },
+    handleRemovedNote (uuid) {
+      this.obj.notes = this.obj.notes.filter((note) => {
+        return note.uuid != uuid
+      })
+    },
+  }
+}
+</script>
diff --git a/front/src/lodash.js b/front/src/lodash.js
index d6ec0c23c953400df57ed2fc4916dc6c00c57aec..91bebd2e8d8a8cbe41c50d881299ed743cdd9b88 100644
--- a/front/src/lodash.js
+++ b/front/src/lodash.js
@@ -2,9 +2,11 @@
 
 export default {
   clone: require('lodash/clone'),
+  cloneDeep: require('lodash/cloneDeep'),
   keys: require('lodash/keys'),
   debounce: require('lodash/debounce'),
   get: require('lodash/get'),
+  set: require('lodash/set'),
   merge: require('lodash/merge'),
   range: require('lodash/range'),
   shuffle: require('lodash/shuffle'),
@@ -16,5 +18,6 @@ export default {
   isEqual: require('lodash/isEqual'),
   sum: require('lodash/sum'),
   startCase: require('lodash/startCase'),
+  tap: require('lodash/tap'),
   trim: require('lodash/trim'),
 }
diff --git a/front/src/router/index.js b/front/src/router/index.js
index 628ee7d72fc2f0689b1f42d8072ee6fda1303e94..71ef0c86cefc9a9233a1622163f7f84db8d93d82 100644
--- a/front/src/router/index.js
+++ b/front/src/router/index.js
@@ -498,6 +498,29 @@ export default new Router({
             ),
           props: true
         },
+        {
+          path: "requests",
+          name: "manage.moderation.requests.list",
+          component: () =>
+            import(
+              /* webpackChunkName: "admin" */ "@/views/admin/moderation/RequestsList"
+            ),
+          props: route => {
+            return {
+              defaultQuery: route.query.q,
+              updateUrl: true
+            }
+          }
+        },
+        {
+          path: "requests/:id",
+          name: "manage.moderation.requests.detail",
+          component: () =>
+            import(
+              /* webpackChunkName: "admin" */ "@/views/admin/moderation/RequestDetail"
+            ),
+          props: true
+        },
       ]
     },
     {
diff --git a/front/src/store/auth.js b/front/src/store/auth.js
index f11f8ee7faf1664b5b1ea65e1abd03d09e421a40..9163d2482c1ea80e1f2979f80dc25b2b8df908f7 100644
--- a/front/src/store/auth.js
+++ b/front/src/store/auth.js
@@ -140,6 +140,7 @@ export default {
           }
           if (response.data.permissions.moderation) {
             dispatch('ui/fetchPendingReviewReports', null, { root: true })
+            dispatch('ui/fetchPendingReviewRequests', null, { root: true })
           }
           dispatch('favorites/fetch', null, { root: true })
           dispatch('channels/fetchSubscriptions', null, { root: true })
diff --git a/front/src/store/instance.js b/front/src/store/instance.js
index b98dc54a65a699227f5b33276a5c8ca7c4fc6788..5d70688dcd1cc2e553d8b508b3d3575eafb1eb50 100644
--- a/front/src/store/instance.js
+++ b/front/src/store/instance.js
@@ -50,6 +50,12 @@ export default {
           value: 0
         }
       },
+      moderation: {
+        signup_approval_enabled: {
+          value: false,
+        },
+        signup_form_customization: {value: null}
+      },
       subsonic: {
         enabled: {
           value: true
diff --git a/front/src/store/ui.js b/front/src/store/ui.js
index 2a8515ab95dca0e49ccb38305f4276cf82ede113..0c16f2b451b2e93f2c5e5973ffca80cbde409957 100644
--- a/front/src/store/ui.js
+++ b/front/src/store/ui.js
@@ -22,6 +22,7 @@ export default {
       inbox: 0,
       pendingReviewEdits: 0,
       pendingReviewReports: 0,
+      pendingReviewRequests: 0,
     },
     websocketEventsHandlers: {
       'inbox.item_added': {},
@@ -29,6 +30,7 @@ export default {
       'mutation.created': {},
       'mutation.updated': {},
       'report.created': {},
+      'user_request.created': {},
     },
     pageTitle: null,
     routePreferences: {
@@ -97,6 +99,16 @@ export default {
         orderingDirection: "-",
         ordering: "creation_date",
       },
+      "manage.moderation.requests.list": {
+        paginateBy: 25,
+        orderingDirection: "-",
+        ordering: "creation_date",
+      },
+      "manage.moderation.reports.list": {
+        paginateBy: 25,
+        orderingDirection: "-",
+        ordering: "creation_date",
+      },
     },
     serviceWorker: {
       refreshing: false,
@@ -260,6 +272,11 @@ export default {
         commit('notifications', {type: 'pendingReviewReports', count: response.data.count})
       })
     },
+    fetchPendingReviewRequests ({commit, rootState}, payload) {
+      axios.get('manage/moderation/requests/', {params: {status: 'pending', page_size: 1}}).then((response) => {
+        commit('notifications', {type: 'pendingReviewRequests', count: response.data.count})
+      })
+    },
     websocketEvent ({state}, event) {
       let handlers = state.websocketEventsHandlers[event.type]
       console.log('Dispatching websocket event', event, handlers)
diff --git a/front/src/views/admin/Settings.vue b/front/src/views/admin/Settings.vue
index 7f40a00e3895fa90ce09ebfc627f29c459f6458b..4381e07acd5af9ce887b33b2714ae976fee3e4d6 100644
--- a/front/src/views/admin/Settings.vue
+++ b/front/src/views/admin/Settings.vue
@@ -78,7 +78,8 @@ export default {
     groups() {
       // somehow, extraction fails if in the return block directly
       let instanceLabel = this.$pgettext('Content/Admin/Menu','Instance information')
-      let usersLabel = this.$pgettext('*/*/*/Noun', 'Users')
+      let signupsLabel = this.$pgettext('*/*/*/Noun', 'Sign-ups')
+      let securityLabel = this.$pgettext('*/*/*/Noun', 'Security')
       let musicLabel = this.$pgettext('*/*/*/Noun', 'Music')
       let channelsLabel = this.$pgettext('*/*/*', 'Channels')
       let playlistsLabel = this.$pgettext('*/*/*', 'Playlists')
@@ -104,10 +105,18 @@ export default {
           ]
         },
         {
-          label: usersLabel,
-          id: "users",
+          label: signupsLabel,
+          id: "signup",
           settings: [
             {name: "users__registration_enabled"},
+            {name: "moderation__signup_approval_enabled"},
+            {name: "moderation__signup_form_customization", fieldType: 'formBuilder'},
+          ]
+        },
+        {
+          label: securityLabel,
+          id: "security",
+          settings: [
             {name: "common__api_authentication_required"},
             {name: "users__default_permissions"},
             {name: "users__upload_quota"},
diff --git a/front/src/views/admin/moderation/AccountsDetail.vue b/front/src/views/admin/moderation/AccountsDetail.vue
index e9fd06bea997af10f48bfe27e55be0813dd53fcf..e8f65dca818dcefe32f11fb6565d7c019ffd70e4 100644
--- a/front/src/views/admin/moderation/AccountsDetail.vue
+++ b/front/src/views/admin/moderation/AccountsDetail.vue
@@ -274,6 +274,16 @@
                       {{ stats.reports }}
                     </td>
                   </tr>
+                  <tr>
+                    <td>
+                      <router-link :to="{name: 'manage.moderation.requests.list', query: {q: getQuery('submitter', `${object.full_username}`) }}">
+                        <translate translate-context="Content/Moderation/Table.Label/Noun">Requests</translate>
+                      </router-link>
+                    </td>
+                    <td>
+                      {{ stats.requests }}
+                    </td>
+                  </tr>
                 </tbody>
               </table>
             </section>
diff --git a/front/src/views/admin/moderation/Base.vue b/front/src/views/admin/moderation/Base.vue
index 04753cd364ffb9b14e68c93c8a72f6e8deecacc8..e8414ed2cb38beb29e8f92e390ed3ec78273a35b 100644
--- a/front/src/views/admin/moderation/Base.vue
+++ b/front/src/views/admin/moderation/Base.vue
@@ -3,7 +3,20 @@
     <nav class="ui secondary pointing menu" role="navigation" :aria-label="labels.secondaryMenu">
       <router-link
         class="ui item"
-        :to="{name: 'manage.moderation.reports.list'}"><translate translate-context="*/Moderation/*/Noun">Reports</translate></router-link>
+        :to="{name: 'manage.moderation.reports.list', query: {q: 'resolved:no'}}">
+        <translate translate-context="*/Moderation/*/Noun">Reports</translate>
+        <div
+          v-if="$store.state.ui.notifications.pendingReviewReports > 0"
+          :class="['ui', 'circular', 'mini', 'right floated', 'teal', 'label']">{{ $store.state.ui.notifications.pendingReviewReports }}</div>
+      </router-link>
+      <router-link
+        class="ui item"
+        :to="{name: 'manage.moderation.requests.list', query: {q: 'status:pending'}}">
+        <translate translate-context="*/Moderation/*/Noun">User Requests</translate>
+        <div
+          v-if="$store.state.ui.notifications.pendingReviewRequests > 0"
+          :class="['ui', 'circular', 'mini', 'right floated', 'teal', 'label']">{{ $store.state.ui.notifications.pendingReviewRequests }}</div>
+      </router-link>
       <router-link
         class="ui item"
         :to="{name: 'manage.moderation.domains.list'}"><translate translate-context="*/Moderation/*/Noun">Domains</translate></router-link>
diff --git a/front/src/views/admin/moderation/RequestDetail.vue b/front/src/views/admin/moderation/RequestDetail.vue
new file mode 100644
index 0000000000000000000000000000000000000000..b8f9e57c9b634423c18c3b8af6e4b48377cd3d51
--- /dev/null
+++ b/front/src/views/admin/moderation/RequestDetail.vue
@@ -0,0 +1,46 @@
+<template>
+  <main>
+    <div v-if="isLoading" class="ui vertical segment">
+      <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
+    </div>
+    <template v-if="object">
+
+      <div class="ui vertical stripe segment">
+        <user-request-card :obj="object"></user-request-card>
+      </div>
+    </template>
+  </main>
+</template>
+
+<script>
+import axios from "axios"
+
+import UserRequestCard from "@/components/manage/moderation/UserRequestCard"
+
+export default {
+  props: ["id"],
+  components: {
+    UserRequestCard,
+  },
+  data() {
+    return {
+      isLoading: true,
+      object: null,
+    }
+  },
+  created() {
+    this.fetchData()
+  },
+  methods: {
+    fetchData() {
+      var self = this
+      this.isLoading = true
+      let url = `manage/moderation/requests/${this.id}/`
+      axios.get(url).then(response => {
+        self.object = response.data
+        self.isLoading = false
+      })
+    },
+  },
+}
+</script>
diff --git a/front/src/views/admin/moderation/RequestsList.vue b/front/src/views/admin/moderation/RequestsList.vue
new file mode 100644
index 0000000000000000000000000000000000000000..8d38371c342a15b33710a9a2e975681d4449f530
--- /dev/null
+++ b/front/src/views/admin/moderation/RequestsList.vue
@@ -0,0 +1,168 @@
+<template>
+  <main v-title="labels.accounts">
+    <section class="ui vertical stripe segment">
+      <h2 class="ui header"><translate translate-context="*/Moderation/*/Noun">User Requests</translate></h2>
+      <div class="ui hidden divider"></div>
+      <div class="ui inline form">
+        <div class="fields">
+          <div class="ui field">
+            <label><translate translate-context="Content/Search/Input.Label/Noun">Search</translate></label>
+            <form @submit.prevent="search.query = $refs.search.value">
+              <input name="search" ref="search" type="text" :value="search.query" :placeholder="labels.searchPlaceholder" />
+            </form>
+          </div>
+          <div class="field">
+            <label><translate translate-context="*/*/*">Status</translate></label>
+            <select class="ui dropdown" @change="addSearchToken('status', $event.target.value)" :value="getTokenValue('status', '')">
+              <option value="">
+                <translate translate-context="Content/*/Dropdown">All</translate>
+              </option>
+              <option value="pending">
+                <translate translate-context="Content/Library/*/Short">Pending</translate>
+              </option>
+              <option value="approved">
+                <translate translate-context="Content/*/*/Short">Approved</translate>
+              </option>
+              <option value="refused">
+                <translate translate-context="Content/*/*/Short">Refused</translate>
+              </option>
+            </select>
+          </div>
+          <div class="field">
+            <label><translate translate-context="Content/Search/Dropdown.Label/Noun">Ordering</translate></label>
+            <select class="ui dropdown" v-model="ordering">
+              <option v-for="option in orderingOptions" :value="option[0]">
+                {{ sharedLabels.filters[option[1]] }}
+              </option>
+            </select>
+          </div>
+          <div class="field">
+            <label><translate translate-context="Content/Search/Dropdown.Label/Noun">Order</translate></label>
+            <select class="ui dropdown" v-model="orderingDirection">
+              <option value="+"><translate translate-context="Content/Search/Dropdown">Ascending</translate></option>
+              <option value="-"><translate translate-context="Content/Search/Dropdown">Descending</translate></option>
+            </select>
+          </div>
+        </div>
+      </div>
+      <div v-if="isLoading" class="ui active inverted dimmer">
+        <div class="ui loader"></div>
+      </div>
+      <div v-else-if="!result || result.count === 0">
+        <empty-state @refresh="fetchData()" :refresh="true"></empty-state>
+      </div>
+      <template v-else>
+        <user-request-card @handled="fetchData" :obj="obj" v-for="obj in result.results" :key="obj.uuid" />
+        <div class="ui center aligned basic segment">
+          <pagination
+            v-if="result.count > paginateBy"
+            @page-changed="selectPage"
+            :current="page"
+            :paginate-by="paginateBy"
+            :total="result.count"
+            ></pagination>
+        </div>
+
+      </template>
+    </section>
+  </main>
+</template>
+
+<script>
+
+
+import axios from 'axios'
+import _ from '@/lodash'
+import time from '@/utils/time'
+import Pagination from '@/components/Pagination'
+import OrderingMixin from '@/components/mixins/Ordering'
+import TranslationsMixin from '@/components/mixins/Translations'
+import UserRequestCard from '@/components/manage/moderation/UserRequestCard'
+import {normalizeQuery, parseTokens} from '@/search'
+import SmartSearchMixin from '@/components/mixins/SmartSearch'
+
+
+export default {
+  mixins: [OrderingMixin, TranslationsMixin, SmartSearchMixin],
+  components: {
+    Pagination,
+    UserRequestCard,
+  },
+  data () {
+    return {
+      time,
+      isLoading: false,
+      result: null,
+      page: 1,
+      search: {
+        query: this.defaultQuery,
+        tokens: parseTokens(normalizeQuery(this.defaultQuery))
+      },
+      orderingOptions: [
+        ['creation_date', 'creation_date'],
+        ['handled_date', 'handled_date'],
+      ],
+      targets: {
+        track: {}
+      }
+    }
+  },
+  created () {
+    this.fetchData()
+  },
+  methods: {
+    fetchData () {
+      let params = _.merge({
+        'page': this.page,
+        'page_size': this.paginateBy,
+        'q': this.search.query,
+        'ordering': this.getOrderingAsString()
+      }, this.filters)
+      let self = this
+      self.isLoading = true
+      this.result = null
+      axios.get('manage/moderation/requests/', {params: params}).then((response) => {
+        self.result = response.data
+        self.isLoading = false
+        if (self.search.query === 'status:pending') {
+          self.$store.commit('ui/incrementNotifications', {type: 'pendingReviewRequests', value: response.data.count})
+        }
+      }, error => {
+        self.isLoading = false
+        self.errors = error.backendErrors
+      })
+    },
+    selectPage: function (page) {
+      this.page = page
+    },
+  },
+  computed: {
+    labels () {
+      return {
+        searchPlaceholder: this.$pgettext('Content/Search/Input.Placeholder', 'Search by username…'),
+        reports: this.$pgettext('*/Moderation/*/Noun', "User Requests"),
+      }
+    },
+  },
+  watch: {
+    search (newValue) {
+      this.page = 1
+      this.fetchData()
+    },
+    page () {
+      this.fetchData()
+    },
+    ordering () {
+      this.fetchData()
+    },
+    orderingDirection () {
+      this.fetchData()
+    }
+  }
+}
+
+</script>
+
+<!-- Add "scoped" attribute to limit CSS to this component only -->
+<style scoped>
+</style>