diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py index 470972135..58c16ac1a 100644 --- a/api/funkwhale_api/users/serializers.py +++ b/api/funkwhale_api/users/serializers.py @@ -6,6 +6,7 @@ from django.utils.translation import gettext_lazy as _ from django.contrib import auth +from allauth.account import models as allauth_models from rest_auth.serializers import PasswordResetSerializer as PRS from rest_auth.registration.serializers import RegisterSerializer as RS, get_adapter from rest_framework import serializers @@ -288,3 +289,29 @@ class LoginSerializer(serializers.Serializer): def save(self, request): return auth.login(request, self.validated_data) + + +class UserChangeEmailSerializer(serializers.Serializer): + password = serializers.CharField() + email = serializers.EmailField() + + def validate_password(self, value): + if not self.instance.check_password(value): + raise serializers.ValidationError("Invalid password") + + def validate_email(self, value): + if ( + allauth_models.EmailAddress.objects.filter(email__iexact=value) + .exclude(user=self.context["user"]) + .exists() + ): + raise serializers.ValidationError("This email address is already in use") + return value + + def save(self, request): + current, _ = allauth_models.EmailAddress.objects.get_or_create( + user=request.user, + email=request.user.email, + defaults={"verified": False, "primary": True}, + ) + current.change(request, self.validated_data["email"], confirm=True) diff --git a/api/funkwhale_api/users/views.py b/api/funkwhale_api/users/views.py index 5a8b2b07f..f177bb1e4 100644 --- a/api/funkwhale_api/users/views.py +++ b/api/funkwhale_api/users/views.py @@ -111,6 +111,22 @@ class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet): data = {"subsonic_api_token": self.request.user.subsonic_api_token} return Response(data) + @action( + methods=["post"], + required_scope="security", + url_path="change-email", + detail=False, + ) + def change_email(self, request, *args, **kwargs): + if not self.request.user.is_authenticated: + return Response(status=403) + serializer = serializers.UserChangeEmailSerializer( + request.user, data=request.data, context={"user": request.user} + ) + serializer.is_valid(raise_exception=True) + serializer.save(request) + return Response(status=204) + def update(self, request, *args, **kwargs): if not self.request.user.username == kwargs.get("username"): return Response(status=403) diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py index 691ed5cfe..9b30c78bb 100644 --- a/api/tests/users/test_views.py +++ b/api/tests/users/test_views.py @@ -568,3 +568,26 @@ def test_update_settings(logged_in_api_client, factories): logged_in_api_client.user.refresh_from_db() assert logged_in_api_client.user.settings == {"foo": "bar", "theme": "dark"} + + +def test_user_change_email_requires_valid_password(logged_in_api_client): + url = reverse("api:v1:users:users-change-email") + payload = {"password": "invalid", "email": "test@new.email"} + response = logged_in_api_client.post(url, payload) + + assert response.status_code == 400 + + +def test_user_change_email(logged_in_api_client, mocker, mailoutbox): + user = logged_in_api_client.user + user.set_password("mypassword") + url = reverse("api:v1:users:users-change-email") + payload = {"password": "mypassword", "email": "test@new.email"} + response = logged_in_api_client.post(url, payload) + + address = user.emailaddress_set.latest("id") + + assert address.email == payload["email"] + assert address.verified is False + assert response.status_code == 204 + assert len(mailoutbox) == 1 diff --git a/changes/changelog.d/292.enhancement b/changes/changelog.d/292.enhancement new file mode 100644 index 000000000..8da9e9a13 --- /dev/null +++ b/changes/changelog.d/292.enhancement @@ -0,0 +1 @@ +Users can now update their email address (#292) \ No newline at end of file diff --git a/docs/swagger.yml b/docs/swagger.yml index e4b5563d4..0c3a0b1d9 100644 --- a/docs/swagger.yml +++ b/docs/swagger.yml @@ -310,6 +310,53 @@ paths: application/json: schema: $ref: "./api/definitions.yml#/Me" + delete: + summary: Delete the user account performing the request + tags: + - "Auth and security" + requestBody: + required: true + content: + application/json: + schema: + type: "object" + properties: + confirm: + type: "boolean" + description: "Must be set to true, to avoid accidental deletion" + password: + type: "string" + description: "The current password of the account" + responses: + 200: + content: + application/json: + schema: + $ref: "./api/definitions.yml#/Me" + /api/v1/users/users/change-email/: + post: + summary: Update the email address associated with a user account + tags: + - "Auth and security" + requestBody: + required: true + content: + application/json: + schema: + type: "object" + properties: + email: + type: "string" + format: "email" + password: + type: "string" + description: "The current password of the account" + responses: + 200: + content: + application/json: + schema: + $ref: "./api/definitions.yml#/Me" /api/v1/rate-limit/: get: diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue index f07d0dcf3..41c60fc44 100644 --- a/front/src/components/auth/Settings.vue +++ b/front/src/components/auth/Settings.vue @@ -270,6 +270,38 @@ Manage plugins + + + + + + Change my email address + + + + Change the email address associated with your account. We will send a confirmation to the new address. + + + Your current email address is %{ email }. + + + + We cannot change your email address + + {{ error }} + + + + New email + + + + Password + + + Update + + @@ -339,6 +371,10 @@ export default { isLoading: false, isLoadingAvatar: false, isDeletingAccount: false, + changeEmailErrors: [], + isChangingEmail: false, + newEmail: null, + emailPassword: null, accountDeleteErrors: [], avatarErrors: [], apps: [], @@ -519,6 +555,33 @@ export default { } ) }, + + changeEmail() { + this.isChangingEmail = true + this.changeEmailErrors = [] + let self = this + let payload = { + password: this.emailPassword, + email: this.newEmail, + } + axios.post(`users/users/change-email/`, payload) + .then( + response => { + self.isChangingEmail = false + self.newEmail = null + self.emailPassword = null + let msg = self.$pgettext('*/Auth/Message', 'Your email has been changed, please check your inbox for our confirmation message.') + self.$store.commit('ui/addMessage', { + content: msg, + date: new Date() + }) + }, + error => { + self.isChangingEmail = false + self.changeEmailErrors = error.backendErrors + } + ) + }, }, computed: { labels() {
+ Change the email address associated with your account. We will send a confirmation to the new address. +
+ Your current email address is %{ email }. +