mirror of
https://gitlab.com/bramw/baserow.git
synced 2025-04-10 23:50:12 +00:00
Merge branch 'feature/authentication' into 'develop'
Authentication See merge request bramw/baserow!2
This commit is contained in:
commit
9b19908914
53 changed files with 1689 additions and 722 deletions
.gitlab-ci.ymlMakefile
backend
manage.py
requirements
src/baserow
tests
sandbox
web-frontend
|
@ -35,6 +35,13 @@ backend-flake8:
|
||||||
backend-pytest:
|
backend-pytest:
|
||||||
stage: test
|
stage: test
|
||||||
image: python:3.6
|
image: python:3.6
|
||||||
|
services:
|
||||||
|
- name: postgres:11.3
|
||||||
|
alias: db
|
||||||
|
variables:
|
||||||
|
POSTGRES_USER: baserow
|
||||||
|
POSTGRES_PASSWORD: baserow
|
||||||
|
POSTGRES_DB: baserow
|
||||||
script:
|
script:
|
||||||
- make install-backend-dependencies
|
- make install-backend-dependencies
|
||||||
- export PYTHONPATH=$CI_PROJECT_DIR/backend/src
|
- export PYTHONPATH=$CI_PROJECT_DIR/backend/src
|
||||||
|
|
6
Makefile
6
Makefile
|
@ -13,7 +13,7 @@ install-backend-dependencies:
|
||||||
pip install -r backend/requirements/base.txt
|
pip install -r backend/requirements/base.txt
|
||||||
pip install -r backend/requirements/dev.txt
|
pip install -r backend/requirements/dev.txt
|
||||||
|
|
||||||
install-dependencies: backend-dependencies install-web-frontend-dependencies
|
install-dependencies: install-backend-dependencies install-web-frontend-dependencies
|
||||||
|
|
||||||
eslint-web-frontend:
|
eslint-web-frontend:
|
||||||
(cd web-frontend && yarn run eslint) || exit;
|
(cd web-frontend && yarn run eslint) || exit;
|
||||||
|
@ -31,3 +31,7 @@ lint-backend:
|
||||||
|
|
||||||
test-backend:
|
test-backend:
|
||||||
(cd backend && pytest tests) || exit;
|
(cd backend && pytest tests) || exit;
|
||||||
|
|
||||||
|
lint: lint-backend lint-web-frontend
|
||||||
|
test: test-backend test-web-frontend
|
||||||
|
lint-and-test: lint test
|
||||||
|
|
|
@ -1,21 +0,0 @@
|
||||||
#!/usr/bin/env python
|
|
||||||
"""Django's command-line utility for administrative tasks."""
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'baserow.config.settings.dev')
|
|
||||||
try:
|
|
||||||
from django.core.management import execute_from_command_line
|
|
||||||
except ImportError as exc:
|
|
||||||
raise ImportError(
|
|
||||||
"Couldn't import Django. Are you sure it's installed and "
|
|
||||||
"available on your PYTHONPATH environment variable? Did you "
|
|
||||||
"forget to activate a virtual environment?"
|
|
||||||
) from exc
|
|
||||||
execute_from_command_line(sys.argv)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
|
@ -1,2 +1,3 @@
|
||||||
flake8==3.7.7
|
flake8==3.7.7
|
||||||
pytest-django==3.5.0
|
pytest-django>=3.5.0
|
||||||
|
Faker==1.0.7
|
||||||
|
|
1
backend/src/baserow/api/v0/__init__.py
Normal file
1
backend/src/baserow/api/v0/__init__.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
app_name = 'baserow.api.v0'
|
5
backend/src/baserow/api/v0/apps.py
Normal file
5
backend/src/baserow/api/v0/apps.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ApiConfig(AppConfig):
|
||||||
|
name = 'baserow.api.v0'
|
56
backend/src/baserow/api/v0/decorators.py
Normal file
56
backend/src/baserow/api/v0/decorators.py
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.exceptions import APIException
|
||||||
|
|
||||||
|
|
||||||
|
def map_exceptions(exceptions):
|
||||||
|
"""This decorator easily maps specific exceptions to a standard api response.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
@map_exceptions({ SomeException: 'ERROR_1' })
|
||||||
|
def get(self, request):
|
||||||
|
raise SomeException('This is a test')
|
||||||
|
|
||||||
|
HTTP/1.1 400
|
||||||
|
{
|
||||||
|
"error": "ERROR_1",
|
||||||
|
"detail": "This is a test"
|
||||||
|
}
|
||||||
|
|
||||||
|
Example 2:
|
||||||
|
@map_exceptions({ SomeException: ('ERROR_1', 404, 'Other message') })
|
||||||
|
def get(self, request):
|
||||||
|
raise SomeException('This is a test')
|
||||||
|
|
||||||
|
HTTP/1.1 404
|
||||||
|
{
|
||||||
|
"error": "ERROR_1",
|
||||||
|
"detail": "Other message"
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
def map_exceptions_decorator(func):
|
||||||
|
def func_wrapper(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
except tuple(exceptions.keys()) as e:
|
||||||
|
value = exceptions.get(e.__class__)
|
||||||
|
status_code = status.HTTP_400_BAD_REQUEST
|
||||||
|
detail = str(e)
|
||||||
|
|
||||||
|
if isinstance(value, str):
|
||||||
|
error = value
|
||||||
|
if isinstance(value, tuple):
|
||||||
|
error = value[0]
|
||||||
|
if len(value) > 1 and value[1] is not None:
|
||||||
|
status_code = value[1]
|
||||||
|
if len(value) > 2 and value[2] is not None:
|
||||||
|
detail = value[2]
|
||||||
|
|
||||||
|
exc = APIException({
|
||||||
|
'error': error,
|
||||||
|
'detail': detail
|
||||||
|
})
|
||||||
|
exc.status_code = status_code
|
||||||
|
|
||||||
|
raise exc
|
||||||
|
return func_wrapper
|
||||||
|
return map_exceptions_decorator
|
10
backend/src/baserow/api/v0/urls.py
Normal file
10
backend/src/baserow/api/v0/urls.py
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
from django.urls import path, include
|
||||||
|
|
||||||
|
from .user import urls as user_urls
|
||||||
|
|
||||||
|
|
||||||
|
app_name = 'baserow.api.v0'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('user/', include(user_urls, namespace='user'))
|
||||||
|
]
|
8
backend/src/baserow/api/v0/user/jwt.py
Normal file
8
backend/src/baserow/api/v0/user/jwt.py
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
from .serializers import UserSerializer
|
||||||
|
|
||||||
|
|
||||||
|
def jwt_response_payload_handler(token, user=None, request=None):
|
||||||
|
return {
|
||||||
|
'token': token,
|
||||||
|
'user': UserSerializer(user, context={'request': request}).data
|
||||||
|
}
|
22
backend/src/baserow/api/v0/user/serializers.py
Normal file
22
backend/src/baserow/api/v0/user/serializers.py
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
from rest_framework import serializers
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ('first_name', 'username', 'password')
|
||||||
|
extra_kwargs = {
|
||||||
|
'password': {
|
||||||
|
'write_only': True
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RegisterSerializer(serializers.Serializer):
|
||||||
|
name = serializers.CharField(max_length=32)
|
||||||
|
email = serializers.EmailField()
|
||||||
|
password = serializers.CharField(max_length=32)
|
||||||
|
authenticate = serializers.BooleanField(required=False, default=False)
|
17
backend/src/baserow/api/v0/user/urls.py
Normal file
17
backend/src/baserow/api/v0/user/urls.py
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
from django.conf.urls import url
|
||||||
|
|
||||||
|
from rest_framework_jwt.views import (
|
||||||
|
obtain_jwt_token, refresh_jwt_token, verify_jwt_token
|
||||||
|
)
|
||||||
|
|
||||||
|
from .views import UserView
|
||||||
|
|
||||||
|
|
||||||
|
app_name = 'baserow.api.v0.user'
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
url(r'^token-auth/$', obtain_jwt_token, name='token_auth'),
|
||||||
|
url(r'^token-refresh/$', refresh_jwt_token, name='token_refresh'),
|
||||||
|
url(r'^token-verify/$', verify_jwt_token, name='token_verify'),
|
||||||
|
url(r'^$', UserView.as_view(), name='index')
|
||||||
|
]
|
43
backend/src/baserow/api/v0/user/views.py
Normal file
43
backend/src/baserow/api/v0/user/views.py
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
from rest_framework_jwt.settings import api_settings
|
||||||
|
|
||||||
|
from baserow.api.v0.decorators import map_exceptions
|
||||||
|
from baserow.user.handler import UserHandler
|
||||||
|
from baserow.user.exceptions import UserAlreadyExist
|
||||||
|
|
||||||
|
|
||||||
|
from .serializers import RegisterSerializer, UserSerializer
|
||||||
|
|
||||||
|
|
||||||
|
jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
|
||||||
|
jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
|
||||||
|
|
||||||
|
|
||||||
|
class UserView(APIView):
|
||||||
|
permission_classes = (AllowAny,)
|
||||||
|
user_handler = UserHandler()
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
@map_exceptions({
|
||||||
|
UserAlreadyExist: 'ERROR_ALREADY_EXISTS'
|
||||||
|
})
|
||||||
|
def post(self, request):
|
||||||
|
serializer = RegisterSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
data = serializer.data
|
||||||
|
user = self.user_handler.create_user(name=data['name'], email=data['email'],
|
||||||
|
password=data['password'])
|
||||||
|
|
||||||
|
response = {'user': UserSerializer(user).data}
|
||||||
|
|
||||||
|
if data['authenticate']:
|
||||||
|
payload = jwt_payload_handler(user)
|
||||||
|
token = jwt_encode_handler(payload)
|
||||||
|
response.update(token=token)
|
||||||
|
|
||||||
|
return Response(response)
|
|
@ -1,4 +1,5 @@
|
||||||
import os
|
import os
|
||||||
|
import datetime
|
||||||
|
|
||||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
@ -21,10 +22,14 @@ INSTALLED_APPS = [
|
||||||
'django.contrib.messages',
|
'django.contrib.messages',
|
||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
|
|
||||||
'rest_framework'
|
'rest_framework',
|
||||||
|
'corsheaders',
|
||||||
|
|
||||||
|
'baserow.api.v0'
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
|
'corsheaders.middleware.CorsMiddleware',
|
||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
@ -118,3 +123,15 @@ REST_FRAMEWORK = {
|
||||||
'rest_framework.authentication.BasicAuthentication',
|
'rest_framework.authentication.BasicAuthentication',
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CORS_ORIGIN_WHITELIST = (
|
||||||
|
'http://localhost:3000',
|
||||||
|
)
|
||||||
|
|
||||||
|
JWT_AUTH = {
|
||||||
|
'JWT_EXPIRATION_DELTA': datetime.timedelta(seconds=300),
|
||||||
|
'JWT_ALLOW_REFRESH': True,
|
||||||
|
'JWT_REFRESH_EXPIRATION_DELTA': datetime.timedelta(days=7),
|
||||||
|
'JWT_RESPONSE_PAYLOAD_HANDLER': 'baserow.api.v0.user.jwt.'
|
||||||
|
'jwt_response_payload_handler'
|
||||||
|
}
|
||||||
|
|
|
@ -1,14 +1,7 @@
|
||||||
from django.urls import path, include
|
from django.urls import include
|
||||||
from django.conf.urls import url
|
from django.conf.urls import url
|
||||||
|
|
||||||
from rest_framework import routers
|
|
||||||
from rest_framework_jwt.views import obtain_jwt_token
|
|
||||||
|
|
||||||
|
|
||||||
router = routers.DefaultRouter()
|
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(r'^api/token-auth/', obtain_jwt_token),
|
url(r'^api/v0/', include('baserow.api.v0.urls', namespace='api')),
|
||||||
path('api/', include(router.urls)),
|
|
||||||
]
|
]
|
||||||
|
|
0
backend/src/baserow/user/__init__.py
Normal file
0
backend/src/baserow/user/__init__.py
Normal file
2
backend/src/baserow/user/exceptions.py
Normal file
2
backend/src/baserow/user/exceptions.py
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
class UserAlreadyExist(Exception):
|
||||||
|
pass
|
28
backend/src/baserow/user/handler.py
Normal file
28
backend/src/baserow/user/handler.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.db import IntegrityError
|
||||||
|
|
||||||
|
from .exceptions import UserAlreadyExist
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class UserHandler:
|
||||||
|
def create_user(self, name, email, password):
|
||||||
|
"""Create a new user with the provided information.
|
||||||
|
|
||||||
|
:param name: The name of the new user.
|
||||||
|
:param email: The e-mail address of the user, this is also the username.
|
||||||
|
:param password: The password of the user.
|
||||||
|
:return: The user object.
|
||||||
|
:rtype: User
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = User(first_name=name, email=email, username=email)
|
||||||
|
user.set_password(password)
|
||||||
|
user.save()
|
||||||
|
except IntegrityError:
|
||||||
|
raise UserAlreadyExist(f'A user with username {email} already exists.')
|
||||||
|
|
||||||
|
return user
|
0
backend/tests/__init__.py
Normal file
0
backend/tests/__init__.py
Normal file
75
backend/tests/baserow/api/v0/user/test_token_auth.py
Normal file
75
backend/tests/baserow/api/v0/user/test_token_auth.py
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from django.shortcuts import reverse
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from rest_framework_jwt.settings import api_settings
|
||||||
|
|
||||||
|
|
||||||
|
jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
|
||||||
|
jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_token_auth(client, data_fixture):
|
||||||
|
data_fixture.create_user(email='test@test.nl', password='password',
|
||||||
|
first_name='Test1')
|
||||||
|
|
||||||
|
response = client.post(reverse('api:user:token_auth'), {
|
||||||
|
'username': 'no_existing@test.nl',
|
||||||
|
'password': 'password'
|
||||||
|
})
|
||||||
|
json = response.json()
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert len(json['non_field_errors']) > 0
|
||||||
|
|
||||||
|
response = client.post(reverse('api:user:token_auth'), {
|
||||||
|
'username': 'test@test.nl',
|
||||||
|
'password': 'wrong_password'
|
||||||
|
})
|
||||||
|
json = response.json()
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert len(json['non_field_errors']) > 0
|
||||||
|
|
||||||
|
response = client.post(reverse('api:user:token_auth'), {
|
||||||
|
'username': 'test@test.nl',
|
||||||
|
'password': 'password'
|
||||||
|
})
|
||||||
|
json = response.json()
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'token' in json
|
||||||
|
assert 'user' in json
|
||||||
|
assert json['user']['username'] == 'test@test.nl'
|
||||||
|
assert json['user']['first_name'] == 'Test1'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_token_refresh(client, data_fixture):
|
||||||
|
user = data_fixture.create_user(email='test@test.nl', password='password',
|
||||||
|
first_name='Test1')
|
||||||
|
|
||||||
|
response = client.post(reverse('api:user:token_refresh'), {'token': 'WRONG_TOKEN'})
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
payload = jwt_payload_handler(user)
|
||||||
|
token = jwt_encode_handler(payload)
|
||||||
|
|
||||||
|
response = client.post(reverse('api:user:token_refresh'), {'token': token})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert 'token' in response.json()
|
||||||
|
|
||||||
|
with patch('rest_framework_jwt.utils.datetime') as mock_datetime:
|
||||||
|
mock_datetime.utcnow.return_value = datetime(2019, 1, 1, 1, 1, 1, 0)
|
||||||
|
payload = jwt_payload_handler(user)
|
||||||
|
token = jwt_encode_handler(payload)
|
||||||
|
|
||||||
|
response = client.post(reverse('api:user:token_refresh'), {'token': token})
|
||||||
|
assert response.status_code == 400
|
37
backend/tests/baserow/api/v0/user/test_user_views.py
Normal file
37
backend/tests/baserow/api/v0/user/test_user_views.py
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.shortcuts import reverse
|
||||||
|
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_create_user(client):
|
||||||
|
response = client.post(reverse('api:user:index'), {
|
||||||
|
'name': 'Test1',
|
||||||
|
'email': 'test@test.nl',
|
||||||
|
'password': 'test12'
|
||||||
|
})
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
user = User.objects.get(email='test@test.nl')
|
||||||
|
assert user.first_name == 'Test1'
|
||||||
|
assert user.email == 'test@test.nl'
|
||||||
|
assert user.password != ''
|
||||||
|
|
||||||
|
response_failed = client.post(reverse('api:user:index'), {
|
||||||
|
'name': 'Test1',
|
||||||
|
'email': 'test@test.nl',
|
||||||
|
'password': 'test12'
|
||||||
|
})
|
||||||
|
|
||||||
|
assert response_failed.status_code == 400
|
||||||
|
assert response_failed.json()['error'] == 'ERROR_ALREADY_EXISTS'
|
||||||
|
|
||||||
|
response_failed_2 = client.post(reverse('api:user:index'), {
|
||||||
|
'email': 'test'
|
||||||
|
})
|
||||||
|
|
||||||
|
assert response_failed_2.status_code == 400
|
|
@ -1,4 +0,0 @@
|
||||||
def test_homepage(client):
|
|
||||||
response = client.get('/api/')
|
|
||||||
assert response.json()['detail'] == 'Authentication credentials were not provided.'
|
|
||||||
assert response.status_code == 401
|
|
18
backend/tests/baserow/user/test_user_handler.py
Normal file
18
backend/tests/baserow/user/test_user_handler.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from baserow.user.exceptions import UserAlreadyExist
|
||||||
|
from baserow.user.handler import UserHandler
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_create_user():
|
||||||
|
user_handler = UserHandler()
|
||||||
|
|
||||||
|
user = user_handler.create_user('Test1', 'test@test.nl', 'password')
|
||||||
|
assert user.pk
|
||||||
|
assert user.first_name == 'Test1'
|
||||||
|
assert user.email == 'test@test.nl'
|
||||||
|
assert user.username == 'test@test.nl'
|
||||||
|
|
||||||
|
with pytest.raises(UserAlreadyExist):
|
||||||
|
user_handler.create_user('Test1', 'test@test.nl', 'password')
|
7
backend/tests/conftest.py
Normal file
7
backend/tests/conftest.py
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def data_fixture():
|
||||||
|
from .fixtures import Fixtures
|
||||||
|
return Fixtures()
|
5
backend/tests/fixtures/__init__.py
vendored
Normal file
5
backend/tests/fixtures/__init__.py
vendored
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
from .user import UserFixtures
|
||||||
|
|
||||||
|
|
||||||
|
class Fixtures(UserFixtures):
|
||||||
|
pass
|
20
backend/tests/fixtures/user.py
vendored
Normal file
20
backend/tests/fixtures/user.py
vendored
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from faker import Faker
|
||||||
|
|
||||||
|
|
||||||
|
fake = Faker()
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class UserFixtures:
|
||||||
|
def create_user(self, **kwargs):
|
||||||
|
kwargs.setdefault('email', fake.email())
|
||||||
|
kwargs.setdefault('username', kwargs['email'])
|
||||||
|
kwargs.setdefault('first_name', fake.name())
|
||||||
|
kwargs.setdefault('password', 'password')
|
||||||
|
|
||||||
|
user = User(**kwargs)
|
||||||
|
user.set_password(kwargs['password'])
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
return user
|
|
@ -935,7 +935,7 @@
|
||||||
webpack-node-externals "^1.7.2"
|
webpack-node-externals "^1.7.2"
|
||||||
webpackbar "^3.2.0"
|
webpackbar "^3.2.0"
|
||||||
|
|
||||||
"@nuxtjs/axios@^5.3.6":
|
"@nuxtjs/axios@^5.5.4":
|
||||||
version "5.5.4"
|
version "5.5.4"
|
||||||
resolved "https://registry.yarnpkg.com/@nuxtjs/axios/-/axios-5.5.4.tgz#c4aee2322901b19d4072670c03144662a73ea6f4"
|
resolved "https://registry.yarnpkg.com/@nuxtjs/axios/-/axios-5.5.4.tgz#c4aee2322901b19d4072670c03144662a73ea6f4"
|
||||||
integrity sha512-/Ljsyh5VIc9paXGrQue7RQ+PpBNES1oit0g4l+ya1tfyKnZMpHSbghuLcv0xq+BpXlSEr690uemHbz54/N6U5w==
|
integrity sha512-/Ljsyh5VIc9paXGrQue7RQ+PpBNES1oit0g4l+ya1tfyKnZMpHSbghuLcv0xq+BpXlSEr690uemHbz54/N6U5w==
|
||||||
|
@ -962,6 +962,11 @@
|
||||||
mustache "^2.3.0"
|
mustache "^2.3.0"
|
||||||
stack-trace "0.0.10"
|
stack-trace "0.0.10"
|
||||||
|
|
||||||
|
"@types/cookie@^0.3.1":
|
||||||
|
version "0.3.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.3.3.tgz#85bc74ba782fb7aa3a514d11767832b0e3bc6803"
|
||||||
|
integrity sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow==
|
||||||
|
|
||||||
"@types/q@^1.5.1":
|
"@types/q@^1.5.1":
|
||||||
version "1.5.2"
|
version "1.5.2"
|
||||||
resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8"
|
resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8"
|
||||||
|
@ -2186,6 +2191,22 @@ cookie-signature@1.0.6:
|
||||||
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
|
resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c"
|
||||||
integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw=
|
integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw=
|
||||||
|
|
||||||
|
cookie-universal-nuxt@^2.0.16:
|
||||||
|
version "2.0.16"
|
||||||
|
resolved "https://registry.yarnpkg.com/cookie-universal-nuxt/-/cookie-universal-nuxt-2.0.16.tgz#8d528098c973162b352199240e40da0e5429b13f"
|
||||||
|
integrity sha512-wRK2zw8w+a5xPehb5kLbgOic/4mbjl2exUCxWZwGuttcwsFgOymiwDrCOzmQslqrDevPDL2SsBbH6wtOm7dB9g==
|
||||||
|
dependencies:
|
||||||
|
"@types/cookie" "^0.3.1"
|
||||||
|
cookie-universal "^2.0.16"
|
||||||
|
|
||||||
|
cookie-universal@^2.0.16:
|
||||||
|
version "2.0.16"
|
||||||
|
resolved "https://registry.yarnpkg.com/cookie-universal/-/cookie-universal-2.0.16.tgz#ec8b55789b502a377ef02ad230923c1dfa5c1061"
|
||||||
|
integrity sha512-EHtQ5Tg3UoUHG7LmeV3rlV3iYthkhUuYZ0y86EseypxGcUuvzxuHExEb6mHKDhDPrIrdewAHdG/aCHuG/T4zEg==
|
||||||
|
dependencies:
|
||||||
|
"@types/cookie" "^0.3.1"
|
||||||
|
cookie "^0.3.1"
|
||||||
|
|
||||||
cookie@0.4.0:
|
cookie@0.4.0:
|
||||||
version "0.4.0"
|
version "0.4.0"
|
||||||
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
|
resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba"
|
||||||
|
@ -4200,6 +4221,11 @@ jsprim@^1.2.2:
|
||||||
json-schema "0.2.3"
|
json-schema "0.2.3"
|
||||||
verror "1.10.0"
|
verror "1.10.0"
|
||||||
|
|
||||||
|
jwt-decode@^2.2.0:
|
||||||
|
version "2.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-2.2.0.tgz#7d86bd56679f58ce6a84704a657dd392bba81a79"
|
||||||
|
integrity sha1-fYa9VmefWM5qhHBKZX3TkruoGnk=
|
||||||
|
|
||||||
kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
|
kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
|
||||||
version "3.2.2"
|
version "3.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"
|
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-3.2.2.tgz#31ea21a734bab9bbb0f32466d893aea51e4a3c64"
|
||||||
|
@ -4317,11 +4343,6 @@ lodash.memoize@^4.1.2:
|
||||||
resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
|
resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
|
||||||
integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=
|
integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=
|
||||||
|
|
||||||
lodash.merge@^4.6.1:
|
|
||||||
version "4.6.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.1.tgz#adc25d9cb99b9391c59624f379fbba60d7111d54"
|
|
||||||
integrity sha512-AOYza4+Hf5z1/0Hztxpm2/xiPZgi/cjMqdnKTUWTBSKchJlxXXuUSxCCl8rJlf4g6yww/j6mA8nC8Hw/EZWxKQ==
|
|
||||||
|
|
||||||
lodash.tail@^4.1.1:
|
lodash.tail@^4.1.1:
|
||||||
version "4.1.1"
|
version "4.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/lodash.tail/-/lodash.tail-4.1.1.tgz#d2333a36d9e7717c8ad2f7cacafec7c32b444664"
|
resolved "https://registry.yarnpkg.com/lodash.tail/-/lodash.tail-4.1.1.tgz#d2333a36d9e7717c8ad2f7cacafec7c32b444664"
|
||||||
|
|
|
@ -25,20 +25,19 @@ export default {
|
||||||
/*
|
/*
|
||||||
** Plugins to load before mounting the App
|
** Plugins to load before mounting the App
|
||||||
*/
|
*/
|
||||||
plugins: [{ src: '@/plugins/Vuelidate.js' }],
|
plugins: [{ src: '@/plugins/auth.js' }, { src: '@/plugins/vuelidate.js' }],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
** Nuxt.js modules
|
** Nuxt.js modules
|
||||||
*/
|
*/
|
||||||
modules: [
|
modules: ['@nuxtjs/axios', 'cookie-universal-nuxt'],
|
||||||
// Doc: https://axios.nuxtjs.org/usage
|
|
||||||
'@nuxtjs/axios'
|
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
router: {
|
||||||
** Axios module configuration
|
middleware: 'authentication'
|
||||||
*/
|
},
|
||||||
axios: {
|
|
||||||
// See https://github.com/nuxt-community/axios-module#options
|
env: {
|
||||||
|
// The API base url, this will be prepended to the urls of the remote calls.
|
||||||
|
baseUrl: 'http://localhost:8000/api/v0'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import merge from 'lodash.merge'
|
import _ from 'lodash'
|
||||||
import StyleLintPlugin from 'stylelint-webpack-plugin'
|
import StyleLintPlugin from 'stylelint-webpack-plugin'
|
||||||
|
|
||||||
import base from './nuxt.config.base.js'
|
import base from './nuxt.config.base.js'
|
||||||
|
@ -6,7 +6,6 @@ import base from './nuxt.config.base.js'
|
||||||
const config = {
|
const config = {
|
||||||
build: {
|
build: {
|
||||||
extend(config, ctx) {
|
extend(config, ctx) {
|
||||||
// Run ESLint ad Stylelint on save
|
|
||||||
if (ctx.isDev && ctx.isClient) {
|
if (ctx.isDev && ctx.isClient) {
|
||||||
config.module.rules.push({
|
config.module.rules.push({
|
||||||
enforce: 'pre',
|
enforce: 'pre',
|
||||||
|
@ -25,4 +24,4 @@ const config = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default merge(base, config)
|
export default _.assign(base, config)
|
||||||
|
|
|
@ -3,18 +3,18 @@ import path from 'path'
|
||||||
import base from './nuxt.config.base.js'
|
import base from './nuxt.config.base.js'
|
||||||
|
|
||||||
export default function(rootDir) {
|
export default function(rootDir) {
|
||||||
const merge = require(rootDir + '/node_modules/lodash.merge')
|
const _ = require(rootDir + '/node_modules/lodash')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Because the nuxt source files are located in we web-frontend directory, but
|
* Because the nuxt source files are located in the web-frontend directory,
|
||||||
* the project is started from another directory we have to explicitly set the
|
* but the project is started from another directory we have to explicitly set
|
||||||
* source directory which contains the nuxt files and the root directory which
|
* the source directory which contains the nuxt files and the root directory
|
||||||
* contains the node modules.
|
* which contains the node modules.
|
||||||
*/
|
*/
|
||||||
const config = {
|
const config = {
|
||||||
rootDir: rootDir,
|
rootDir: rootDir,
|
||||||
srcDir: path.resolve(__dirname, '../')
|
srcDir: path.resolve(__dirname, '../')
|
||||||
}
|
}
|
||||||
|
|
||||||
return merge(base, config)
|
return _.assign(base, config)
|
||||||
}
|
}
|
||||||
|
|
17
web-frontend/config/nuxt.config.test.js
Normal file
17
web-frontend/config/nuxt.config.test.js
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import { resolve } from 'path'
|
||||||
|
import _ from 'lodash'
|
||||||
|
|
||||||
|
import base from './nuxt.config.base.js'
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
rootDir: resolve(__dirname, '../'),
|
||||||
|
css: [],
|
||||||
|
dev: false,
|
||||||
|
debug: false,
|
||||||
|
env: {
|
||||||
|
// The API base url, this will be prepended to the urls of the remote calls.
|
||||||
|
baseUrl: 'http://localhost/api/v0'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default _.assign({}, base, config)
|
|
@ -1,4 +1,7 @@
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
testEnvironment: 'node',
|
||||||
|
expand: true,
|
||||||
|
forceExit: true,
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
'^@/(.*)$': '<rootDir>/$1',
|
'^@/(.*)$': '<rootDir>/$1',
|
||||||
'^~/(.*)$': '<rootDir>/$1',
|
'^~/(.*)$': '<rootDir>/$1',
|
||||||
|
|
13
web-frontend/middleware/authenticated.js
Normal file
13
web-frontend/middleware/authenticated.js
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
/**
|
||||||
|
* If this middleware is added to a page, it will redirect back to the login
|
||||||
|
* page if the user is not authenticated.
|
||||||
|
*/
|
||||||
|
export default function({ req, store, redirect }) {
|
||||||
|
// If nuxt generate, pass this middleware
|
||||||
|
if (process.server && !req) return
|
||||||
|
|
||||||
|
// If the user is not authenticated we will redirect him to the login page.
|
||||||
|
if (!store.getters['auth/isAuthenticated']) {
|
||||||
|
redirect('/login')
|
||||||
|
}
|
||||||
|
}
|
16
web-frontend/middleware/authentication.js
Normal file
16
web-frontend/middleware/authentication.js
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
import { getToken } from '@/utils/auth'
|
||||||
|
|
||||||
|
export default function({ store, req, app }) {
|
||||||
|
// If nuxt generate, pass this middleware
|
||||||
|
if (process.server && !req) return
|
||||||
|
|
||||||
|
// Load the token
|
||||||
|
const token = getToken(app.$cookies)
|
||||||
|
|
||||||
|
// If there already is a token we will refresh it to check if it is valid and
|
||||||
|
// to get fresh user information. This will probably happen on the server
|
||||||
|
// side.
|
||||||
|
if (token && !store.getters['auth/isAuthenticated']) {
|
||||||
|
return store.dispatch('auth/refresh', token)
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,13 +11,15 @@
|
||||||
"start": "nuxt start",
|
"start": "nuxt start",
|
||||||
"eslint": "eslint --ext .js,.vue .",
|
"eslint": "eslint --ext .js,.vue .",
|
||||||
"stylelint": "stylelint **/*.scss --syntax scss",
|
"stylelint": "stylelint **/*.scss --syntax scss",
|
||||||
"test": "jest"
|
"test": "jest -i --verbose false test/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^5.8.2",
|
"@fortawesome/fontawesome-free": "^5.8.2",
|
||||||
"@nuxtjs/axios": "^5.3.6",
|
"@nuxtjs/axios": "^5.5.4",
|
||||||
|
"cookie-universal-nuxt": "^2.0.16",
|
||||||
"cross-env": "^5.2.0",
|
"cross-env": "^5.2.0",
|
||||||
"lodash.merge": "^4.6.1",
|
"jwt-decode": "^2.2.0",
|
||||||
|
"lodash": "^4.17.11",
|
||||||
"node-sass": "^4.12.0",
|
"node-sass": "^4.12.0",
|
||||||
"normalize-scss": "^7.0.1",
|
"normalize-scss": "^7.0.1",
|
||||||
"nuxt": "^2.4.0",
|
"nuxt": "^2.4.0",
|
||||||
|
@ -43,6 +45,9 @@
|
||||||
"eslint-plugin-standard": ">=4.0.0",
|
"eslint-plugin-standard": ">=4.0.0",
|
||||||
"eslint-plugin-vue": "^5.2.2",
|
"eslint-plugin-vue": "^5.2.2",
|
||||||
"jest": "^24.1.0",
|
"jest": "^24.1.0",
|
||||||
|
"jsdom": "^15.1.1",
|
||||||
|
"moxios": "^0.4.0",
|
||||||
|
"node-mocks-http": "^1.7.6",
|
||||||
"nodemon": "^1.18.9",
|
"nodemon": "^1.18.9",
|
||||||
"prettier": "^1.16.4",
|
"prettier": "^1.16.4",
|
||||||
"stylelint": "^9.2.1",
|
"stylelint": "^9.2.1",
|
||||||
|
|
18
web-frontend/pages/app/index.vue
Normal file
18
web-frontend/pages/app/index.vue
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h1>Welcome {{ user }}</h1>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { mapState } from 'vuex'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
middleware: 'authenticated',
|
||||||
|
computed: {
|
||||||
|
...mapState({
|
||||||
|
user: state => state.auth.user
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
|
@ -1,5 +1,12 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>{{ test }}</div>
|
||||||
<h1>Baserow</h1>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
fetch({ store, redirect }) {
|
||||||
|
const name = store.getters['auth/isAuthenticated'] ? 'app' : 'login'
|
||||||
|
redirect({ name: name })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
|
@ -3,14 +3,24 @@
|
||||||
<h1 class="box-title">
|
<h1 class="box-title">
|
||||||
<img src="@/static/img/logo.svg" alt="" />
|
<img src="@/static/img/logo.svg" alt="" />
|
||||||
</h1>
|
</h1>
|
||||||
|
<div v-if="invalid" class="alert alert-error alert-has-icon">
|
||||||
|
<div class="alert-icon">
|
||||||
|
<i class="fas fa-exclamation"></i>
|
||||||
|
</div>
|
||||||
|
<div class="alert-title">Incorrect credentials</div>
|
||||||
|
<p class="alert-content">
|
||||||
|
The provided e-mail address or password is incorrect.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<form @submit.prevent="login">
|
<form @submit.prevent="login">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<label class="control-label">E-mail address</label>
|
<label class="control-label">E-mail address</label>
|
||||||
<div class="control-elements">
|
<div class="control-elements">
|
||||||
<input
|
<input
|
||||||
|
ref="email"
|
||||||
v-model="credentials.email"
|
v-model="credentials.email"
|
||||||
:class="{ 'input-error': $v.credentials.email.$error }"
|
:class="{ 'input-error': $v.credentials.email.$error }"
|
||||||
type="text"
|
type="email"
|
||||||
class="input input-large"
|
class="input input-large"
|
||||||
@blur="$v.credentials.email.$touch()"
|
@blur="$v.credentials.email.$touch()"
|
||||||
/>
|
/>
|
||||||
|
@ -23,6 +33,7 @@
|
||||||
<label class="control-label">Password</label>
|
<label class="control-label">Password</label>
|
||||||
<div class="control-elements">
|
<div class="control-elements">
|
||||||
<input
|
<input
|
||||||
|
ref="password"
|
||||||
v-model="credentials.password"
|
v-model="credentials.password"
|
||||||
:class="{ 'input-error': $v.credentials.password.$error }"
|
:class="{ 'input-error': $v.credentials.password.$error }"
|
||||||
type="password"
|
type="password"
|
||||||
|
@ -67,6 +78,7 @@ export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
loading: false,
|
loading: false,
|
||||||
|
invalid: false,
|
||||||
credentials: {
|
credentials: {
|
||||||
email: '',
|
email: '',
|
||||||
password: ''
|
password: ''
|
||||||
|
@ -84,6 +96,23 @@ export default {
|
||||||
this.$v.$touch()
|
this.$v.$touch()
|
||||||
if (!this.$v.$invalid) {
|
if (!this.$v.$invalid) {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
|
this.$store
|
||||||
|
.dispatch('auth/login', {
|
||||||
|
email: this.credentials.email,
|
||||||
|
password: this.credentials.password
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
this.$nuxt.$router.replace({ name: 'app' })
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.invalid = true
|
||||||
|
this.credentials.password = ''
|
||||||
|
this.$v.$reset()
|
||||||
|
this.$refs.password.focus()
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
this.loading = false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,18 @@
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="box-title">Sign up</h1>
|
<h1 class="box-title">Sign up</h1>
|
||||||
|
<div
|
||||||
|
v-if="error == 'ERROR_ALREADY_EXISTS'"
|
||||||
|
class="alert alert-error alert-has-icon"
|
||||||
|
>
|
||||||
|
<div class="alert-icon">
|
||||||
|
<i class="fas fa-exclamation"></i>
|
||||||
|
</div>
|
||||||
|
<div class="alert-title">User already exists</div>
|
||||||
|
<p class="alert-content">
|
||||||
|
A user with the provided e-mail address already exists.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<form @submit.prevent="register">
|
<form @submit.prevent="register">
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<label class="control-label">E-mail address</label>
|
<label class="control-label">E-mail address</label>
|
||||||
|
@ -108,6 +120,7 @@ export default {
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
error: '',
|
||||||
loading: false,
|
loading: false,
|
||||||
account: {
|
account: {
|
||||||
email: '',
|
email: '',
|
||||||
|
@ -122,6 +135,23 @@ export default {
|
||||||
this.$v.$touch()
|
this.$v.$touch()
|
||||||
if (!this.$v.$invalid) {
|
if (!this.$v.$invalid) {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
|
this.error = ''
|
||||||
|
this.$store
|
||||||
|
.dispatch('auth/register', {
|
||||||
|
name: this.account.name,
|
||||||
|
email: this.account.email,
|
||||||
|
password: this.account.password
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
this.$nuxt.$router.replace({ name: 'app' })
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
this.error = error.responseError
|
||||||
|
this.$v.$reset()
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
this.loading = false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
24
web-frontend/plugins/auth.js
Normal file
24
web-frontend/plugins/auth.js
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
import { client } from '@/services/client'
|
||||||
|
|
||||||
|
export default function({ store }) {
|
||||||
|
// Create a request interceptor to add the authorization token to every
|
||||||
|
// request if the user is authenticated.
|
||||||
|
client.interceptors.request.use(config => {
|
||||||
|
if (store.getters['auth/isAuthenticated']) {
|
||||||
|
const token = store.getters['auth/token']
|
||||||
|
config.headers.Authorization = `JWT: ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
// If the user is authenticated, but is not refreshing in the browser means
|
||||||
|
// that the refresh was done on the server side, so we need to manually start
|
||||||
|
// the refreshing timeout here.
|
||||||
|
if (
|
||||||
|
store.getters['auth/isAuthenticated'] &&
|
||||||
|
!store.getters['auth/isRefreshing'] &&
|
||||||
|
process.browser
|
||||||
|
) {
|
||||||
|
store.dispatch('auth/startRefreshTimeout')
|
||||||
|
}
|
||||||
|
}
|
23
web-frontend/services/auth.js
Normal file
23
web-frontend/services/auth.js
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { client } from './client'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
login(username, password) {
|
||||||
|
return client.post('/user/token-auth/', {
|
||||||
|
username,
|
||||||
|
password
|
||||||
|
})
|
||||||
|
},
|
||||||
|
refresh(token) {
|
||||||
|
return client.post('/user/token-refresh/', {
|
||||||
|
token
|
||||||
|
})
|
||||||
|
},
|
||||||
|
register(email, name, password, authenticate = true) {
|
||||||
|
return client.post('/user/', {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
authenticate
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
31
web-frontend/services/client.js
Normal file
31
web-frontend/services/client.js
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
export const client = axios.create({
|
||||||
|
baseURL: process.env.baseUrl,
|
||||||
|
withCredentials: false,
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
client.interceptors.response.use(
|
||||||
|
response => {
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
error => {
|
||||||
|
error.responseError = undefined
|
||||||
|
error.responseDetail = undefined
|
||||||
|
|
||||||
|
if (
|
||||||
|
error.response &&
|
||||||
|
'error' in error.response.data &&
|
||||||
|
'detail' in error.response.data
|
||||||
|
) {
|
||||||
|
error.responseError = error.response.data.error
|
||||||
|
error.responseDetail = error.response.data.detail
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
112
web-frontend/store/auth.js
Normal file
112
web-frontend/store/auth.js
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
import jwtDecode from 'jwt-decode'
|
||||||
|
|
||||||
|
import AuthService from '@/services/auth'
|
||||||
|
import { setToken, unsetToken } from '@/utils/auth'
|
||||||
|
|
||||||
|
export const state = () => ({
|
||||||
|
refreshing: false,
|
||||||
|
token: null,
|
||||||
|
user: null
|
||||||
|
})
|
||||||
|
|
||||||
|
export const mutations = {
|
||||||
|
SET_USER_DATA(state, { token, user }) {
|
||||||
|
state.token = token
|
||||||
|
state.token_data = jwtDecode(token)
|
||||||
|
state.user = user
|
||||||
|
},
|
||||||
|
CLEAR_USER_DATA(state) {
|
||||||
|
state.token = null
|
||||||
|
state.user = null
|
||||||
|
},
|
||||||
|
SET_REFRESHING(state, refreshing) {
|
||||||
|
state.refreshing = refreshing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
/**
|
||||||
|
* Authenticate a user by his email and password. If successful commit the
|
||||||
|
* token to the state and start the refresh timeout to stay authenticated.
|
||||||
|
*/
|
||||||
|
login({ commit, dispatch }, { email, password }) {
|
||||||
|
return AuthService.login(email, password).then(({ data }) => {
|
||||||
|
setToken(data.token, this.app.$cookies)
|
||||||
|
commit('SET_USER_DATA', data)
|
||||||
|
dispatch('startRefreshTimeout')
|
||||||
|
})
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Register a new user and immediately authenticate. If successful commit the
|
||||||
|
* token to the state and start the refresh timeout to stay authenticated.
|
||||||
|
*/
|
||||||
|
register({ commit, dispatch }, { email, name, password }) {
|
||||||
|
return AuthService.register(email, name, password, true).then(
|
||||||
|
({ data }) => {
|
||||||
|
setToken(data.token, this.app.$cookies)
|
||||||
|
commit('SET_USER_DATA', data)
|
||||||
|
dispatch('startRefreshTimeout')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Refresh the existing token. If successful commit the new token and start a
|
||||||
|
* new refresh timeout. If unsuccessful the existing cookie and user data is
|
||||||
|
* cleared.
|
||||||
|
*/
|
||||||
|
refresh({ commit, state, dispatch }, token) {
|
||||||
|
return AuthService.refresh(token)
|
||||||
|
.then(({ data }) => {
|
||||||
|
setToken(data.token, this.app.$cookies)
|
||||||
|
commit('SET_USER_DATA', data)
|
||||||
|
dispatch('startRefreshTimeout')
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// The token could not be refreshed, this means the token is no longer
|
||||||
|
// valid and the user not logged in anymore.
|
||||||
|
unsetToken(this.app.$cookies)
|
||||||
|
commit('CLEAR_USER_DATA')
|
||||||
|
|
||||||
|
// @TODO we might want to do something here, trigger some event, show
|
||||||
|
// show the user a login popup or redirect to the login page.
|
||||||
|
})
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Because the token expires within a configurable time, we need to keep
|
||||||
|
* refreshing the token before that happens. This process may only happen in
|
||||||
|
* the browser because that is where we measure if the user still has the
|
||||||
|
* application open.
|
||||||
|
*/
|
||||||
|
startRefreshTimeout({ getters, commit, dispatch }) {
|
||||||
|
if (!process.browser) return
|
||||||
|
|
||||||
|
clearTimeout(this.refreshTimeout)
|
||||||
|
commit('SET_REFRESHING', true)
|
||||||
|
|
||||||
|
this.refreshTimeout = setTimeout(() => {
|
||||||
|
dispatch('refresh', getters.token)
|
||||||
|
commit('SET_REFRESHING', false)
|
||||||
|
}, (getters.tokenExpireSeconds - 10) * 1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getters = {
|
||||||
|
isAuthenticated(state) {
|
||||||
|
return !!state.user
|
||||||
|
},
|
||||||
|
isRefreshing(state) {
|
||||||
|
return state.refresh
|
||||||
|
},
|
||||||
|
token(state) {
|
||||||
|
return state.token
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* Returns the amount of seconds it will take before the tokes expires.
|
||||||
|
* @TODO figure out what happens if the browser and server time and not very
|
||||||
|
* much in sync.
|
||||||
|
*/
|
||||||
|
tokenExpireSeconds(state) {
|
||||||
|
const now = Math.ceil(new Date().getTime() / 1000)
|
||||||
|
return state.token_data.exp - now
|
||||||
|
}
|
||||||
|
}
|
20
web-frontend/test/config/config.spec.js
Normal file
20
web-frontend/test/config/config.spec.js
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import createNuxt from '@/test/helpers/create-nuxt'
|
||||||
|
|
||||||
|
let nuxt = null
|
||||||
|
|
||||||
|
describe('Config', () => {
|
||||||
|
beforeAll(async done => {
|
||||||
|
nuxt = await createNuxt(true)
|
||||||
|
done()
|
||||||
|
}, 120000)
|
||||||
|
|
||||||
|
test('Plugins', () => {
|
||||||
|
const plugins = nuxt.options.plugins.map(option => option.src)
|
||||||
|
expect(plugins.includes('@/plugins/auth.js')).toBe(true)
|
||||||
|
expect(plugins.includes('@/plugins/vuelidate.js')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await nuxt.close()
|
||||||
|
})
|
||||||
|
})
|
13
web-frontend/test/helpers/create-nuxt.js
Normal file
13
web-frontend/test/helpers/create-nuxt.js
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
import { Builder, Nuxt } from 'nuxt'
|
||||||
|
|
||||||
|
import config from '@/config/nuxt.config.test'
|
||||||
|
|
||||||
|
export default async function createNuxt(buildAndListen = false) {
|
||||||
|
const nuxt = new Nuxt(config)
|
||||||
|
await nuxt.ready()
|
||||||
|
if (buildAndListen) {
|
||||||
|
await new Builder(nuxt).build()
|
||||||
|
await nuxt.server.listen(3001, 'localhost')
|
||||||
|
}
|
||||||
|
return nuxt
|
||||||
|
}
|
|
@ -1,9 +1,56 @@
|
||||||
import { mount } from '@vue/test-utils'
|
import moxios from 'moxios'
|
||||||
import Index from '@/pages/index.vue'
|
import httpMocks from 'node-mocks-http'
|
||||||
|
|
||||||
describe('Home', () => {
|
import createNuxt from '@/test/helpers/create-nuxt'
|
||||||
test('is a Vue instance', () => {
|
|
||||||
const wrapper = mount(Index)
|
let nuxt = null
|
||||||
expect(wrapper.isVueInstance()).toBeTruthy()
|
|
||||||
|
describe('index redirect', () => {
|
||||||
|
beforeAll(async done => {
|
||||||
|
moxios.install()
|
||||||
|
|
||||||
|
// Because the token 'test1' exists it will be refreshed immediately, the
|
||||||
|
// refresh endpoint is stubbed so that it will always provide a valid
|
||||||
|
// unexpired token.
|
||||||
|
moxios.stubRequest('http://localhost/api/v0/user/token-refresh/', {
|
||||||
|
status: 200,
|
||||||
|
response: {
|
||||||
|
token:
|
||||||
|
'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxLCJ1c2' +
|
||||||
|
'VybmFtZSI6InRlc3RAdGVzdCIsImV4cCI6MTk5OTk5OTk5OSwiZW1haWwiO' +
|
||||||
|
'iJ0ZXN0QHRlc3QubmwiLCJvcmlnX2lhdCI6MTU2Mjc3MzQxNH0.2i0gqrcH' +
|
||||||
|
'5uy7mk4kf3LoLpZYXoyMrOfi0fDQneVcaFE',
|
||||||
|
user: {
|
||||||
|
first_name: 'Test',
|
||||||
|
username: 'test@test.nl'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
nuxt = await createNuxt(true)
|
||||||
|
done()
|
||||||
|
}, 120000)
|
||||||
|
|
||||||
|
test('if not authenticated', async () => {
|
||||||
|
const { redirected } = await nuxt.server.renderRoute('/')
|
||||||
|
expect(redirected.path).toBe('/login')
|
||||||
|
expect(redirected.status).toBe(302)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('if authenticated', async () => {
|
||||||
|
const req = httpMocks.createRequest({
|
||||||
|
headers: {
|
||||||
|
cookie: 'jwt_token=test1'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const res = httpMocks.createResponse()
|
||||||
|
const { redirected } = await nuxt.server.renderRoute('/', { req, res })
|
||||||
|
expect(redirected.path).toBe('/app')
|
||||||
|
expect(redirected.status).toBe(302)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await nuxt.close()
|
||||||
|
moxios.uninstall()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
19
web-frontend/test/pages/login/index.spec.js
Normal file
19
web-frontend/test/pages/login/index.spec.js
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import createNuxt from '@/test/helpers/create-nuxt'
|
||||||
|
|
||||||
|
let nuxt = null
|
||||||
|
|
||||||
|
describe('children', () => {
|
||||||
|
beforeAll(async done => {
|
||||||
|
nuxt = await createNuxt(true)
|
||||||
|
done()
|
||||||
|
}, 120000)
|
||||||
|
|
||||||
|
test('/login', async () => {
|
||||||
|
const { html } = await nuxt.server.renderRoute('/login')
|
||||||
|
expect(html).toContain('Login')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await nuxt.close()
|
||||||
|
})
|
||||||
|
})
|
19
web-frontend/test/pages/login/signup.spec.js
Normal file
19
web-frontend/test/pages/login/signup.spec.js
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
import createNuxt from '@/test/helpers/create-nuxt'
|
||||||
|
|
||||||
|
let nuxt = null
|
||||||
|
|
||||||
|
describe('children', () => {
|
||||||
|
beforeAll(async done => {
|
||||||
|
nuxt = await createNuxt(true)
|
||||||
|
done()
|
||||||
|
}, 120000)
|
||||||
|
|
||||||
|
test('/login', async () => {
|
||||||
|
const { html } = await nuxt.server.renderRoute('/login/signup')
|
||||||
|
expect(html).toContain('Sign up')
|
||||||
|
})
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await nuxt.close()
|
||||||
|
})
|
||||||
|
})
|
15
web-frontend/utils/auth.js
Normal file
15
web-frontend/utils/auth.js
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
const cookieTokenName = 'jwt_token'
|
||||||
|
|
||||||
|
export const setToken = (token, cookie) => {
|
||||||
|
if (process.SERVER_BUILD) return
|
||||||
|
cookie.set(cookieTokenName, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const unsetToken = cookie => {
|
||||||
|
if (process.SERVER_BUILD) return
|
||||||
|
cookie.remove(cookieTokenName)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getToken = cookie => {
|
||||||
|
return cookie.get(cookieTokenName)
|
||||||
|
}
|
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue