1
0
Fork 0
mirror of https://gitlab.com/bramw/baserow.git synced 2025-04-10 23:50:12 +00:00

Merge branch '187-disable-signup' into 'develop'

Resolve "Disable Signup"

Closes 

See merge request 
This commit is contained in:
Bram Wiepjes 2021-02-17 20:36:11 +00:00
commit 78ef2c2942
43 changed files with 854 additions and 134 deletions

View file

@ -0,0 +1,12 @@
from rest_framework import serializers
from baserow.core.models import Settings
class SettingsSerializer(serializers.ModelSerializer):
class Meta:
model = Settings
fields = ('allow_new_signups',)
extra_kwargs = {
'allow_new_signups': {'required': False},
}

View file

@ -0,0 +1,11 @@
from django.conf.urls import url
from .views import SettingsView, UpdateSettingsView
app_name = 'baserow.api.settings'
urlpatterns = [
url(r'^update/$', UpdateSettingsView.as_view(), name='update'),
url(r'^$', SettingsView.as_view(), name='get'),
]

View file

@ -0,0 +1,58 @@
from django.db import transaction
from drf_spectacular.utils import extend_schema
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import AllowAny, IsAdminUser
from baserow.api.decorators import validate_body
from baserow.core.handler import CoreHandler
from .serializers import SettingsSerializer
class SettingsView(APIView):
permission_classes = (AllowAny,)
@extend_schema(
tags=['Settings'],
operation_id='get_settings',
description='Responds with all the admin configured settings.',
responses={
200: SettingsSerializer,
},
auth=[None],
)
def get(self, request):
"""
Responds with all the admin configured settings.
"""
settings = CoreHandler().get_settings()
return Response(SettingsSerializer(settings).data)
class UpdateSettingsView(APIView):
permission_classes = (IsAdminUser,)
@extend_schema(
tags=['Settings'],
operation_id='update_settings',
description=(
'Updates the admin configured settings if the user has admin permissions.'
),
request=SettingsSerializer,
responses={
200: SettingsSerializer,
},
)
@validate_body(SettingsSerializer)
@transaction.atomic
def patch(self, request, data):
"""
Updates the provided config settings if the user has admin permissions.
"""
settings = CoreHandler().update_settings(request.user, **data)
return Response(SettingsSerializer(settings).data)

View file

@ -4,6 +4,7 @@ from drf_spectacular.views import SpectacularJSONAPIView, SpectacularRedocView
from baserow.core.registries import plugin_registry, application_type_registry
from .settings import urls as settings_urls
from .user import urls as user_urls
from .user_files import urls as user_files_urls
from .groups import urls as group_urls
@ -19,6 +20,7 @@ urlpatterns = [
SpectacularRedocView.as_view(url_name='api:json_schema'),
name='redoc'
),
path('settings/', include(settings_urls, namespace='settings')),
path('user/', include(user_urls, namespace='user')),
path('user-files/', include(user_files_urls, namespace='user_files')),
path('groups/', include(group_urls, namespace='groups')),

View file

@ -1,3 +1,4 @@
ERROR_ALREADY_EXISTS = 'ERROR_EMAIL_ALREADY_EXISTS'
ERROR_USER_NOT_FOUND = 'ERROR_USER_NOT_FOUND'
ERROR_INVALID_OLD_PASSWORD = 'ERROR_INVALID_OLD_PASSWORD'
ERROR_DISABLED_SIGNUP = 'ERROR_DISABLED_SIGNUP'

View file

@ -13,11 +13,10 @@ User = get_user_model()
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ('first_name', 'username', 'password')
fields = ('first_name', 'username', 'password', 'is_staff')
extra_kwargs = {
'password': {
'write_only': True
}
'password': {'write_only': True},
'is_staff': {'read_only': True},
}

View file

@ -30,7 +30,7 @@ from baserow.core.exceptions import (
from baserow.core.models import GroupInvitation
from baserow.core.user.handler import UserHandler
from baserow.core.user.exceptions import (
UserAlreadyExist, UserNotFound, InvalidPassword
UserAlreadyExist, UserNotFound, InvalidPassword, DisabledSignupError
)
from .serializers import (
@ -39,7 +39,8 @@ from .serializers import (
NormalizedEmailWebTokenSerializer, DashboardSerializer
)
from .errors import (
ERROR_ALREADY_EXISTS, ERROR_USER_NOT_FOUND, ERROR_INVALID_OLD_PASSWORD
ERROR_ALREADY_EXISTS, ERROR_USER_NOT_FOUND, ERROR_INVALID_OLD_PASSWORD,
ERROR_DISABLED_SIGNUP
)
from .schemas import create_user_response_schema, authenticate_user_schema
@ -146,7 +147,8 @@ class UserView(APIView):
UserAlreadyExist: ERROR_ALREADY_EXISTS,
BadSignature: BAD_TOKEN_SIGNATURE,
GroupInvitationDoesNotExist: ERROR_GROUP_INVITATION_DOES_NOT_EXIST,
GroupInvitationEmailMismatch: ERROR_GROUP_INVITATION_EMAIL_MISMATCH
GroupInvitationEmailMismatch: ERROR_GROUP_INVITATION_EMAIL_MISMATCH,
DisabledSignupError: ERROR_DISABLED_SIGNUP
})
@validate_body(RegisterSerializer)
def post(self, request, data):

