Skip to content
Snippets Groups Projects
Verified Commit 24cb1d95 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

See #75: user can now manage the Subsonic API token from their settings page

parent 75959362
No related branches found
No related tags found
No related merge requests found
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import mixins from rest_framework import mixins
from rest_framework import viewsets 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 rest_auth.registration.views import RegisterView as BaseRegisterView
from allauth.account.adapter import get_adapter from allauth.account.adapter import get_adapter
from funkwhale_api.common import preferences
from . import models from . import models
from . import serializers from . import serializers
...@@ -37,6 +39,28 @@ class UserViewSet( ...@@ -37,6 +39,28 @@ class UserViewSet(
serializer = serializers.UserReadSerializer(request.user) serializer = serializers.UserReadSerializer(request.user)
return Response(serializer.data) 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): def update(self, request, *args, **kwargs):
if not self.request.user.username == kwargs.get('username'): if not self.request.user.username == kwargs.get('username'):
return Response(status=403) return Response(status=403)
......
...@@ -45,6 +45,13 @@ def test_exception_wrong_credentials(f, db, api_client): ...@@ -45,6 +45,13 @@ def test_exception_wrong_credentials(f, db, api_client):
assert response.data == expected 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']) @pytest.mark.parametrize('f', ['xml', 'json'])
def test_get_license(f, db, logged_in_api_client, mocker): def test_get_license(f, db, logged_in_api_client, mocker):
url = reverse('api:subsonic-get-license') url = reverse('api:subsonic-get-license')
......
...@@ -167,6 +167,77 @@ def test_user_can_patch_his_own_settings(logged_in_api_client): ...@@ -167,6 +167,77 @@ def test_user_can_patch_his_own_settings(logged_in_api_client):
assert user.privacy_level == 'me' 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']) @pytest.mark.parametrize('method', ['put', 'patch'])
def test_user_cannot_patch_another_user( def test_user_cannot_patch_another_user(
method, logged_in_api_client, factories): method, logged_in_api_client, factories):
......
...@@ -26,6 +26,10 @@ ...@@ -26,6 +26,10 @@
<div class="ui hidden divider"></div> <div class="ui hidden divider"></div>
<div class="ui small text container"> <div class="ui small text container">
<h2 class="ui header"><i18next path="Change my password"/></h2> <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()"> <form class="ui form" @submit.prevent="submitPassword()">
<div v-if="passwordError" class="ui negative message"> <div v-if="passwordError" class="ui negative message">
<div class="header"><i18next path="Cannot change your password"/></div> <div class="header"><i18next path="Cannot change your password"/></div>
...@@ -41,10 +45,25 @@ ...@@ -41,10 +45,25 @@
<div class="field"> <div class="field">
<label><i18next path="New password"/></label> <label><i18next path="New password"/></label>
<password-input required v-model="new_password" /> <password-input required v-model="new_password" />
</div> </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> </form>
<div class="ui hidden divider" />
<subsonic-token-form />
</div> </div>
</div> </div>
</div> </div>
...@@ -55,10 +74,12 @@ import $ from 'jquery' ...@@ -55,10 +74,12 @@ import $ from 'jquery'
import axios from 'axios' import axios from 'axios'
import logger from '@/logging' import logger from '@/logging'
import PasswordInput from '@/components/forms/PasswordInput' import PasswordInput from '@/components/forms/PasswordInput'
import SubsonicTokenForm from '@/components/auth/SubsonicTokenForm'
export default { export default {
components: { components: {
PasswordInput PasswordInput,
SubsonicTokenForm
}, },
data () { data () {
let d = { let d = {
......
<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>
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment