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" /> </template><slot>{{ repr | truncate(truncateLength) }}</slot> + <template v-if="avatar"><actor-avatar :actor="actor" /><span> </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> + <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> + <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>