Merge branch '187-emails' into 'develop'

Resolve "Add email support"

Closes #187

See merge request funkwhale/funkwhale!182
This commit is contained in:
Eliot Berriot 2018-05-06 11:54:00 +00:00
commit 2649ad88ff
32 changed files with 497 additions and 165 deletions

View File

@ -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

View File

@ -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
# ------------------------------------------------------------------------------

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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'])

View File

@ -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()

View File

@ -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 %}

View File

@ -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 %}

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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
}
}

View File

@ -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

View File

@ -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']()

View File

@ -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

View File

@ -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 = {

View File

@ -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.

View File

@ -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

View File

@ -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``

View File

@ -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')">

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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',

View File

@ -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

View File

@ -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')
})

View File

@ -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>

View File

@ -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>

View File

@ -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>