View file

@ -185,6 +185,7 @@ SPECTACULAR_SETTINGS = {
'VERSION': '0.8.0',
'SERVE_INCLUDE_SCHEMA': False,
'TAGS': [
{'name': 'Settings'},
{'name': 'User'},
{'name': 'User files'},
{'name': 'Groups'},

View file

@ -1,3 +1,10 @@
class IsNotAdminError(Exception):
"""
Raised when the user tries to perform an action that is not allowed because he
does not have admin permissions.
"""
class UserNotInGroupError(Exception):
"""Raised when the user doesn't have access to the related group."""

View file

@ -6,13 +6,13 @@ from django.conf import settings
from baserow.core.user.utils import normalize_email_address
from .models import (
Group, GroupUser, GroupInvitation, Application, GROUP_USER_PERMISSION_CHOICES,
GROUP_USER_PERMISSION_ADMIN
Settings, Group, GroupUser, GroupInvitation, Application,
GROUP_USER_PERMISSION_CHOICES, GROUP_USER_PERMISSION_ADMIN
)
from .exceptions import (
GroupDoesNotExist, ApplicationDoesNotExist, BaseURLHostnameNotAllowed,
GroupInvitationEmailMismatch, GroupInvitationDoesNotExist, GroupUserDoesNotExist,
GroupUserAlreadyExists
GroupUserAlreadyExists, IsNotAdminError
)
from .utils import extract_allowed, set_allowed_attrs
from .registries import application_type_registry
@ -24,6 +24,46 @@ from .emails import GroupInvitationEmail
class CoreHandler:
def get_settings(self):
"""
Returns a settings model instance containing all the admin configured settings.
:return: The settings instance.
:rtype: Settings
"""
try:
return Settings.objects.all()[:1].get()
except Settings.DoesNotExist:
return Settings.objects.create()
def update_settings(self, user, settings_instance=None, **kwargs):
"""
Updates one or more setting values if the user has staff permissions.
:param user: The user on whose behalf the settings are updated.
:type user: User
:param settings_instance: If already fetched, the settings instance can be
provided to avoid fetching the values for a second time.
:type settings_instance: Settings
:param kwargs: An dict containing the settings that need to be updated.
:type kwargs: dict
:return: The update settings instance.
:rtype: Settings
"""
if not user.is_staff:
raise IsNotAdminError(user)
if not settings_instance:
settings_instance = self.get_settings()
for name, value in kwargs.items():
setattr(settings_instance, name, value)
settings_instance.save()
return settings_instance
def get_group(self, group_id, base_queryset=None):
"""
Selects a group with a given id from the database.

View file

@ -0,0 +1,29 @@
# Generated by Django 2.2.11 on 2021-02-15 13:08
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0004_auto_20210126_1950'),
]
operations = [
migrations.CreateModel(
name='Settings',
fields=[
('id', models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name='ID'
)),
('allow_new_signups', models.BooleanField(
default=True,
help_text='Indicates whether new users can create a new account '
'when signing up.'
)),
],
),
]

View file

@ -31,6 +31,19 @@ def get_default_application_content_type():
return ContentType.objects.get_for_model(Application)
class Settings(models.Model):
"""
The settings model represents the application wide settings that only admins can
change. This table can only contain a single row.
"""
allow_new_signups = models.BooleanField(
default=True,
help_text='Indicates whether new users can create a new account when signing '
'up.'
)
class Group(CreatedAndUpdatedOnMixin, models.Model):
name = models.CharField(max_length=100)
users = models.ManyToManyField(User, through='GroupUser')

View file

@ -8,3 +8,9 @@ class UserAlreadyExist(Exception):
class InvalidPassword(Exception):
"""Raised when the provided password is incorrect."""
class DisabledSignupError(Exception):
"""
Raised when a user account is created when the new signup setting is disabled.
"""

View file

@ -12,7 +12,9 @@ from baserow.core.exceptions import (
)
from baserow.core.exceptions import GroupInvitationEmailMismatch
from .exceptions import UserAlreadyExist, UserNotFound, InvalidPassword
from .exceptions import (
UserAlreadyExist, UserNotFound, InvalidPassword, DisabledSignupError
)
from .emails import ResetPasswordEmail
from .utils import normalize_email_address
@ -72,10 +74,14 @@ class UserHandler:
already exists.
:raises GroupInvitationEmailMismatch: If the group invitation email does not
match the one of the user.
:raises SignupDisabledError: If signing up is disabled.
:return: The user object.
:rtype: User
"""
if not CoreHandler().get_settings().allow_new_signups:
raise DisabledSignupError('Sign up is disabled.')
email = normalize_email_address(email)
if User.objects.filter(Q(email=email) | Q(username=email)).exists():

View file

@ -139,5 +139,5 @@ class CoreConsumer(AsyncJsonWebsocketConsumer):
await self.send_json(payload)
async def disconnect(self, message):
self.discard_current_page()
await self.discard_current_page()
await self.channel_layer.group_discard('users', self.channel_name)

View file

@ -0,0 +1,75 @@
import pytest
from rest_framework.status import (
HTTP_200_OK, HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN
)
from django.shortcuts import reverse
from baserow.core.models import Settings
from baserow.core.handler import CoreHandler
@pytest.mark.django_db
def test_get_settings(api_client):
response = api_client.get(reverse('api:settings:get'))
assert response.status_code == HTTP_200_OK
response_json = response.json()
assert response_json['allow_new_signups'] is True
settings = Settings.objects.first()
settings.allow_new_signups = False
settings.save()
response = api_client.get(reverse('api:settings:get'))
assert response.status_code == HTTP_200_OK
response_json = response.json()
assert response_json['allow_new_signups'] is False
@pytest.mark.django_db
def test_update_settings(api_client, data_fixture):
user, token = data_fixture.create_user_and_token(is_staff=True)
user_2, token_2 = data_fixture.create_user_and_token()
response = api_client.patch(
reverse('api:settings:update'),
{'allow_new_signups': False},
format='json',
HTTP_AUTHORIZATION=f'JWT {token_2}'
)
assert response.status_code == HTTP_403_FORBIDDEN
assert CoreHandler().get_settings().allow_new_signups is True
response = api_client.patch(
reverse('api:settings:update'),
{'allow_new_signups': {}},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
assert response.status_code == HTTP_400_BAD_REQUEST
response_json = response.json()
assert response_json['error'] == 'ERROR_REQUEST_BODY_VALIDATION'
assert response_json['detail']['allow_new_signups'][0]['code'] == 'invalid'
response = api_client.patch(
reverse('api:settings:update'),
{'allow_new_signups': False},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
assert response.status_code == HTTP_200_OK
response_json = response.json()
assert response_json['allow_new_signups'] is False
assert CoreHandler().get_settings().allow_new_signups is False
response = api_client.patch(
reverse('api:settings:update'),
{},
format='json',
HTTP_AUTHORIZATION=f'JWT {token}'
)
assert response.status_code == HTTP_200_OK
response_json = response.json()
assert response_json['allow_new_signups'] is False
assert CoreHandler().get_settings().allow_new_signups is False

View file

@ -53,6 +53,7 @@ def test_token_auth(api_client, data_fixture):
assert 'user' in json
assert json['user']['username'] == 'test@test.nl'
assert json['user']['first_name'] == 'Test1'
assert json['user']['is_staff'] is False
user.refresh_from_db()
assert user.last_login == datetime(2020, 1, 1, 12, 00, tzinfo=timezone('UTC'))
@ -68,6 +69,7 @@ def test_token_auth(api_client, data_fixture):
assert 'user' in json
assert json['user']['username'] == 'test@test.nl'
assert json['user']['first_name'] == 'Test1'
assert json['user']['is_staff'] is False
user.refresh_from_db()
assert user.last_login == datetime(2020, 1, 2, 12, 00, tzinfo=timezone('UTC'))
@ -85,7 +87,12 @@ def test_token_refresh(api_client, data_fixture):
response = api_client.post(reverse('api:user:token_refresh'),
{'token': token}, format='json')
assert response.status_code == HTTP_200_OK
assert 'token' in response.json()
json = response.json()
assert 'token' in json
assert 'user' in json
assert json['user']['username'] == 'test@test.nl'
assert json['user']['first_name'] == 'Test1'
assert json['user']['is_staff'] is False
with patch('rest_framework_jwt.utils.datetime') as mock_datetime:
mock_datetime.utcnow.return_value = datetime(2019, 1, 1, 1, 1, 1, 0)

View file

@ -16,7 +16,7 @@ User = get_user_model()
@pytest.mark.django_db
def test_create_user(client):
def test_create_user(client, data_fixture):
response = client.post(reverse('api:user:index'), {
'name': 'Test1',
'email': 'test@test.nl',
@ -31,6 +31,7 @@ def test_create_user(client):
assert 'password' not in response_json['user']
assert response_json['user']['username'] == 'test@test.nl'
assert response_json['user']['first_name'] == 'Test1'
assert response_json['user']['is_staff'] is False
response_failed = client.post(reverse('api:user:index'), {
'name': 'Test1',
@ -48,6 +49,16 @@ def test_create_user(client):
assert response_failed.status_code == 400
assert response_failed.json()['error'] == 'ERROR_EMAIL_ALREADY_EXISTS'
data_fixture.update_settings(allow_new_signups=False)
response_failed = client.post(reverse('api:user:index'), {
'name': 'Test1',
'email': 'test10@test.nl',
'password': 'test12'
}, format='json')
assert response_failed.status_code == 400
assert response_failed.json()['error'] == 'ERROR_DISABLED_SIGNUP'
data_fixture.update_settings(allow_new_signups=True)
response_failed_2 = client.post(reverse('api:user:index'), {
'email': 'test'
}, format='json')

View file

@ -7,17 +7,40 @@ from django.db import connection
from baserow.core.handler import CoreHandler
from baserow.core.models import (
Group, GroupUser, GroupInvitation, Application, GROUP_USER_PERMISSION_ADMIN
Settings, Group, GroupUser, GroupInvitation, Application,
GROUP_USER_PERMISSION_ADMIN
)
from baserow.core.exceptions import (
UserNotInGroupError, ApplicationTypeDoesNotExist, GroupDoesNotExist,
GroupUserDoesNotExist, ApplicationDoesNotExist, UserInvalidGroupPermissionsError,
BaseURLHostnameNotAllowed, GroupInvitationEmailMismatch,
GroupInvitationDoesNotExist, GroupUserAlreadyExists
GroupInvitationDoesNotExist, GroupUserAlreadyExists, IsNotAdminError
)
from baserow.contrib.database.models import Database, Table
@pytest.mark.django_db
def test_get_settings():
settings = CoreHandler().get_settings()
assert isinstance(settings, Settings)
assert settings.allow_new_signups is True
@pytest.mark.django_db
def test_update_settings(data_fixture):
user_1 = data_fixture.create_user(is_staff=True)
user_2 = data_fixture.create_user()
with pytest.raises(IsNotAdminError):
CoreHandler().update_settings(user_2, allow_new_signups=False)
settings = CoreHandler().update_settings(user_1, allow_new_signups=False)
assert settings.allow_new_signups is False
settings = Settings.objects.all().first()
assert settings.allow_new_signups is False
@pytest.mark.django_db
def test_get_group(data_fixture):
user_1 = data_fixture.create_user()

View file

@ -5,6 +5,8 @@ from freezegun import freeze_time
from itsdangerous.exc import SignatureExpired, BadSignature
from django.contrib.auth import get_user_model
from baserow.core.models import Group, GroupUser
from baserow.core.registries import plugin_registry
from baserow.contrib.database.models import (
@ -17,11 +19,14 @@ from baserow.core.exceptions import (
)
from baserow.core.handler import CoreHandler
from baserow.core.user.exceptions import (
UserAlreadyExist, UserNotFound, InvalidPassword
UserAlreadyExist, UserNotFound, InvalidPassword, DisabledSignupError
)
from baserow.core.user.handler import UserHandler
User = get_user_model()
@pytest.mark.django_db
def test_get_user(data_fixture):
user_1 = data_fixture.create_user(email='user1@localhost')
@ -42,12 +47,18 @@ def test_get_user(data_fixture):
@pytest.mark.django_db
def test_create_user():
def test_create_user(data_fixture):
plugin_mock = MagicMock()
plugin_registry.registry['mock'] = plugin_mock
user_handler = UserHandler()
data_fixture.update_settings(allow_new_signups=False)
with pytest.raises(DisabledSignupError):
user_handler.create_user('Test1', 'test@test.nl', 'password')
assert User.objects.all().count() == 0
data_fixture.update_settings(allow_new_signups=True)
user = user_handler.create_user('Test1', 'test@test.nl', 'password')
assert user.pk
assert user.first_name == 'Test1'

View file

@ -1,5 +1,6 @@
from faker import Faker
from .settings import SettingsFixtures
from .user import UserFixtures
from .user_file import UserFileFixtures
from .group import GroupFixtures
@ -10,6 +11,7 @@ from .field import FieldFixtures
from .token import TokenFixtures
class Fixtures(UserFixtures, UserFileFixtures, GroupFixtures, ApplicationFixtures,
TableFixtures, ViewFixtures, FieldFixtures, TokenFixtures):
class Fixtures(SettingsFixtures, UserFixtures, UserFileFixtures, GroupFixtures,
ApplicationFixtures, TableFixtures, ViewFixtures, FieldFixtures,
TokenFixtures):
fake = Faker()

7
backend/tests/fixtures/settings.py vendored Normal file
View file

@ -0,0 +1,7 @@
from baserow.core.models import Settings
class SettingsFixtures:
def update_settings(self, **kwargs):
settings, created = Settings.objects.update_or_create(defaults=kwargs)
return settings

View file

@ -21,6 +21,7 @@
* Made it possible to configure SMTP settings via environment variables.
* Added field name to the public REST API docs.
* Made the public REST API docs compatible with smaller screens.
* Made it possible for the admin to disable new signups.
* Reduced the amount of queries when using the link row field.
## Released (2021-02-04)

View file

@ -0,0 +1,78 @@
import { Registerable } from '@baserow/modules/core/registry'
/**
* An admin type is visible in the sidebar under the admin menu item. All
* registered admin types are visible in the sidebar to admins and he clicks
* on one he is redirected to the route related to the admin type.
*/
export class AdminType extends Registerable {
/**
* The font awesome 5 icon name that is used as convenience for the user to
* recognize admin types. The icon will for example be displayed in the
* sidebar. If you for example want the database icon, you must return
* 'database' here. This will result in the classname 'fas fa-database'.
*/
getIconClass() {
return null
}
/**
* A human readable name of the admin type. This will be shown in the sidebar
* if the user is an admin.
*/
getName() {
return null
}
getRouteName() {
throw new Error('The route name of an admin type must be set.')
}
constructor() {
super()
this.type = this.getType()
this.iconClass = this.getIconClass()
this.name = this.getName()
this.routeName = this.getRouteName()
if (this.type === null) {
throw new Error('The type name of an admin type must be set.')
}
if (this.iconClass === null) {
throw new Error('The icon class of an admin type must be set.')
}
if (this.name === null) {
throw new Error('The name of an admin type must be set.')
}
}
/**
* @return object
*/
serialize() {
return {
type: this.type,
iconClass: this.iconClass,
name: this.name,
routeName: this.routeName,
}
}
}
export class SettingsAdminType extends AdminType {
static getType() {
return 'settings'
}
getIconClass() {
return 'cogs'
}
getName() {
return 'Settings'
}
getRouteName() {
return 'admin-settings'
}
}

View file

@ -0,0 +1,48 @@
.admin-settings {
padding: 30px;
}
.admin-settings__group {
&:not(:last-child) {
padding-bottom: 30px;
margin-bottom: 30px;
border-bottom: solid 1px $color-neutral-200;
}
}
.admin-settings__group-title {
font-size: 20px;
margin-bottom: 30px;
}
.admin-settings__item {
display: flex;
&:not(:last-child) {
margin-bottom: 30px;
}
}
.admin-settings__label {
flex: 0 0 25%;
max-width: 340px;
min-width: 200px;
margin-right: 40px;
}
.admin-settings__name {
font-size: 14px;
font-weight: 700;
margin-bottom: 12px;
}
.admin-settings__description {
font-size: 13px;
color: $color-neutral-600;
line-height: 160%;
}
.admin-settings__control {
min-width: 0;
width: 100%;
}

View file

@ -60,3 +60,4 @@
@import 'group_member';
@import 'separator';
@import 'quote';
@import 'admin_settings';

View file

@ -147,6 +147,10 @@
.layout--collapsed {
// Some minor changes regarding the tree items within the collapsed sidebar.
.tree .sidebar__tree {
padding-left: 0;
}
.sidebar__action {
.tree__link {
text-align: center;
@ -157,7 +161,6 @@
}
.sidebar__item-name {
margin-top: -10.5px;
background-color: $color-neutral-900;
color: $white;
border-radius: 3px;
@ -166,7 +169,7 @@
font-weight: 400;
display: none;
@include absolute(50%, auto, auto, 36px);
@include absolute(6px, auto, auto, 36px);
@include center-text(auto, 11px, 21px);
}

View file

@ -5,6 +5,7 @@
.tree__item & {
padding-left: 8px;
margin-top: 6px;
}
}
@ -38,7 +39,7 @@
padding: 0 6px;
border-radius: 3px;
&:hover {
&:not(.tree__action--disabled):hover {
background-color: $color-neutral-100;
}
@ -67,6 +68,10 @@
&.tree__link--group {
font-weight: 600;
}
.tree__action--disabled &:hover {
cursor: inherit;
}
}
.tree__icon {

View file

@ -67,6 +67,42 @@
</nuxt-link>
</div>
</li>
<li v-if="isStaff" class="tree__item">
<div
class="tree__action sidebar__action"
:class="{ 'tree__action--disabled': isAdminPage }"
>
<a class="tree__link" @click.prevent="admin()">
<i class="tree__icon fas fa-users-cog"></i>
<span class="sidebar__item-name">Admin</span>
</a>
</div>
<ul v-show="isAdminPage" class="tree sidebar__tree">
<li
v-for="adminType in adminTypes"
:key="adminType.type"
class="tree__item"
:class="{
active: $route.matched.some(
({ name }) => name === adminType.routeName
),
}"
>
<div class="tree__action sidebar__action">
<nuxt-link
:to="{ name: adminType.routeName }"
class="tree__link"
>
<i
class="tree__icon fas"
:class="'fa-' + adminType.iconClass"
></i>
<span class="sidebar__item-name">{{ adminType.name }}</span>
</nuxt-link>
</div>
</li>
</ul>
</li>
<template v-if="hasSelectedGroup && !isCollapsed">
<li class="tree__item margin-top-2">
<div class="tree__action">
@ -208,12 +244,26 @@ export default {
this.selectedGroup
)
},
adminTypes() {
return this.$registry.getAll('admin')
},
/**
* Indicates whether the current user is visiting an admin page.
*/
isAdminPage() {
return Object.values(this.adminTypes).some((adminType) => {
return this.$route.matched.some(
({ name }) => name === adminType.routeName
)
})
},
...mapState({
allApplications: (state) => state.application.items,
groups: (state) => state.group.items,
selectedGroup: (state) => state.group.selected,
}),
...mapGetters({
isStaff: 'auth/isStaff',
name: 'auth/getName',
email: 'auth/getUsername',
hasSelectedGroup: 'group/hasSelected',
@ -225,6 +275,24 @@ export default {
this.$store.dispatch('auth/logoff')
this.$nuxt.$router.push({ name: 'login' })
},
/**
* Called when the user clicks on the admin menu. Because there isn't an
* admin page it will navigate to the route of the first registered admin
* type.
*/
admin() {
// If the user is already on an admin page we don't have to do anything because
// the link is disabled.
if (this.isAdminPage) {
return
}
const types = Object.values(this.adminTypes)
if (types.length > 0) {
this.$nuxt.$router.push({ name: types[0].routeName })
}
},
},
}
</script>

View file

@ -23,7 +23,7 @@ export default {
Notifications,
Sidebar,
},
middleware: ['authenticated', 'groupsAndApplications'],
middleware: ['settings', 'authenticated', 'groupsAndApplications'],
computed: {
...mapGetters({
isCollapsed: 'sidebar/isCollapsed',

View file

@ -15,5 +15,6 @@ import Notifications from '@baserow/modules/core/components/notifications/Notifi
export default {
components: { Notifications },
middleware: ['settings'],
}
</script>

View file

@ -1,10 +1,14 @@
import settings from '@baserow/modules/core/middleware/settings'
import authentication from '@baserow/modules/core/middleware/authentication'
import authenticated from '@baserow/modules/core/middleware/authenticated'
import staff from '@baserow/modules/core/middleware/staff'
import groupsAndApplications from '@baserow/modules/core/middleware/groupsAndApplications'
/* eslint-disable-next-line */
import Middleware from './middleware'
Middleware.settings = settings
Middleware.authentication = authentication
Middleware.authenticated = authenticated
Middleware.staff = staff
Middleware.groupsAndApplications = groupsAndApplications

View file

@ -0,0 +1,11 @@
/**
* This middleware makes sure the settings are fetched and available in the store.
*/
export default async function ({ store, req }) {
// If nuxt generate, pass this middleware
if (process.server && !req) return
if (!store.getters['settings/isLoaded']) {
await store.dispatch('settings/load')
}
}

View file

@ -0,0 +1,13 @@
/**
* This middleware makes sure that the current user is admin else a 403 error
* will be shown to the user.
*/
export default function ({ store, req, error }) {
// If nuxt generate, pass this middleware
if (process.server && !req) return
// If the user is not staff we want to show a forbidden error.
if (!store.getters['auth/isStaff']) {
return error({ statusCode: 403, message: 'Forbidden.' })
}
}

View file

@ -0,0 +1,52 @@
<template>
<div class="layout__col-2-scroll">
<div class="admin-settings">
<h1>Admin settings</h1>
<div class="admin-settings__group">
<h2 class="admin-settings__group-title">Signup restrictions</h2>
<div class="admin-settings__item">
<div class="admin-settings__label">
<div class="admin-settings__name">Allow creating new accounts</div>
<div class="admin-settings__description">
By default, any user visiting your Baserow domain can sign up for
a new account.
</div>
</div>
<div class="admin-settings__control">
<SwitchInput
:value="settings.allow_new_signups"
:large="true"
@input="updateSettings({ allow_new_signups: $event })"
>enabled</SwitchInput
>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import { notifyIf } from '@baserow/modules/core/utils/error'
export default {
layout: 'app',
middleware: 'staff',
computed: {
...mapGetters({
settings: 'settings/get',
}),
},
methods: {
async updateSettings(values) {
try {
await this.$store.dispatch('settings/update', values)
} catch (error) {
notifyIf(error, 'settings')
}
},
},
}
</script>

View file

@ -64,7 +64,7 @@
</div>
<div class="actions">
<ul class="action__links">
<li>
<li v-if="settings.allow_new_signups">
<nuxt-link :to="{ name: 'signup' }"> Sign up </nuxt-link>
</li>
<li>
@ -87,6 +87,8 @@
</template>
<script>
import { mapGetters } from 'vuex'
import { required, email } from 'vuelidate/lib/validators'
import error from '@baserow/modules/core/mixins/error'
import groupInvitationToken from '@baserow/modules/core/mixins/groupInvitationToken'
@ -115,6 +117,11 @@ export default {
],
}
},
computed: {
...mapGetters({
settings: 'settings/get',
}),
},
beforeMount() {
if (this.invitation !== null) {
this.credentials.email = this.invitation.email

View file

@ -1,122 +1,144 @@
<template>
<div>
<h1 class="box__title">Sign up</h1>
<div
v-if="invitation !== null"
class="alert alert--simple alert-primary alert--has-icon"
>
<div class="alert__icon">
<i class="fas fa-exclamation"></i>
<template v-if="!settings.allow_new_signups">
<div class="alert alert--simple alert--error alert--has-icon">
<div class="alert__icon">
<i class="fas fa-exclamation"></i>
</div>
<div class="alert__title">Sign up is disabled</div>
<p class="alert__content">
It's not possible to create an account because it has been disabled.
</p>
</div>
<div class="alert__title">Invitation</div>
<p class="alert__content">
<strong>{{ invitation.invited_by }}</strong> has invited you to join
<strong>{{ invitation.group }}</strong
>.
</p>
</div>
<Error :error="error"></Error>
<form @submit.prevent="register">
<div class="control">
<label class="control__label">E-mail address</label>
<div class="control__elements">
<input
v-if="invitation !== null"
ref="email"
type="email"
class="input input--large"
disabled
:value="account.email"
/>
<input
v-else
ref="email"
v-model="account.email"
:class="{ 'input--error': $v.account.email.$error }"
type="text"
class="input input--large"
@blur="$v.account.email.$touch()"
/>
<div v-if="$v.account.email.$error" class="error">
Please enter a valid e-mail address.
<nuxt-link
:to="{ name: 'login' }"
class="button button--large button--primary"
>
<i class="fas fa-arrow-left"></i>
Back to login
</nuxt-link>
</template>
<template v-else>
<div
v-if="invitation !== null"
class="alert alert--simple alert--primary alert--has-icon"
>
<div class="alert__icon">
<i class="fas fa-exclamation"></i>
</div>
<div class="alert__title">Invitation</div>
<p class="alert__content">
<strong>{{ invitation.invited_by }}</strong> has invited you to join
<strong>{{ invitation.group }}</strong
>.
</p>
</div>
<Error :error="error"></Error>
<form @submit.prevent="register">
<div class="control">
<label class="control__label">E-mail address</label>
<div class="control__elements">
<input
v-if="invitation !== null"
ref="email"
type="email"
class="input input--large"
disabled
:value="account.email"
/>
<input
v-else
ref="email"
v-model="account.email"
:class="{ 'input--error': $v.account.email.$error }"
type="text"
class="input input--large"
@blur="$v.account.email.$touch()"
/>
<div v-if="$v.account.email.$error" class="error">
Please enter a valid e-mail address.
</div>
</div>
</div>
</div>
<div class="control">
<label class="control__label">Your name</label>
<div class="control__elements">
<input
v-model="account.name"
:class="{ 'input--error': $v.account.name.$error }"
type="text"
class="input input--large"
@blur="$v.account.name.$touch()"
/>
<div v-if="$v.account.name.$error" class="error">
A minimum of two characters is required here.
<div class="control">
<label class="control__label">Your name</label>
<div class="control__elements">
<input
v-model="account.name"
:class="{ 'input--error': $v.account.name.$error }"
type="text"
class="input input--large"
@blur="$v.account.name.$touch()"
/>
<div v-if="$v.account.name.$error" class="error">
A minimum of two characters is required here.
</div>
</div>
</div>
</div>
<div class="control">
<label class="control__label">Password</label>
<div class="control__elements">
<input
v-model="account.password"
:class="{ 'input--error': $v.account.password.$error }"
type="password"
class="input input--large"
@blur="$v.account.password.$touch()"
/>
<div
v-if="$v.account.password.$error && !$v.account.password.required"
class="error"
<div class="control">
<label class="control__label">Password</label>
<div class="control__elements">
<input
v-model="account.password"
:class="{ 'input--error': $v.account.password.$error }"
type="password"
class="input input--large"
@blur="$v.account.password.$touch()"
/>
<div
v-if="$v.account.password.$error && !$v.account.password.required"
class="error"
>
A password is required.
</div>
<div
v-if="
$v.account.password.$error && !$v.account.password.maxLength
"
class="error"
>
A maximum of
{{ $v.account.password.$params.maxLength.max }} characters is
allowed here.
</div>
</div>
</div>
<div class="control">
<label class="control__label">Repeat password</label>
<div class="control__elements">
<input
v-model="account.passwordConfirm"
:class="{ 'input--error': $v.account.passwordConfirm.$error }"
type="password"
class="input input--large"
@blur="$v.account.passwordConfirm.$touch()"
/>
<div v-if="$v.account.passwordConfirm.$error" class="error">
This field must match your password field.
</div>
</div>
</div>
<div class="actions">
<ul class="action__links">
<li>
<nuxt-link :to="{ name: 'login' }">
<i class="fas fa-arrow-left"></i>
Back
</nuxt-link>
</li>
</ul>
<button
:class="{ 'button--loading': loading }"
class="button button--large"
:disabled="loading"
>
A password is required.
</div>
<div
v-if="$v.account.password.$error && !$v.account.password.maxLength"
class="error"
>
A maximum of
{{ $v.account.password.$params.maxLength.max }} characters is
allowed here.
</div>
Sign up
<i class="fas fa-user-plus"></i>
</button>
</div>
</div>
<div class="control">
<label class="control__label">Repeat password</label>
<div class="control__elements">
<input
v-model="account.passwordConfirm"
:class="{ 'input--error': $v.account.passwordConfirm.$error }"
type="password"
class="input input--large"
@blur="$v.account.passwordConfirm.$touch()"
/>
<div v-if="$v.account.passwordConfirm.$error" class="error">
This field must match your password field.
</div>
</div>
</div>
<div class="actions">
<ul class="action__links">
<li>
<nuxt-link :to="{ name: 'login' }">
<i class="fas fa-arrow-left"></i>
Back
</nuxt-link>
</li>
</ul>
<button
:class="{ 'button--loading': loading }"
class="button button--large"
:disabled="loading"
>
Sign up
<i class="fas fa-user-plus"></i>
</button>
</div>
</form>
</form>
</template>
</div>
</template>
@ -132,6 +154,7 @@ import {
import { ResponseErrorMessage } from '@baserow/modules/core/plugins/clientHandler'
import groupInvitationToken from '@baserow/modules/core/mixins/groupInvitationToken'
import error from '@baserow/modules/core/mixins/error'
import { mapGetters } from 'vuex'
export default {
mixins: [error, groupInvitationToken],
@ -152,6 +175,11 @@ export default {
title: 'Create new account',
}
},
computed: {
...mapGetters({
settings: 'settings/get',
}),
},
beforeMount() {
if (this.invitation !== null) {
this.account.email = this.invitation.email

View file

@ -7,7 +7,9 @@ import {
UploadFileUserFileUploadType,
UploadViaURLUserFileUploadType,
} from '@baserow/modules/core/userFileUploadTypes'
import { SettingsAdminType } from '@baserow/modules/core/adminTypes'
import settingsStore from '@baserow/modules/core/store/settings'
import applicationStore from '@baserow/modules/core/store/application'
import authStore from '@baserow/modules/core/store/auth'
import groupStore from '@baserow/modules/core/store/group'
@ -27,8 +29,10 @@ export default ({ store, app }, inject) => {
registry.register('settings', new PasswordSettingsType())
registry.register('userFileUpload', new UploadFileUserFileUploadType())
registry.register('userFileUpload', new UploadViaURLUserFileUploadType())
registry.register('admin', new SettingsAdminType())
inject('registry', registry)
store.registerModule('settings', settingsStore)
store.registerModule('application', applicationStore)
store.registerModule('auth', authStore)
store.registerModule('group', groupStore)

View file

@ -36,6 +36,11 @@ export const routes = [
path: '/group-invitation/:token',
component: path.resolve(__dirname, 'pages/groupInvitation.vue'),
},
{
name: 'admin-settings',
path: '/admin/settings',
component: path.resolve(__dirname, 'pages/admin/settings.vue'),
},
{
name: 'style-guide',
path: '/style-guide',

View file

@ -0,0 +1,10 @@
export default (client) => {
return {
get() {
return client.get('/settings/')
},
update(values) {
return client.patch('/settings/update/', values)
},
}
}

View file

@ -145,6 +145,9 @@ export const getters = {
getUsername(state) {
return state.user ? state.user.username : ''
},
isStaff(state) {
return state.user ? state.user.is_staff : false
},
/**
* Returns the amount of seconds it will take before the tokes expires.
* @TODO figure out what happens if the browser and server time are not in

View file

@ -0,0 +1,55 @@
import SettingsService from '@baserow/modules/core/services/settings'
import { clone } from '@baserow/modules/core/utils/object'
export const state = () => ({
loaded: false,
settings: {},
})
export const mutations = {
SET_SETTINGS(state, values) {
state.settings = values
},
UPDATE_SETTINGS(state, values) {
state.settings = Object.assign({}, state.settings, values)
},
SET_LOADED(state, value) {
state.loaded = value
},
}
export const actions = {
async load({ commit }) {
const { data } = await SettingsService(this.$client).get()
commit('SET_SETTINGS', data)
commit('SET_LOADED', true)
},
async update({ commit, getters }, values) {
const oldValues = clone(getters.get)
commit('UPDATE_SETTINGS', values)
try {
await SettingsService(this.$client).update(values)
} catch (e) {
commit('SET_SETTINGS', oldValues)
throw e
}
},
}
export const getters = {
isLoaded(state) {
return state.loaded
},
get(state) {
return state.settings
},
}
export default {
namespaced: true,
state,
getters,
actions,
mutations,
}