Merge branch '187-emails' into 'develop'
Resolve "Add email support" Closes #187 See merge request funkwhale/funkwhale!182
This commit is contained in:
commit
2649ad88ff
|
@ -144,7 +144,6 @@ INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
|||
MIDDLEWARE = (
|
||||
# Make sure djangosecure.middleware.SecurityMiddleware is listed first
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'funkwhale_api.users.middleware.AnonymousSessionMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
|
@ -173,7 +172,10 @@ FIXTURE_DIRS = (
|
|||
|
||||
# EMAIL CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
EMAIL_BACKEND = env('DJANGO_EMAIL_BACKEND', default='django.core.mail.backends.smtp.EmailBackend')
|
||||
EMAIL_CONFIG = env.email_url(
|
||||
'EMAIL_CONFIG', default='consolemail://')
|
||||
|
||||
vars().update(EMAIL_CONFIG)
|
||||
|
||||
# DATABASE CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
|
@ -293,7 +295,7 @@ AUTHENTICATION_BACKENDS = (
|
|||
'django.contrib.auth.backends.ModelBackend',
|
||||
'allauth.account.auth_backends.AuthenticationBackend',
|
||||
)
|
||||
|
||||
SESSION_COOKIE_HTTPONLY = False
|
||||
# Some really nice defaults
|
||||
ACCOUNT_AUTHENTICATION_METHOD = 'username_email'
|
||||
ACCOUNT_EMAIL_REQUIRED = True
|
||||
|
@ -368,6 +370,7 @@ CORS_ORIGIN_ALLOW_ALL = True
|
|||
# 'funkwhale.localhost',
|
||||
# )
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
|
@ -392,6 +395,11 @@ REST_FRAMEWORK = {
|
|||
'django_filters.rest_framework.DjangoFilterBackend',
|
||||
)
|
||||
}
|
||||
REST_AUTH_SERIALIZERS = {
|
||||
'PASSWORD_RESET_SERIALIZER': 'funkwhale_api.users.serializers.PasswordResetSerializer' # noqa
|
||||
}
|
||||
REST_SESSION_LOGIN = False
|
||||
REST_USE_JWT = True
|
||||
|
||||
ATOMIC_REQUESTS = False
|
||||
USE_X_FORWARDED_HOST = True
|
||||
|
|
|
@ -25,9 +25,6 @@ SECRET_KEY = env("DJANGO_SECRET_KEY", default='mc$&b=5j#6^bv7tld1gyjp2&+^-qrdy=0
|
|||
# ------------------------------------------------------------------------------
|
||||
EMAIL_HOST = 'localhost'
|
||||
EMAIL_PORT = 1025
|
||||
EMAIL_BACKEND = env('DJANGO_EMAIL_BACKEND',
|
||||
default='django.core.mail.backends.console.EmailBackend')
|
||||
|
||||
|
||||
# django-debug-toolbar
|
||||
# ------------------------------------------------------------------------------
|
||||
|
|
|
@ -21,13 +21,6 @@ class Listening(models.Model):
|
|||
class Meta:
|
||||
ordering = ('-creation_date',)
|
||||
|
||||
def save(self, **kwargs):
|
||||
if not self.user and not self.session_key:
|
||||
raise ValidationError('Cannot have both session_key and user empty for listening')
|
||||
|
||||
super().save(**kwargs)
|
||||
|
||||
|
||||
def get_activity_url(self):
|
||||
return '{}/listenings/tracks/{}'.format(
|
||||
self.user.get_activity_url(), self.pk)
|
||||
|
|
|
@ -36,13 +36,9 @@ class ListeningSerializer(serializers.ModelSerializer):
|
|||
|
||||
class Meta:
|
||||
model = models.Listening
|
||||
fields = ('id', 'user', 'session_key', 'track', 'creation_date')
|
||||
|
||||
fields = ('id', 'user', 'track', 'creation_date')
|
||||
|
||||
def create(self, validated_data):
|
||||
if self.context.get('user'):
|
||||
validated_data['user'] = self.context.get('user')
|
||||
else:
|
||||
validated_data['session_key'] = self.context['session_key']
|
||||
validated_data['user'] = self.context['user']
|
||||
|
||||
return super().create(validated_data)
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from rest_framework import generics, mixins, viewsets
|
||||
from rest_framework import permissions
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import detail_route
|
||||
|
@ -10,31 +11,26 @@ from funkwhale_api.music.serializers import TrackSerializerNested
|
|||
from . import models
|
||||
from . import serializers
|
||||
|
||||
class ListeningViewSet(mixins.CreateModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
viewsets.GenericViewSet):
|
||||
|
||||
class ListeningViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
viewsets.GenericViewSet):
|
||||
|
||||
serializer_class = serializers.ListeningSerializer
|
||||
queryset = models.Listening.objects.all()
|
||||
permission_classes = [ConditionalAuthentication]
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
r = super().perform_create(serializer)
|
||||
if self.request.user.is_authenticated:
|
||||
record.send(serializer.instance)
|
||||
record.send(serializer.instance)
|
||||
return r
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
if self.request.user.is_authenticated:
|
||||
return queryset.filter(user=self.request.user)
|
||||
else:
|
||||
return queryset.filter(session_key=self.request.session.session_key)
|
||||
return queryset.filter(user=self.request.user)
|
||||
|
||||
def get_serializer_context(self):
|
||||
context = super().get_serializer_context()
|
||||
if self.request.user.is_authenticated:
|
||||
context['user'] = self.request.user
|
||||
else:
|
||||
context['session_key'] = self.request.session.session_key
|
||||
context['user'] = self.request.user
|
||||
return context
|
||||
|
|
|
@ -55,8 +55,6 @@ class RadioSession(models.Model):
|
|||
related_object = GenericForeignKey('related_object_content_type', 'related_object_id')
|
||||
|
||||
def save(self, **kwargs):
|
||||
if not self.user and not self.session_key:
|
||||
raise ValidationError('Cannot have both session_key and user empty for radio session')
|
||||
self.radio.clean(self)
|
||||
super().save(**kwargs)
|
||||
|
||||
|
|
|
@ -38,6 +38,7 @@ class RadioSerializer(serializers.ModelSerializer):
|
|||
|
||||
return super().save(**kwargs)
|
||||
|
||||
|
||||
class RadioSessionTrackSerializerCreate(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.RadioSessionTrack
|
||||
|
@ -62,17 +63,14 @@ class RadioSessionSerializer(serializers.ModelSerializer):
|
|||
'user',
|
||||
'creation_date',
|
||||
'custom_radio',
|
||||
'session_key')
|
||||
)
|
||||
|
||||
def validate(self, data):
|
||||
registry[data['radio_type']]().validate_session(data, **self.context)
|
||||
return data
|
||||
|
||||
def create(self, validated_data):
|
||||
if self.context.get('user'):
|
||||
validated_data['user'] = self.context.get('user')
|
||||
else:
|
||||
validated_data['session_key'] = self.context['session_key']
|
||||
validated_data['user'] = self.context['user']
|
||||
if validated_data.get('related_object_id'):
|
||||
radio = registry[validated_data['radio_type']]()
|
||||
validated_data['related_object'] = radio.get_related_object(validated_data['related_object_id'])
|
||||
|
|
|
@ -2,6 +2,7 @@ from django.db.models import Q
|
|||
from django.http import Http404
|
||||
|
||||
from rest_framework import generics, mixins, viewsets
|
||||
from rest_framework import permissions
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.decorators import detail_route, list_route
|
||||
|
@ -24,7 +25,7 @@ class RadioViewSet(
|
|||
viewsets.GenericViewSet):
|
||||
|
||||
serializer_class = serializers.RadioSerializer
|
||||
permission_classes = [ConditionalAuthentication]
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
filter_class = filtersets.RadioFilter
|
||||
|
||||
def get_queryset(self):
|
||||
|
@ -84,21 +85,15 @@ class RadioSessionViewSet(mixins.CreateModelMixin,
|
|||
|
||||
serializer_class = serializers.RadioSessionSerializer
|
||||
queryset = models.RadioSession.objects.all()
|
||||
permission_classes = [ConditionalAuthentication]
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = super().get_queryset()
|
||||
if self.request.user.is_authenticated:
|
||||
return queryset.filter(user=self.request.user)
|
||||
else:
|
||||
return queryset.filter(session_key=self.request.session.session_key)
|
||||
return queryset.filter(user=self.request.user)
|
||||
|
||||
def get_serializer_context(self):
|
||||
context = super().get_serializer_context()
|
||||
if self.request.user.is_authenticated:
|
||||
context['user'] = self.request.user
|
||||
else:
|
||||
context['session_key'] = self.request.session.session_key
|
||||
context['user'] = self.request.user
|
||||
return context
|
||||
|
||||
|
||||
|
@ -106,17 +101,14 @@ class RadioSessionTrackViewSet(mixins.CreateModelMixin,
|
|||
viewsets.GenericViewSet):
|
||||
serializer_class = serializers.RadioSessionTrackSerializer
|
||||
queryset = models.RadioSessionTrack.objects.all()
|
||||
permission_classes = [ConditionalAuthentication]
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
session = serializer.validated_data['session']
|
||||
try:
|
||||
if request.user.is_authenticated:
|
||||
assert request.user == session.user
|
||||
else:
|
||||
assert request.session.session_key == session.session_key
|
||||
assert request.user == session.user
|
||||
except AssertionError:
|
||||
return Response(status=status.HTTP_403_FORBIDDEN)
|
||||
track = session.radio.pick()
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
{% load account %}{% user_display user as user_display %}{% load i18n %}{% autoescape off %}{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}Hello from {{ site_name }}!
|
||||
|
||||
You're receiving this e-mail because user {{ user_display }} at {{ site_domain }} has given yours as an e-mail address to connect their account.
|
||||
|
||||
To confirm this is correct, go to {{ funkwhale_url }}/auth/email/confirm?key={{ key }}
|
||||
{% endblocktrans %}{% endautoescape %}
|
||||
{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}Thank you from {{ site_name }}!
|
||||
{{ site_domain }}{% endblocktrans %}
|
|
@ -0,0 +1,12 @@
|
|||
{% load i18n %}{% autoescape off %}
|
||||
{% blocktrans %}You're receiving this email because you requested a password reset for your user account at {{ site_name }}.{% endblocktrans %}
|
||||
|
||||
{% trans "Please go to the following page and choose a new password:" %}
|
||||
{{ funkwhale_url }}/auth/password/reset/confirm?uid={{ uid }}&token={{ token }}
|
||||
{% trans "Your username, in case you've forgotten:" %} {{ user.get_username }}
|
||||
|
||||
{% trans "Thanks for using our site!" %}
|
||||
|
||||
{% blocktrans %}The {{ site_name }} team{% endblocktrans %}
|
||||
|
||||
{% endautoescape %}
|
|
@ -1,5 +1,6 @@
|
|||
from allauth.account.adapter import DefaultAccountAdapter
|
||||
from django.conf import settings
|
||||
|
||||
from allauth.account.adapter import DefaultAccountAdapter
|
||||
from dynamic_preferences.registries import global_preferences_registry
|
||||
|
||||
|
||||
|
@ -8,3 +9,7 @@ class FunkwhaleAccountAdapter(DefaultAccountAdapter):
|
|||
def is_open_for_signup(self, request):
|
||||
manager = global_preferences_registry.manager()
|
||||
return manager['users__registration_enabled']
|
||||
|
||||
def send_mail(self, template_prefix, email, context):
|
||||
context['funkwhale_url'] = settings.FUNKWHALE_URL
|
||||
return super().send_mail(template_prefix, email, context)
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
|
||||
|
||||
class AnonymousSessionMiddleware:
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
if not request.session.session_key:
|
||||
request.session.save()
|
||||
response = self.get_response(request)
|
||||
return response
|
|
@ -1,16 +1,20 @@
|
|||
from django.views.generic import TemplateView
|
||||
from django.conf.urls import url
|
||||
|
||||
from rest_auth.registration.views import VerifyEmailView
|
||||
from rest_auth.views import PasswordChangeView
|
||||
from rest_auth.registration import views as registration_views
|
||||
from rest_auth import views as rest_auth_views
|
||||
|
||||
from .views import RegisterView
|
||||
from . import views
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^$', RegisterView.as_view(), name='rest_register'),
|
||||
url(r'^verify-email/$', VerifyEmailView.as_view(), name='rest_verify_email'),
|
||||
url(r'^change-password/$', PasswordChangeView.as_view(), name='change_password'),
|
||||
url(r'^$', views.RegisterView.as_view(), name='rest_register'),
|
||||
url(r'^verify-email/$',
|
||||
registration_views.VerifyEmailView.as_view(),
|
||||
name='rest_verify_email'),
|
||||
url(r'^change-password/$',
|
||||
rest_auth_views.PasswordChangeView.as_view(),
|
||||
name='change_password'),
|
||||
|
||||
# This url is used by django-allauth and empty TemplateView is
|
||||
# defined just to allow reverse() call inside app, for example when email
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
from rest_framework import serializers
|
||||
from django.conf import settings
|
||||
|
||||
from rest_framework import serializers
|
||||
from rest_auth.serializers import PasswordResetSerializer as PRS
|
||||
from funkwhale_api.activity import serializers as activity_serializers
|
||||
|
||||
from . import models
|
||||
|
@ -63,3 +65,12 @@ class UserReadSerializer(serializers.ModelSerializer):
|
|||
'status': o.has_perm(internal_codename)
|
||||
}
|
||||
return perms
|
||||
|
||||
|
||||
class PasswordResetSerializer(PRS):
|
||||
def get_email_options(self):
|
||||
return {
|
||||
'extra_email_context': {
|
||||
'funkwhale_url': settings.FUNKWHALE_URL
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ python_files = tests.py test_*.py *_tests.py
|
|||
testpaths = tests
|
||||
env =
|
||||
SECRET_KEY=test
|
||||
DJANGO_EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
|
||||
EMAIL_CONFIG=consolemail://
|
||||
CELERY_BROKER_URL=memory://
|
||||
CELERY_TASK_ALWAYS_EAGER=True
|
||||
CACHEOPS_ENABLED=False
|
||||
|
|
|
@ -14,21 +14,6 @@ def test_can_create_listening(factories):
|
|||
l = models.Listening.objects.create(user=user, track=track)
|
||||
|
||||
|
||||
def test_anonymous_user_can_create_listening_via_api(
|
||||
client, factories, preferences):
|
||||
preferences['common__api_authentication_required'] = False
|
||||
track = factories['music.Track']()
|
||||
url = reverse('api:v1:history:listenings-list')
|
||||
response = client.post(url, {
|
||||
'track': track.pk,
|
||||
})
|
||||
|
||||
listening = models.Listening.objects.latest('id')
|
||||
|
||||
assert listening.track == track
|
||||
assert listening.session_key == client.session.session_key
|
||||
|
||||
|
||||
def test_logged_in_user_can_create_listening_via_api(
|
||||
logged_in_client, factories, activity_muted):
|
||||
track = factories['music.Track']()
|
||||
|
|
|
@ -151,20 +151,6 @@ def test_can_start_radio_for_logged_in_user(logged_in_client):
|
|||
assert session.user == logged_in_client.user
|
||||
|
||||
|
||||
def test_can_start_radio_for_anonymous_user(api_client, db, preferences):
|
||||
preferences['common__api_authentication_required'] = False
|
||||
url = reverse('api:v1:radios:sessions-list')
|
||||
response = api_client.post(url, {'radio_type': 'random'})
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
session = models.RadioSession.objects.latest('id')
|
||||
|
||||
assert session.radio_type == 'random'
|
||||
assert session.user is None
|
||||
assert session.session_key == api_client.session.session_key
|
||||
|
||||
|
||||
def test_can_get_track_for_session_from_api(factories, logged_in_client):
|
||||
files = factories['music.TrackFile'].create_batch(1)
|
||||
tracks = [f.track for f in files]
|
||||
|
@ -227,25 +213,25 @@ def test_can_start_tag_radio(factories):
|
|||
|
||||
radio = radios.TagRadio()
|
||||
session = radio.start_session(user, related_object=tag)
|
||||
assert session.radio_type =='tag'
|
||||
assert session.radio_type == 'tag'
|
||||
for i in range(5):
|
||||
assert radio.pick() in good_tracks
|
||||
|
||||
|
||||
def test_can_start_artist_radio_from_api(api_client, preferences, factories):
|
||||
preferences['common__api_authentication_required'] = False
|
||||
def test_can_start_artist_radio_from_api(
|
||||
logged_in_api_client, preferences, factories):
|
||||
artist = factories['music.Artist']()
|
||||
url = reverse('api:v1:radios:sessions-list')
|
||||
|
||||
response = api_client.post(
|
||||
response = logged_in_api_client.post(
|
||||
url, {'radio_type': 'artist', 'related_object_id': artist.id})
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
session = models.RadioSession.objects.latest('id')
|
||||
|
||||
assert session.radio_type, 'artist'
|
||||
assert session.related_object, artist
|
||||
assert session.radio_type == 'artist'
|
||||
assert session.related_object == artist
|
||||
|
||||
|
||||
def test_can_start_less_listened_radio(factories):
|
||||
|
@ -257,6 +243,6 @@ def test_can_start_less_listened_radio(factories):
|
|||
good_tracks = [f.track for f in good_files]
|
||||
radio = radios.LessListenedRadio()
|
||||
session = radio.start_session(user)
|
||||
assert session.related_object == user
|
||||
|
||||
for i in range(5):
|
||||
assert radio.pick() in good_tracks
|
||||
|
|
|
@ -136,6 +136,20 @@ def test_changing_password_updates_secret_key(logged_in_client):
|
|||
assert user.password != password
|
||||
|
||||
|
||||
def test_can_request_password_reset(
|
||||
factories, api_client, mailoutbox):
|
||||
user = factories['users.User']()
|
||||
payload = {
|
||||
'email': user.email,
|
||||
}
|
||||
emails = len(mailoutbox)
|
||||
url = reverse('rest_password_reset')
|
||||
|
||||
response = api_client.post(url, payload)
|
||||
assert response.status_code == 200
|
||||
assert len(mailoutbox) > emails
|
||||
|
||||
|
||||
def test_user_can_patch_his_own_settings(logged_in_api_client):
|
||||
user = logged_in_api_client.user
|
||||
payload = {
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
Users can now request password reset by email, assuming
|
||||
a SMTP server was correctly configured (#187)
|
||||
|
||||
Update
|
||||
^^^^^^
|
||||
|
||||
Starting from this release, Funkwhale will send two types
|
||||
of emails:
|
||||
|
||||
- Email confirmation emails, to ensure a user's email is valid
|
||||
- Password reset emails, enabling user to reset their password without an admin's intervention
|
||||
|
||||
Email sending is disabled by default, as it requires additional configuration.
|
||||
In this mode, emails are simply outputed on stdout.
|
||||
|
||||
If you want to actually send those emails to your users, you should edit your
|
||||
.env file and tweak the EMAIL_CONFIG variable. See :ref:`setting-EMAIL_CONFIG`
|
||||
for more details.
|
||||
|
||||
.. note::
|
||||
|
||||
As a result of these changes, the DJANGO_EMAIL_BACKEND variable,
|
||||
which was not documented, has no effect anymore. You can safely remove it from
|
||||
your .env file if it is set.
|
|
@ -6,6 +6,7 @@
|
|||
# - DJANGO_SECRET_KEY
|
||||
# - DJANGO_ALLOWED_HOSTS
|
||||
# - FUNKWHALE_URL
|
||||
# - EMAIL_CONFIG (if you plan to send emails)
|
||||
# On non-docker setup **only**, you'll also have to tweak/uncomment those variables:
|
||||
# - DATABASE_URL
|
||||
# - CACHE_URL
|
||||
|
@ -41,6 +42,16 @@ FUNKWHALE_API_PORT=5000
|
|||
# your instance
|
||||
FUNKWHALE_URL=https://yourdomain.funwhale
|
||||
|
||||
# Configure email sending using this variale
|
||||
# By default, funkwhale will output emails sent to stdout
|
||||
# here are a few examples for this setting
|
||||
# EMAIL_CONFIG=consolemail:// # output emails to console (the default)
|
||||
# EMAIL_CONFIG=dummymail:// # disable email sending completely
|
||||
# On a production instance, you'll usually want to use an external SMTP server:
|
||||
# EMAIL_CONFIG=smtp://user@:password@youremail.host:25'
|
||||
# EMAIL_CONFIG=smtp+ssl://user@:password@youremail.host:465'
|
||||
# EMAIL_CONFIG=smtp+tls://user@:password@youremail.host:587'
|
||||
|
||||
# Depending on the reverse proxy used in front of your funkwhale instance,
|
||||
# the API will use different kind of headers to serve audio files
|
||||
# Allowed values: nginx, apache2
|
||||
|
|
|
@ -39,6 +39,24 @@ settings in this interface.
|
|||
Configuration reference
|
||||
-----------------------
|
||||
|
||||
.. _setting-EMAIL_CONFIG:
|
||||
|
||||
``EMAIL_CONFIG``
|
||||
^^^^^^^^^^^^^^^^
|
||||
|
||||
Determine how emails are sent.
|
||||
|
||||
Default: ``consolemail://``
|
||||
|
||||
Possible values:
|
||||
|
||||
- ``consolemail://``: Output sent emails to stdout
|
||||
- ``dummymail://``: Completely discard sent emails
|
||||
- ``smtp://user:password@youremail.host:25``: Send emails via SMTP via youremail.host on port 25, without encryption, authenticating as user "user" with password "password"
|
||||
- ``smtp+ssl://user:password@youremail.host:465``: Send emails via SMTP via youremail.host on port 465, using SSL encryption, authenticating as user "user" with password "password"
|
||||
- ``smtp+tls://user:password@youremail.host:587``: Send emails via SMTP via youremail.host on port 587, using TLS encryption, authenticating as user "user" with password "password"
|
||||
|
||||
|
||||
.. _setting-MUSIC_DIRECTORY_PATH:
|
||||
|
||||
``MUSIC_DIRECTORY_PATH``
|
||||
|
|
|
@ -35,24 +35,24 @@
|
|||
<div class="header">{{ $t('My account') }}</div>
|
||||
<div class="menu">
|
||||
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'profile', params: {username: $store.state.auth.username}}"><i class="user icon"></i>{{ $t('Logged in as {%name%}', { name: $store.state.auth.username }) }}</router-link>
|
||||
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i> {{ $t('Logout') }}</router-link>
|
||||
<router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i> {{ $t('Login') }}</router-link>
|
||||
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{name: 'logout'}"><i class="sign out icon"></i>{{ $t('Logout') }}</router-link>
|
||||
<router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i>{{ $t('Login') }}</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item">
|
||||
<div class="header">{{ $t('Music') }}</div>
|
||||
<div class="menu">
|
||||
<router-link class="item" :to="{path: '/library'}"><i class="sound icon"> </i>{{ $t('Browse library') }}</router-link>
|
||||
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{path: '/favorites'}"><i class="heart icon"></i> {{ $t('Favorites') }}</router-link>
|
||||
<router-link class="item" v-if="$store.state.auth.authenticated" :to="{path: '/favorites'}"><i class="heart icon"></i>{{ $t('Favorites') }}</router-link>
|
||||
<a
|
||||
@click="$store.commit('playlists/chooseTrack', null)"
|
||||
v-if="$store.state.auth.authenticated"
|
||||
class="item">
|
||||
<i class="list icon"></i> {{ $t('Playlists') }}
|
||||
<i class="list icon"></i>{{ $t('Playlists') }}
|
||||
</a>
|
||||
<router-link
|
||||
v-if="$store.state.auth.authenticated"
|
||||
class="item" :to="{path: '/activity'}"><i class="bell icon"></i> {{ $t('Activity') }}</router-link>
|
||||
class="item" :to="{path: '/activity'}"><i class="bell icon"></i>{{ $t('Activity') }}</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="item" v-if="showAdmin">
|
||||
|
@ -62,7 +62,7 @@
|
|||
class="item"
|
||||
v-if="$store.state.auth.availablePermissions['import.launch']"
|
||||
:to="{name: 'library.requests', query: {status: 'pending' }}">
|
||||
<i class="download icon"></i> {{ $t('Import requests') }}
|
||||
<i class="download icon"></i>{{ $t('Import requests') }}
|
||||
<div
|
||||
:class="['ui', {'teal': notifications.importRequests > 0}, 'label']"
|
||||
:title="$t('Pending import requests')">
|
||||
|
@ -72,7 +72,7 @@
|
|||
class="item"
|
||||
v-if="$store.state.auth.availablePermissions['federation.manage']"
|
||||
:to="{path: '/manage/federation/libraries'}">
|
||||
<i class="sitemap icon"></i> {{ $t('Federation') }}
|
||||
<i class="sitemap icon"></i>{{ $t('Federation') }}
|
||||
<div
|
||||
:class="['ui', {'teal': notifications.federation > 0}, 'label']"
|
||||
:title="$t('Pending follow requests')">
|
||||
|
|
|
@ -12,9 +12,15 @@
|
|||
</ul>
|
||||
</div>
|
||||
<div class="field">
|
||||
<i18next tag="label" path="Username or email"/>
|
||||
<label>
|
||||
{{ $t('Username or email') }} |
|
||||
<router-link :to="{path: '/signup'}">
|
||||
{{ $t('Create an account') }}
|
||||
</router-link>
|
||||
</label>
|
||||
<input
|
||||
ref="username"
|
||||
tabindex="1"
|
||||
required
|
||||
type="text"
|
||||
autofocus
|
||||
|
@ -23,18 +29,16 @@
|
|||
>
|
||||
</div>
|
||||
<div class="field">
|
||||
<i18next tag="label" path="Password"/>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
v-model="credentials.password"
|
||||
>
|
||||
<label>
|
||||
{{ $t('Password') }} |
|
||||
<router-link :to="{name: 'auth.password-reset', query: {email: credentials.username}}">
|
||||
{{ $t('Reset your password') }}
|
||||
</router-link>
|
||||
</label>
|
||||
<password-input :index="2" required v-model="credentials.password" />
|
||||
|
||||
</div>
|
||||
<button :class="['ui', {'loading': isLoading}, 'button']" type="submit"><i18next path="Login"/></button>
|
||||
<router-link class="ui right floated basic button" :to="{path: '/signup'}">
|
||||
<i18next path="Create an account"/>
|
||||
</router-link>
|
||||
<button tabindex="3" :class="['ui', {'loading': isLoading}, 'right', 'floated', 'green', 'button']" type="submit"><i18next path="Login"/></button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -42,12 +46,15 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import PasswordInput from '@/components/forms/PasswordInput'
|
||||
|
||||
export default {
|
||||
name: 'login',
|
||||
props: {
|
||||
next: {type: String, default: '/'}
|
||||
},
|
||||
components: {
|
||||
PasswordInput
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
// We need to initialize the component with any
|
||||
|
|
|
@ -35,21 +35,13 @@
|
|||
</div>
|
||||
<div class="field">
|
||||
<label><i18next path="Old password"/></label>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
autofocus
|
||||
placeholder="Enter your old password"
|
||||
v-model="old_password">
|
||||
<password-input required v-model="old_password" />
|
||||
|
||||
</div>
|
||||
<div class="field">
|
||||
<label><i18next path="New password"/></label>
|
||||
<input
|
||||
required
|
||||
type="password"
|
||||
autofocus
|
||||
placeholder="Enter your new password"
|
||||
v-model="new_password">
|
||||
<password-input required v-model="new_password" />
|
||||
|
||||
</div>
|
||||
<button :class="['ui', {'loading': isLoading}, 'button']" type="submit"><i18next path="Change password"/></button>
|
||||
</form>
|
||||
|
@ -62,8 +54,12 @@
|
|||
import $ from 'jquery'
|
||||
import axios from 'axios'
|
||||
import logger from '@/logging'
|
||||
import PasswordInput from '@/components/forms/PasswordInput'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PasswordInput
|
||||
},
|
||||
data () {
|
||||
let d = {
|
||||
// We need to initialize the component with any
|
||||
|
|
|
@ -34,16 +34,7 @@
|
|||
</div>
|
||||
<div class="field">
|
||||
<i18next tag="label" path="Password"/>
|
||||
<div class="ui action input">
|
||||
<input
|
||||
required
|
||||
:type="passwordInputType"
|
||||
placeholder="Enter your password"
|
||||
v-model="password">
|
||||
<span @click="showPassword = !showPassword" title="Show/hide password" class="ui icon button">
|
||||
<i class="eye icon"></i>
|
||||
</span>
|
||||
</div>
|
||||
<password-input v-model="password" />
|
||||
</div>
|
||||
<button :class="['ui', 'green', {'loading': isLoading}, 'button']" type="submit"><i18next path="Create my account"/></button>
|
||||
</form>
|
||||
|
@ -57,8 +48,13 @@
|
|||
import axios from 'axios'
|
||||
import logger from '@/logging'
|
||||
|
||||
import PasswordInput from '@/components/forms/PasswordInput'
|
||||
|
||||
export default {
|
||||
name: 'login',
|
||||
components: {
|
||||
PasswordInput
|
||||
},
|
||||
props: {
|
||||
next: {type: String, default: '/'}
|
||||
},
|
||||
|
@ -69,8 +65,7 @@ export default {
|
|||
password: '',
|
||||
isLoadingInstanceSetting: true,
|
||||
errors: [],
|
||||
isLoading: false,
|
||||
showPassword: false
|
||||
isLoading: false
|
||||
}
|
||||
},
|
||||
created () {
|
||||
|
@ -104,16 +99,7 @@ export default {
|
|||
self.isLoading = false
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
passwordInputType () {
|
||||
if (this.showPassword) {
|
||||
return 'text'
|
||||
}
|
||||
return 'password'
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
<template>
|
||||
<div class="ui action input">
|
||||
<input
|
||||
required
|
||||
:tabindex="index"
|
||||
:type="passwordInputType"
|
||||
@input="$emit('input', $event.target.value)"
|
||||
:value="value">
|
||||
<span @click="showPassword = !showPassword" :title="$t('Show/hide password')" class="ui icon button">
|
||||
<i class="eye icon"></i>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: ['value', 'index'],
|
||||
data () {
|
||||
return {
|
||||
showPassword: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
passwordInputType () {
|
||||
if (this.showPassword) {
|
||||
return 'text'
|
||||
}
|
||||
return 'password'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -9,6 +9,9 @@ import Signup from '@/components/auth/Signup'
|
|||
import Profile from '@/components/auth/Profile'
|
||||
import Settings from '@/components/auth/Settings'
|
||||
import Logout from '@/components/auth/Logout'
|
||||
import PasswordReset from '@/views/auth/PasswordReset'
|
||||
import PasswordResetConfirm from '@/views/auth/PasswordResetConfirm'
|
||||
import EmailConfirm from '@/views/auth/EmailConfirm'
|
||||
import Library from '@/components/library/Library'
|
||||
import LibraryHome from '@/components/library/Home'
|
||||
import LibraryArtist from '@/components/library/Artist'
|
||||
|
@ -59,6 +62,31 @@ export default new Router({
|
|||
component: Login,
|
||||
props: (route) => ({ next: route.query.next || '/library' })
|
||||
},
|
||||
{
|
||||
path: '/auth/password/reset',
|
||||
name: 'auth.password-reset',
|
||||
component: PasswordReset,
|
||||
props: (route) => ({
|
||||
defaultEmail: route.query.email
|
||||
})
|
||||
},
|
||||
{
|
||||
path: '/auth/email/confirm',
|
||||
name: 'auth.email-confirm',
|
||||
component: EmailConfirm,
|
||||
props: (route) => ({
|
||||
defaultKey: route.query.key
|
||||
})
|
||||
},
|
||||
{
|
||||
path: '/auth/password/reset/confirm',
|
||||
name: 'auth.password-reset-confirm',
|
||||
component: PasswordResetConfirm,
|
||||
props: (route) => ({
|
||||
defaultUid: route.query.uid,
|
||||
defaultToken: route.query.token
|
||||
})
|
||||
},
|
||||
{
|
||||
path: '/signup',
|
||||
name: 'signup',
|
||||
|
|
|
@ -97,6 +97,11 @@ export default {
|
|||
}
|
||||
},
|
||||
fetchProfile ({commit, dispatch, state}) {
|
||||
if (document) {
|
||||
// this is to ensure we do not have any leaking cookie set by django
|
||||
document.cookie = 'sessionid=; Path=/; Expires=Thu, 01 Jan 1970 00:00:01 GMT;'
|
||||
}
|
||||
|
||||
return axios.get('users/users/me/').then((response) => {
|
||||
logger.default.info('Successfully fetched user profile')
|
||||
let data = response.data
|
||||
|
|
|
@ -85,7 +85,10 @@ export default {
|
|||
togglePlay ({commit, state}) {
|
||||
commit('playing', !state.playing)
|
||||
},
|
||||
trackListened ({commit}, track) {
|
||||
trackListened ({commit, rootState}, track) {
|
||||
if (!rootState.auth.authenticated) {
|
||||
return
|
||||
}
|
||||
return axios.post('history/listenings/', {'track': track.id}).then((response) => {}, (response) => {
|
||||
logger.default.error('Could not record track in history')
|
||||
})
|
||||
|
|
|
@ -0,0 +1,71 @@
|
|||
<template>
|
||||
<div class="main pusher" v-title="$t('Confirm your email')">
|
||||
<div class="ui vertical stripe segment">
|
||||
<div class="ui small text container">
|
||||
<h2>{{ $t('Confirm your email') }}</h2>
|
||||
<form v-if="!success" class="ui form" @submit.prevent="submit()">
|
||||
<div v-if="errors.length > 0" class="ui negative message">
|
||||
<div class="header">{{ $t('Error while confirming your email') }}</div>
|
||||
<ul class="list">
|
||||
<li v-for="error in errors">{{ error }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>{{ $t('Confirmation code') }}</label>
|
||||
<input type="text" required v-model="key" />
|
||||
</div>
|
||||
<router-link :to="{path: '/login'}">
|
||||
{{ $t('Back to login') }}
|
||||
</router-link>
|
||||
<button :class="['ui', {'loading': isLoading}, 'right', 'floated', 'green', 'button']" type="submit">
|
||||
{{ $t('Confirm your email') }}</button>
|
||||
</form>
|
||||
<div v-else class="ui positive message">
|
||||
<div class="header">{{ $t('Email confirmed') }}</div>
|
||||
<p>{{ $t('Your email address was confirmed, you can now use the service without limitations.') }}</p>
|
||||
<router-link :to="{name: 'login'}">
|
||||
{{ $t('Proceed to login') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
props: ['defaultKey'],
|
||||
data () {
|
||||
return {
|
||||
isLoading: false,
|
||||
errors: [],
|
||||
key: this.defaultKey,
|
||||
success: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submit () {
|
||||
let self = this
|
||||
self.isLoading = true
|
||||
self.errors = []
|
||||
let payload = {
|
||||
key: this.key
|
||||
}
|
||||
return axios.post('auth/registration/verify-email/', payload).then(response => {
|
||||
self.isLoading = false
|
||||
self.success = true
|
||||
}, error => {
|
||||
self.errors = error.backendErrors
|
||||
self.isLoading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
|
@ -0,0 +1,75 @@
|
|||
<template>
|
||||
<div class="main pusher" v-title="$t('Reset your password')">
|
||||
<div class="ui vertical stripe segment">
|
||||
<div class="ui small text container">
|
||||
<h2>{{ $t('Reset your password') }}</h2>
|
||||
<form class="ui form" @submit.prevent="submit()">
|
||||
<div v-if="errors.length > 0" class="ui negative message">
|
||||
<div class="header">{{ $t('Error while asking for a password reset') }}</div>
|
||||
<ul class="list">
|
||||
<li v-for="error in errors">{{ error }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p>{{ $t('Use this form to request a password reset. We will send an email to the given address with instructions to reset your password.') }}</p>
|
||||
<div class="field">
|
||||
<label>{{ $t('Account\'s email') }}</label>
|
||||
<input
|
||||
required
|
||||
ref="email"
|
||||
type="email"
|
||||
autofocus
|
||||
:placeholder="$t('Input the email address binded to your account')"
|
||||
v-model="email">
|
||||
</div>
|
||||
<router-link :to="{path: '/login'}">
|
||||
{{ $t('Back to login') }}
|
||||
</router-link>
|
||||
<button :class="['ui', {'loading': isLoading}, 'right', 'floated', 'green', 'button']" type="submit">
|
||||
{{ $t('Ask for a password reset') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
props: ['defaultEmail'],
|
||||
data () {
|
||||
return {
|
||||
email: this.defaultEmail,
|
||||
isLoading: false,
|
||||
errors: []
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.$refs.email.focus()
|
||||
},
|
||||
methods: {
|
||||
submit () {
|
||||
let self = this
|
||||
self.isLoading = true
|
||||
self.errors = []
|
||||
let payload = {
|
||||
email: this.email
|
||||
}
|
||||
return axios.post('auth/password/reset/', payload).then(response => {
|
||||
self.isLoading = false
|
||||
self.$router.push({
|
||||
name: 'auth.password-reset-confirm'
|
||||
})
|
||||
}, error => {
|
||||
self.errors = error.backendErrors
|
||||
self.isLoading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
|
@ -0,0 +1,85 @@
|
|||
<template>
|
||||
<div class="main pusher" v-title="$t('Change your password')">
|
||||
<div class="ui vertical stripe segment">
|
||||
<div class="ui small text container">
|
||||
<h2>{{ $t('Change your password') }}</h2>
|
||||
<form v-if="!success" class="ui form" @submit.prevent="submit()">
|
||||
<div v-if="errors.length > 0" class="ui negative message">
|
||||
<div class="header">{{ $t('Error while changing your password') }}</div>
|
||||
<ul class="list">
|
||||
<li v-for="error in errors">{{ error }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<template v-if="token && uid">
|
||||
<div class="field">
|
||||
<label>{{ $t('New password') }}</label>
|
||||
<password-input v-model="newPassword" />
|
||||
</div>
|
||||
<router-link :to="{path: '/login'}">
|
||||
{{ $t('Back to login') }}
|
||||
</router-link>
|
||||
<button :class="['ui', {'loading': isLoading}, 'right', 'floated', 'green', 'button']" type="submit">
|
||||
{{ $t('Update your password') }}</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>{{ $t('If the email address provided in the previous step is valid and binded to a user account, you should receive an email with reset instructions in the next couple of minutes.') }}</p>
|
||||
</template>
|
||||
</form>
|
||||
<div v-else class="ui positive message">
|
||||
<div class="header">{{ $t('Password updated successfully') }}</div>
|
||||
<p>{{ $t('Your password has been updated successfully.') }}</p>
|
||||
<router-link :to="{name: 'login'}">
|
||||
{{ $t('Proceed to login') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import PasswordInput from '@/components/forms/PasswordInput'
|
||||
|
||||
export default {
|
||||
props: ['defaultToken', 'defaultUid'],
|
||||
components: {
|
||||
PasswordInput
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
newPassword: '',
|
||||
isLoading: false,
|
||||
errors: [],
|
||||
token: this.defaultToken,
|
||||
uid: this.defaultUid,
|
||||
success: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submit () {
|
||||
let self = this
|
||||
self.isLoading = true
|
||||
self.errors = []
|
||||
let payload = {
|
||||
uid: this.uid,
|
||||
token: this.token,
|
||||
new_password1: this.newPassword,
|
||||
new_password2: this.newPassword
|
||||
}
|
||||
return axios.post('auth/password/reset/confirm/', payload).then(response => {
|
||||
self.isLoading = false
|
||||
self.success = true
|
||||
}, error => {
|
||||
self.errors = error.backendErrors
|
||||
self.isLoading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
Loading…
Reference in New Issue