diff --git a/api/funkwhale_api/users/serializers.py b/api/funkwhale_api/users/serializers.py index 4709721355ee42e21408e6502ee2a9c8d58780c0..58c16ac1a88db237409c5299d86e70fe70f15265 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 5a8b2b07f99a9c5fe0a2d7e6d199e3fbd588fd9e..f177bb1e409b1d7a44e8efa12cb437e2554054ea 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 691ed5cfe97a7bf2aa88e0fda895493c900f50df..9b30c78bb4c0301daa526610e73ccb02885bf831 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 0000000000000000000000000000000000000000..8da9e9a13dffbe03c59d5b1412f97289d634a7f7 --- /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 e4b5563d462cf403971d03438aae6d1792cae357..0c3a0b1d98598d9929b0c4a12133ae2506a13a1a 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 f07d0dcf347de11c8a22d8569c7083dc9a5ef631..41c60fc44cfe1bef0a72d55a082fa84147486a60 100644 --- a/front/src/components/auth/Settings.vue +++ b/front/src/components/auth/Settings.vue @@ -270,6 +270,38 @@ <translate translate-context="Content/Settings/Button.Label">Manage plugins</translate> </router-link> </section> + <section class="ui text container"> + <div class="ui hidden divider"></div> + <h2 class="ui header"> + <i class="comment icon"></i> + <div class="content"> + <translate translate-context="*/*/Button.Label">Change my email address</translate> + </div> + </h2> + <p> + <translate translate-context="Content/Settings/Paragraph'">Change the email address associated with your account. We will send a confirmation to the new address.</translate> + </p> + <p> + <translate :translate-params="{email: $store.state.auth.profile.email}" translate-context="Content/Settings/Paragraph'">Your current email address is %{ email }.</translate> + </p> + <form class="ui form" @submit.prevent="changeEmail"> + <div v-if="changeEmailErrors.length > 0" role="alert" class="ui negative message"> + <h4 class="header"><translate translate-context="Content/Settings/Error message.Title">We cannot change your email address</translate></h4> + <ul class="list"> + <li v-for="error in changeEmailErrors">{{ error }}</li> + </ul> + </div> + <div class="field"> + <label for="new-email"><translate translate-context="*/*/*">New email</translate></label> + <input id="new-email" required v-model="newEmail" type="email" /> + </div> + <div class="field"> + <label for="current-password-field-email"><translate translate-context="*/*/*">Password</translate></label> + <password-input field-id="current-password-field-email" required v-model="emailPassword" /> + </div> + <button type="submit" class="ui button"><translate translate-context="*/*/*">Update</translate></button> + </form> + </section> <section class="ui text container"> <div class="ui hidden divider"></div> <h2 class="ui header"> @@ -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() {