From 24cb1d95191d3bc83278ff30da37c2b4b2a35534 Mon Sep 17 00:00:00 2001 From: Eliot Berriot <contact@eliotberriot.com> Date: Wed, 9 May 2018 22:18:33 +0200 Subject: [PATCH] See #75: user can now manage the Subsonic API token from their settings page --- api/funkwhale_api/users/views.py | 26 +++- api/tests/subsonic/test_views.py | 7 + api/tests/users/test_views.py | 71 +++++++++ front/src/components/auth/Settings.vue | 27 +++- .../src/components/auth/SubsonicTokenForm.vue | 137 ++++++++++++++++++ 5 files changed, 264 insertions(+), 4 deletions(-) create mode 100644 front/src/components/auth/SubsonicTokenForm.vue diff --git a/api/funkwhale_api/users/views.py b/api/funkwhale_api/users/views.py index 7c58363a..0cc31788 100644 --- a/api/funkwhale_api/users/views.py +++ b/api/funkwhale_api/users/views.py @@ -1,11 +1,13 @@ from rest_framework.response import Response from rest_framework import mixins from rest_framework import viewsets -from rest_framework.decorators import list_route +from rest_framework.decorators import detail_route, list_route from rest_auth.registration.views import RegisterView as BaseRegisterView from allauth.account.adapter import get_adapter +from funkwhale_api.common import preferences + from . import models from . import serializers @@ -37,6 +39,28 @@ class UserViewSet( serializer = serializers.UserReadSerializer(request.user) return Response(serializer.data) + @detail_route( + methods=['get', 'post', 'delete'], url_path='subsonic-token') + def subsonic_token(self, request, *args, **kwargs): + if not self.request.user.username == kwargs.get('username'): + return Response(status=403) + if not preferences.get('subsonic__enabled'): + return Response(status=405) + if request.method.lower() == 'get': + return Response({ + 'subsonic_api_token': self.request.user.subsonic_api_token + }) + if request.method.lower() == 'delete': + self.request.user.subsonic_api_token = None + self.request.user.save(update_fields=['subsonic_api_token']) + return Response(status=204) + self.request.user.update_subsonic_api_token() + self.request.user.save(update_fields=['subsonic_api_token']) + data = { + 'subsonic_api_token': self.request.user.subsonic_api_token + } + return Response(data) + def update(self, request, *args, **kwargs): if not self.request.user.username == kwargs.get('username'): return Response(status=403) diff --git a/api/tests/subsonic/test_views.py b/api/tests/subsonic/test_views.py index b69be0d4..bd445e07 100644 --- a/api/tests/subsonic/test_views.py +++ b/api/tests/subsonic/test_views.py @@ -45,6 +45,13 @@ def test_exception_wrong_credentials(f, db, api_client): assert response.data == expected +def test_disabled_subsonic(preferences, api_client): + preferences['subsonic__enabled'] = False + url = reverse('api:subsonic-ping') + response = api_client.get(url) + assert response.status_code == 405 + + @pytest.mark.parametrize('f', ['xml', 'json']) def test_get_license(f, db, logged_in_api_client, mocker): url = reverse('api:subsonic-get-license') diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py index 985a78c8..fffc762f 100644 --- a/api/tests/users/test_views.py +++ b/api/tests/users/test_views.py @@ -167,6 +167,77 @@ def test_user_can_patch_his_own_settings(logged_in_api_client): assert user.privacy_level == 'me' +def test_user_can_request_new_subsonic_token(logged_in_api_client): + user = logged_in_api_client.user + user.subsonic_api_token = 'test' + user.save() + + url = reverse( + 'api:v1:users:users-subsonic-token', + kwargs={'username': user.username}) + + response = logged_in_api_client.post(url) + + assert response.status_code == 200 + user.refresh_from_db() + assert user.subsonic_api_token != 'test' + assert user.subsonic_api_token is not None + assert response.data == { + 'subsonic_api_token': user.subsonic_api_token + } + + +def test_user_can_get_new_subsonic_token(logged_in_api_client): + user = logged_in_api_client.user + user.subsonic_api_token = 'test' + user.save() + + url = reverse( + 'api:v1:users:users-subsonic-token', + kwargs={'username': user.username}) + + response = logged_in_api_client.get(url) + + assert response.status_code == 200 + assert response.data == { + 'subsonic_api_token': 'test' + } +def test_user_can_request_new_subsonic_token(logged_in_api_client): + user = logged_in_api_client.user + user.subsonic_api_token = 'test' + user.save() + + url = reverse( + 'api:v1:users:users-subsonic-token', + kwargs={'username': user.username}) + + response = logged_in_api_client.post(url) + + assert response.status_code == 200 + user.refresh_from_db() + assert user.subsonic_api_token != 'test' + assert user.subsonic_api_token is not None + assert response.data == { + 'subsonic_api_token': user.subsonic_api_token + } + + +def test_user_can_delete_subsonic_token(logged_in_api_client): + user = logged_in_api_client.user + user.subsonic_api_token = 'test' + user.save() + + url = reverse( + 'api:v1:users:users-subsonic-token', + kwargs={'username': user.username}) + + response = logged_in_api_client.delete(url) + + assert response.status_code == 204 + user.refresh_from_db() + assert user.subsonic_api_token is None + + @pytest.mark.parametrize('method', ['put', 'patch']) def test_user_cannot_patch_another_user( method, logged_in_api_client, factories): diff --git a/front/src/components/auth/Settings.vue b/front/src/components/auth/Settings.vue index 8eeae85a..5468358a 100644 --- a/front/src/components/auth/Settings.vue +++ b/front/src/components/auth/Settings.vue @@ -26,6 +26,10 @@ <div class="ui hidden divider"></div> <div class="ui small text container"> <h2 class="ui header"><i18next path="Change my password"/></h2> + <div class="ui message"> + {{ $t('Changing your password will also change your Subsonic API password if you have requested one.') }} + {{ $t('You will have to update your password on your clients that use this password.') }} + </div> <form class="ui form" @submit.prevent="submitPassword()"> <div v-if="passwordError" class="ui negative message"> <div class="header"><i18next path="Cannot change your password"/></div> @@ -41,10 +45,25 @@ <div class="field"> <label><i18next path="New password"/></label> <password-input required v-model="new_password" /> - </div> - <button :class="['ui', {'loading': isLoading}, 'button']" type="submit"><i18next path="Change password"/></button> + <dangerous-button + color="yellow" + :class="['ui', {'loading': isLoading}, 'button']" + :action="submitPassword"> + {{ $t('Change password') }} + <p slot="modal-header">{{ $t('Change your password?') }}</p> + <div slot="modal-content"> + <p>{{ $t("Changing your password will have the following consequences") }}</p> + <ul> + <li>{{ $t('You will be logged out from this session and have to log out with the new one') }}</li> + <li>{{ $t('Your Subsonic password will be changed to a new, random one, logging you out from devices that used the old Subsonic password') }}</li> + </ul> + </div> + <p slot="modal-confirm">{{ $t('Disable access') }}</p> + </dangerous-button> </form> + <div class="ui hidden divider" /> + <subsonic-token-form /> </div> </div> </div> @@ -55,10 +74,12 @@ import $ from 'jquery' import axios from 'axios' import logger from '@/logging' import PasswordInput from '@/components/forms/PasswordInput' +import SubsonicTokenForm from '@/components/auth/SubsonicTokenForm' export default { components: { - PasswordInput + PasswordInput, + SubsonicTokenForm }, data () { let d = { diff --git a/front/src/components/auth/SubsonicTokenForm.vue b/front/src/components/auth/SubsonicTokenForm.vue new file mode 100644 index 00000000..dd0bd5ca --- /dev/null +++ b/front/src/components/auth/SubsonicTokenForm.vue @@ -0,0 +1,137 @@ +<template> + <form class="ui form" @submit.prevent="requestNewToken()"> + <h2>{{ $t('Subsonic API password') }}</h2> + <p class="ui message" v-if="!subsonicEnabled"> + {{ $t('The Subsonic API is not available on this Funkwhale instance.') }} + </p> + <p> + {{ $t('Funkwhale is compatible with other music players that support the Subsonic API.') }} + {{ $t('You can use those to enjoy your playlist and music in offline mode, on your smartphone or tablet, for instance.') }} + </p> + <p> + {{ $t('However, accessing Funkwhale from those clients require a separate password you can set below.') }} + </p> + <p><a href="https://docs.funkwhale.audio/users/apps.html#subsonic" target="_blank"> + {{ $t('Discover how to use Funkwhale from other apps') }} + </a></p> + <div v-if="success" class="ui positive message"> + <div class="header">{{ successMessage }}</div> + </div> + <div v-if="subsonicEnabled && errors.length > 0" class="ui negative message"> + <div class="header">{{ $t('Error') }}</div> + <ul class="list"> + <li v-for="error in errors">{{ error }}</li> + </ul> + </div> + <template v-if="subsonicEnabled"> + <div v-if="token" class="field"> + <password-input v-model="token" /> + </div> + <dangerous-button + v-if="token" + color="grey" + :class="['ui', {'loading': isLoading}, 'button']" + :action="requestNewToken"> + {{ $t('Request a new password') }} + <p slot="modal-header">{{ $t('Request a new Subsonic API password?') }}</p> + <p slot="modal-content">{{ $t('This will log you out from existing devices that use the current password.') }}</p> + <p slot="modal-confirm">{{ $t('Request a new password') }}</p> + </dangerous-button> + <button + v-else + color="grey" + :class="['ui', {'loading': isLoading}, 'button']" + @click="requestNewToken">{{ $t('Request a password') }}</button> + <dangerous-button + v-if="token" + color="yellow" + :class="['ui', {'loading': isLoading}, 'button']" + :action="disable"> + {{ $t('Disable Subsonic access') }} + <p slot="modal-header">{{ $t('Disable Subsonic API access?') }}</p> + <p slot="modal-content">{{ $t('This will completely disable access to the Subsonic API using from account.') }}</p> + <p slot="modal-confirm">{{ $t('Disable access') }}</p> + </dangerous-button> + </template> + </form> +</template> + +<script> +import axios from 'axios' +import PasswordInput from '@/components/forms/PasswordInput' + +export default { + components: { + PasswordInput + }, + data () { + return { + token: null, + errors: [], + success: false, + isLoading: false, + successMessage: '' + } + }, + created () { + this.fetchToken() + }, + methods: { + fetchToken () { + this.success = false + this.errors = [] + this.isLoading = true + let self = this + let url = `users/users/${this.$store.state.auth.username}/subsonic-token/` + return axios.get(url).then(response => { + self.token = response.data['subsonic_api_token'] + self.isLoading = false + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + requestNewToken () { + this.successMessage = this.$t('Password updated') + this.success = false + this.errors = [] + this.isLoading = true + let self = this + let url = `users/users/${this.$store.state.auth.username}/subsonic-token/` + return axios.post(url, {}).then(response => { + self.token = response.data['subsonic_api_token'] + self.isLoading = false + self.success = true + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + disable () { + this.successMessage = this.$t('Access disabled') + this.success = false + this.errors = [] + this.isLoading = true + let self = this + let url = `users/users/${this.$store.state.auth.username}/subsonic-token/` + return axios.delete(url).then(response => { + self.isLoading = false + self.token = null + self.success = true + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + } + }, + computed: { + subsonicEnabled () { + return this.$store.state.instance.settings.subsonic.enabled.value + } + } +} +</script> + +<!-- Add "scoped" attribute to limit CSS to this component only --> +<style scoped> +</style> -- GitLab